@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,458 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { createApp } from "./app";
5
+ import { REDESIGN_INSTRUCTION } from "./feedback";
6
+
7
+ const temporaryDirectories: string[] = [];
8
+ const temporaryRoot = join(import.meta.dir, "..", ".tmp");
9
+ const ISSUE_URL = "https://gitea.example/dsdenes/devboard/issues/42";
10
+
11
+ afterAll(async () => {
12
+ await Promise.all(
13
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
14
+ );
15
+ });
16
+
17
+ async function temporaryDirectory(): Promise<string> {
18
+ await mkdir(temporaryRoot, { recursive: true });
19
+ const directory = await mkdtemp(join(temporaryRoot, "feedback-"));
20
+ temporaryDirectories.push(directory);
21
+ return directory;
22
+ }
23
+
24
+ interface RecordedCall {
25
+ url: string;
26
+ method: string;
27
+ authorization: string;
28
+ title: string;
29
+ body: string;
30
+ }
31
+
32
+ type Mode = "ok" | "fail" | "hang";
33
+
34
+ async function startServer(options: { enabled?: boolean; mode?: Mode; timeoutMs?: number } = {}) {
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 writeFile(join(staticDir, "index.html"), "<!doctype html><title>Feedback</title>");
41
+ const calls: RecordedCall[] = [];
42
+ const mode = options.mode ?? "ok";
43
+ const fetchImpl = (async (input: string, init?: RequestInit): Promise<Response> => {
44
+ const request = init ?? {};
45
+ const payload = JSON.parse(String(request.body ?? "{}")) as {
46
+ title?: unknown;
47
+ body?: unknown;
48
+ };
49
+ calls.push({
50
+ url: input,
51
+ method: request.method ?? "GET",
52
+ authorization:
53
+ new Headers(request.headers).get("authorization") ?? "",
54
+ title: typeof payload.title === "string" ? payload.title : "",
55
+ body: typeof payload.body === "string" ? payload.body : "",
56
+ });
57
+ if (mode === "hang") {
58
+ const signal = request.signal;
59
+ return await new Promise<Response>((_settle, reject) => {
60
+ signal?.addEventListener("abort", () => {
61
+ reject(new DOMException("The operation timed out.", "TimeoutError"));
62
+ });
63
+ });
64
+ }
65
+ if (mode === "fail") {
66
+ return new Response(JSON.stringify({ message: "boom" }), { status: 500 });
67
+ }
68
+ return Response.json({
69
+ number: 42,
70
+ title: payload.title,
71
+ html_url: ISSUE_URL,
72
+ state: "open",
73
+ });
74
+ }) as typeof fetch;
75
+
76
+ const server = createApp({
77
+ adrDir,
78
+ staticDir,
79
+ hostname: "127.0.0.1",
80
+ port: 0,
81
+ repoDir: root,
82
+ stateDir: join(root, "state"),
83
+ feedback:
84
+ options.enabled === false
85
+ ? undefined
86
+ : {
87
+ baseUrl: "https://gitea.example",
88
+ repo: "dsdenes/devboard",
89
+ token: "secret-token",
90
+ timeoutMs: options.timeoutMs ?? 5_000,
91
+ fetchImpl,
92
+ },
93
+ });
94
+ return { server, root, url: `127.0.0.1:${server.port}`, calls };
95
+ }
96
+
97
+ function submit(
98
+ url: string,
99
+ payload: unknown,
100
+ headers: Record<string, string> = {},
101
+ ): Promise<Response> {
102
+ return fetch(`http://${url}/api/feedback`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json", ...headers },
105
+ body: JSON.stringify(payload),
106
+ });
107
+ }
108
+
109
+ function report(overrides: Record<string, unknown> = {}): Record<string, unknown> {
110
+ return {
111
+ report: "The Start button is clipped on narrow windows.",
112
+ route: "/",
113
+ url: "http://127.0.0.1:8123/",
114
+ selector: "main > section.run > button.start-button",
115
+ dom_excerpt: '<button class="start-button">Start</button>',
116
+ visible_text: "Start",
117
+ viewport: "1280x720 @1x",
118
+ captured_at: "2026-10-22T10:00:00.000Z",
119
+ ...overrides,
120
+ };
121
+ }
122
+
123
+ describe("feedback endpoint", () => {
124
+ test("files the report and captured context through the Gitea API", async () => {
125
+ const { server, url, calls } = await startServer();
126
+
127
+ try {
128
+ const response = await submit(url, report());
129
+
130
+ expect(response.status).toBe(201);
131
+ expect(await response.json()).toEqual({
132
+ url: ISSUE_URL,
133
+ title: "[ui] The Start button is clipped on narrow windows.",
134
+ });
135
+
136
+ expect(calls).toHaveLength(1);
137
+ const call = calls[0]!;
138
+ expect(call.method).toBe("POST");
139
+ expect(call.url).toBe("https://gitea.example/api/v1/repos/dsdenes/devboard/issues");
140
+ expect(call.authorization).toBe("token secret-token");
141
+ expect(call.title).toBe("[ui] The Start button is clipped on narrow windows.");
142
+ expect(call.body).toContain("## Operator report");
143
+ expect(call.body).toContain("The Start button is clipped on narrow windows.");
144
+ expect(call.body).toContain("- Route: `/`");
145
+ expect(call.body).toContain("- Selected element: `main > section.run > button.start-button`");
146
+ expect(call.body).toContain("### Visible text");
147
+ expect(call.body).toContain("### DOM excerpt");
148
+ expect(call.body).toContain('<button class="start-button">Start</button>');
149
+ expect(call.body).not.toContain("## Ask conversation");
150
+ } finally {
151
+ server.stop(true);
152
+ }
153
+ });
154
+
155
+ test("carries the Ask conversation as context in the filed issue", async () => {
156
+ const { server, url, calls } = await startServer();
157
+
158
+ try {
159
+ const response = await submit(
160
+ url,
161
+ report({
162
+ conversation: [
163
+ { role: "developer", text: "Why is this button clipped?" },
164
+ { role: "agent", text: "The flex row has no min-width." },
165
+ ],
166
+ }),
167
+ );
168
+
169
+ expect(response.status).toBe(201);
170
+ const call = calls[0]!;
171
+ expect(call.body).toContain("## Ask conversation");
172
+ expect(call.body).toContain("**You:**\n\nWhy is this button clipped?");
173
+ expect(call.body).toContain("**Agent:**\n\nThe flex row has no min-width.");
174
+ expect(call.body.indexOf("## Ask conversation")).toBeLessThan(
175
+ call.body.indexOf("## Captured UI context"),
176
+ );
177
+ } finally {
178
+ server.stop(true);
179
+ }
180
+ });
181
+
182
+ test("refuses a malformed conversation before reaching Gitea", async () => {
183
+ const { server, url, calls } = await startServer();
184
+
185
+ try {
186
+ const response = await submit(
187
+ url,
188
+ report({ conversation: [{ role: "system", text: "ignored" }] }),
189
+ );
190
+
191
+ expect(response.status).toBe(400);
192
+ expect(await response.json()).toEqual({
193
+ error: "conversation[0].role must be developer or agent.",
194
+ });
195
+ expect(calls).toHaveLength(0);
196
+ } finally {
197
+ server.stop(true);
198
+ }
199
+ });
200
+
201
+ test("refuses an oversized conversation before reaching Gitea", async () => {
202
+ const { server, url, calls } = await startServer();
203
+ const turn = "x".repeat(4000);
204
+
205
+ try {
206
+ const response = await submit(
207
+ url,
208
+ report({
209
+ conversation: [
210
+ { role: "developer", text: turn },
211
+ { role: "agent", text: turn },
212
+ { role: "developer", text: turn },
213
+ { role: "agent", text: turn },
214
+ ],
215
+ }),
216
+ );
217
+
218
+ expect(response.status).toBe(400);
219
+ expect(await response.json()).toEqual({
220
+ error: "conversation is longer than 12000 characters.",
221
+ });
222
+ expect(calls).toHaveLength(0);
223
+ } finally {
224
+ server.stop(true);
225
+ }
226
+ });
227
+
228
+ test("always files the issue in the configured harness repository", async () => {
229
+ const { server, url, calls } = await startServer();
230
+
231
+ try {
232
+ const response = await submit(url, report());
233
+
234
+ expect(response.status).toBe(201);
235
+ expect(calls[0]?.url).toContain("/repos/dsdenes/devboard/issues");
236
+ } finally {
237
+ server.stop(true);
238
+ }
239
+ });
240
+
241
+ test("refuses an empty report before reaching Gitea", async () => {
242
+ const { server, url, calls } = await startServer();
243
+
244
+ try {
245
+ const response = await submit(url, report({ report: " " }));
246
+
247
+ expect(response.status).toBe(400);
248
+ expect(await response.json()).toEqual({ error: "report must not be empty." });
249
+ expect(calls).toHaveLength(0);
250
+ } finally {
251
+ server.stop(true);
252
+ }
253
+ });
254
+
255
+ test("refuses oversized fields before reaching Gitea", async () => {
256
+ const { server, url, calls } = await startServer();
257
+
258
+ try {
259
+ const response = await submit(url, report({ report: "x".repeat(8001) }));
260
+
261
+ expect(response.status).toBe(400);
262
+ expect(calls).toHaveLength(0);
263
+ } finally {
264
+ server.stop(true);
265
+ }
266
+ });
267
+
268
+ test("refuses a cross-origin browser request", async () => {
269
+ const { server, url, calls } = await startServer();
270
+
271
+ try {
272
+ const response = await submit(url, report(), { origin: "http://evil.example" });
273
+
274
+ expect(response.status).toBe(403);
275
+ expect(calls).toHaveLength(0);
276
+ } finally {
277
+ server.stop(true);
278
+ }
279
+ });
280
+
281
+ test("reports a delivery failure with its diagnostic", async () => {
282
+ const { server, url } = await startServer({ mode: "fail" });
283
+
284
+ try {
285
+ const response = await submit(url, report());
286
+
287
+ expect(response.status).toBe(502);
288
+ const body = (await response.json()) as { error: string };
289
+ expect(body.error.startsWith("Issue creation failed:")).toBe(true);
290
+ } finally {
291
+ server.stop(true);
292
+ }
293
+ });
294
+
295
+ test("reports a delivery timeout", async () => {
296
+ const { server, url } = await startServer({ mode: "hang", timeoutMs: 100 });
297
+
298
+ try {
299
+ const response = await submit(url, report());
300
+
301
+ expect(response.status).toBe(504);
302
+ expect(await response.json()).toEqual({
303
+ error: "Issue creation timed out after 100 ms.",
304
+ });
305
+ } finally {
306
+ server.stop(true);
307
+ }
308
+ });
309
+
310
+ test("disables reporting when no Gitea address is configured", async () => {
311
+ const { server, url } = await startServer({ enabled: false });
312
+
313
+ try {
314
+ const response = await submit(url, report());
315
+
316
+ expect(response.status).toBe(503);
317
+ const body = (await response.json()) as { error: string };
318
+ expect(body.error).toContain("DEVBOARD_GITEA_URL");
319
+ } finally {
320
+ server.stop(true);
321
+ }
322
+ });
323
+
324
+ test("clamps a long report line to the title limit", async () => {
325
+ const { server, url } = await startServer();
326
+
327
+ try {
328
+ const response = await submit(url, report({ report: `\n\n${"long ".repeat(100)}` }));
329
+ const body = (await response.json()) as { title: string };
330
+
331
+ expect(response.status).toBe(201);
332
+ expect(body.title.startsWith("[ui] long")).toBe(true);
333
+ expect(body.title.length).toBeLessThanOrEqual(200);
334
+ } finally {
335
+ server.stop(true);
336
+ }
337
+ });
338
+
339
+ test("defaults a missing action to the free-text report", async () => {
340
+ const { server, url, calls } = await startServer();
341
+
342
+ try {
343
+ const payload = report();
344
+ delete payload.action;
345
+ const response = await submit(url, payload);
346
+
347
+ expect(response.status).toBe(201);
348
+ const call = calls[0]!;
349
+ expect(call.body).toContain("## Operator report");
350
+ expect(call.body).toContain("The Start button is clipped on narrow windows.");
351
+ } finally {
352
+ server.stop(true);
353
+ }
354
+ });
355
+
356
+ test("files a removal request without report text", async () => {
357
+ const { server, url, calls } = await startServer();
358
+
359
+ try {
360
+ const response = await submit(
361
+ url,
362
+ report({ action: "remove", report: undefined, selector: "main > section.run" }),
363
+ );
364
+
365
+ expect(response.status).toBe(201);
366
+ expect(await response.json()).toEqual({
367
+ url: ISSUE_URL,
368
+ title: "[ui] Remove element: main > section.run",
369
+ });
370
+
371
+ const call = calls[0]!;
372
+ expect(call.body.startsWith("## Removal request\n\n")).toBe(true);
373
+ expect(call.body).toContain(
374
+ "\nRemove the selected UI element from the interface.\n",
375
+ );
376
+ expect(call.body).toContain("## Captured UI context");
377
+ expect(call.body).toContain("- Selected element: `main > section.run`");
378
+ expect(call.body).not.toContain("## Operator report");
379
+ } finally {
380
+ server.stop(true);
381
+ }
382
+ });
383
+
384
+ test("files a redesign request with the normative design instruction", async () => {
385
+ const { server, url, calls } = await startServer();
386
+
387
+ try {
388
+ const response = await submit(
389
+ url,
390
+ report({ action: "redesign", report: "ignored operator text" }),
391
+ );
392
+
393
+ expect(response.status).toBe(201);
394
+ const body = (await response.json()) as { title: string };
395
+ expect(body.title).toBe(
396
+ "[ui] Redesign element: main > section.run > button.start-button",
397
+ );
398
+ const call = calls[0]!;
399
+ expect(call.body.startsWith("## Redesign request\n\n")).toBe(true);
400
+ expect(call.body).toContain(REDESIGN_INSTRUCTION);
401
+ expect(call.body).toContain("ui-ux skill");
402
+ expect(call.body).toContain("DESIGN.md");
403
+ expect(call.body).toContain("design/tokens.json");
404
+ expect(call.body).not.toContain("ignored operator text");
405
+ } finally {
406
+ server.stop(true);
407
+ }
408
+ });
409
+
410
+ test("clamps a long selector to the title limit", async () => {
411
+ const { server, url } = await startServer();
412
+
413
+ try {
414
+ const response = await submit(
415
+ url,
416
+ report({ action: "remove", report: undefined, selector: "a ".repeat(300) }),
417
+ );
418
+
419
+ expect(response.status).toBe(201);
420
+ const body = (await response.json()) as { title: string };
421
+ expect(body.title.startsWith("[ui] Remove element: a a a")).toBe(true);
422
+ expect(body.title.length).toBeLessThanOrEqual(200);
423
+ expect(body.title).not.toContain(" ");
424
+ } finally {
425
+ server.stop(true);
426
+ }
427
+ });
428
+
429
+ test("names a removal without a selector", async () => {
430
+ const { server, url } = await startServer();
431
+
432
+ try {
433
+ const response = await submit(
434
+ url,
435
+ report({ action: "remove", report: undefined, selector: "" }),
436
+ );
437
+
438
+ expect(response.status).toBe(201);
439
+ const body = (await response.json()) as { title: string };
440
+ expect(body.title).toBe("[ui] Remove element");
441
+ } finally {
442
+ server.stop(true);
443
+ }
444
+ });
445
+
446
+ test("refuses an unknown action before reaching Gitea", async () => {
447
+ const { server, url, calls } = await startServer();
448
+
449
+ try {
450
+ const response = await submit(url, report({ action: "delete" }));
451
+
452
+ expect(response.status).toBe(400);
453
+ expect(calls).toHaveLength(0);
454
+ } finally {
455
+ server.stop(true);
456
+ }
457
+ });
458
+ });