@dsdenes/devboard 0.1.560 → 0.1.562

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +709 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AppNotification } from "./notifications.svelte";
3
+ import { MAX_NOTIFICATION_REPORT, notificationReport } from "./notification-issue";
4
+
5
+ function notification(overrides: Partial<AppNotification> = {}): AppNotification {
6
+ return {
7
+ id: 1,
8
+ severity: "error",
9
+ text: 'item 1 ("Flow restore re-adds checkpoints.jsonl entries") evidence: server/flow.ts does not exist',
10
+ at: "2026-10-28T11:29:44.932Z",
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe("notification issue report", () => {
16
+ test("leads with the severity and notification text for the issue title", () => {
17
+ const report = notificationReport(notification());
18
+ expect(report.split("\n")[0]).toBe(
19
+ 'Error notification: item 1 ("Flow restore re-adds checkpoints.jsonl entries") evidence: server/flow.ts does not exist',
20
+ );
21
+ expect(report).toContain("\n\nRaised at: 2026-10-28T11:29:44.932Z");
22
+ });
23
+
24
+ test("labels every severity without changing the notification text", () => {
25
+ expect(
26
+ notificationReport(notification({ severity: "warning" })).startsWith(
27
+ "Warning notification: ",
28
+ ),
29
+ ).toBe(true);
30
+ expect(
31
+ notificationReport(notification({ severity: "notice" })).startsWith(
32
+ "Notice notification: ",
33
+ ),
34
+ ).toBe(true);
35
+ expect(
36
+ notificationReport(notification({ severity: "success" })).startsWith(
37
+ "Success notification: ",
38
+ ),
39
+ ).toBe(true);
40
+ });
41
+
42
+ test("trims surrounding whitespace and keeps a long message inside the server bound", () => {
43
+ const long = notification({ text: ` ${"x".repeat(MAX_NOTIFICATION_REPORT)} ` });
44
+ const report = notificationReport(long);
45
+ expect(report.length).toBeLessThanOrEqual(MAX_NOTIFICATION_REPORT);
46
+ expect(report.endsWith("…\n\nRaised at: 2026-10-28T11:29:44.932Z")).toBe(true);
47
+ });
48
+
49
+ test("a long first line is truncated without losing the timestamp", () => {
50
+ const report = notificationReport(notification({ text: "y".repeat(10_000) }));
51
+ expect(report.split("\n")[0]!.length).toBeLessThanOrEqual(MAX_NOTIFICATION_REPORT);
52
+ });
53
+ });
@@ -0,0 +1,27 @@
1
+ import type { AppNotification, Severity } from "./notifications.svelte";
2
+
3
+ /** The server's `report` bound; the title line and metadata must fit inside it. */
4
+ export const MAX_NOTIFICATION_REPORT = 8000;
5
+
6
+ const SEVERITY_LABEL: Record<Severity, string> = {
7
+ error: "Error",
8
+ warning: "Warning",
9
+ notice: "Notice",
10
+ success: "Success",
11
+ };
12
+
13
+ /**
14
+ * The report a notification button files. Its first line becomes the issue
15
+ * title, so the notification text leads, labelled with its severity; the
16
+ * timestamp follows as context. The whole report stays inside the server's
17
+ * bound even for a long server message.
18
+ */
19
+ export function notificationReport(item: AppNotification): string {
20
+ const prefix = `${SEVERITY_LABEL[item.severity]} notification: `;
21
+ const suffix = `\n\nRaised at: ${item.at}`;
22
+ const budget = Math.max(0, MAX_NOTIFICATION_REPORT - prefix.length - suffix.length);
23
+ const text = item.text.trim();
24
+ const body =
25
+ text.length <= budget ? text : `${text.slice(0, Math.max(0, budget - 1)).trimEnd()}…`;
26
+ return `${prefix}${body}${suffix}`;
27
+ }
@@ -0,0 +1,62 @@
1
+ export type Severity = "error" | "warning" | "notice" | "success";
2
+
3
+ export interface AppNotification {
4
+ id: number;
5
+ severity: Severity;
6
+ text: string;
7
+ at: string;
8
+ }
9
+
10
+ const HISTORY_LIMIT = 200;
11
+
12
+ /**
13
+ * Server errors, notices, and client connection failures. The newest entries
14
+ * render in the live notification region; every entry stays in the history
15
+ * opened from the status bar.
16
+ */
17
+ export class NotificationStore {
18
+ items = $state<AppNotification[]>([]);
19
+ unread = $state(0);
20
+ historyOpen = $state(false);
21
+ private counter = 0;
22
+
23
+ push(severity: Severity, text: string): void {
24
+ const message = text.trim();
25
+ if (message.length === 0) return;
26
+ this.counter += 1;
27
+ this.items = [
28
+ {
29
+ id: this.counter,
30
+ severity,
31
+ text: message,
32
+ at: new Date().toISOString(),
33
+ },
34
+ ...this.items,
35
+ ].slice(0, HISTORY_LIMIT);
36
+ this.unread += 1;
37
+ }
38
+
39
+ dismiss(id: number): void {
40
+ this.items = this.items.filter((item) => item.id !== id);
41
+ }
42
+
43
+ clear(): void {
44
+ this.items = [];
45
+ this.unread = 0;
46
+ }
47
+
48
+ markRead(): void {
49
+ this.unread = 0;
50
+ }
51
+
52
+ openHistory(): void {
53
+ this.historyOpen = true;
54
+ this.markRead();
55
+ }
56
+
57
+ closeHistory(): void {
58
+ this.historyOpen = false;
59
+ }
60
+ }
61
+
62
+ export const notifications = new NotificationStore();
@@ -0,0 +1,141 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { IssuesSnapshot } from "./types";
3
+ import { pollAge, pollInterval, pollStatus } from "./poll-status";
4
+
5
+ const AT = Date.parse("2026-10-27T14:03:24.000Z");
6
+
7
+ function snapshot(overrides: Partial<IssuesSnapshot> = {}): IssuesSnapshot {
8
+ return {
9
+ present: true,
10
+ repo: "fixture/host",
11
+ root: "/fixture",
12
+ lifecycle: "running",
13
+ enabled: true,
14
+ paused: false,
15
+ attached: true,
16
+ worker: null,
17
+ poll: {
18
+ intervalMs: 2_000,
19
+ lastPollAt: "2026-10-27T14:03:22.854Z",
20
+ nextPollAt: "2026-10-27T14:03:24.854Z",
21
+ etag: '"etag"',
22
+ notModified: false,
23
+ rateLimitedUntil: null,
24
+ error: null,
25
+ },
26
+ queue: [],
27
+ runs: [],
28
+ agents: [],
29
+ cooldowns: {},
30
+ lastError: null,
31
+ lastExitCode: null,
32
+ finishedAt: null,
33
+ notice: null,
34
+ updatedAt: null,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("poll interval", () => {
40
+ test("keeps sub-second cadences in milliseconds and whole seconds unrounded", () => {
41
+ expect(pollInterval(250)).toBe("250ms");
42
+ expect(pollInterval(2_000)).toBe("2s");
43
+ expect(pollInterval(60_000)).toBe("60s");
44
+ });
45
+
46
+ test("rounds a fractional cadence to one decimal", () => {
47
+ expect(pollInterval(1_500)).toBe("1.5s");
48
+ expect(pollInterval(1_333)).toBe("1.3s");
49
+ });
50
+
51
+ test("does not invent a cadence for an invalid interval", () => {
52
+ expect(pollInterval(0)).toBe("unknown");
53
+ expect(pollInterval(Number.NaN)).toBe("unknown");
54
+ });
55
+ });
56
+
57
+ describe("poll age", () => {
58
+ test("reads seconds, minutes, hours, and days at polling resolution", () => {
59
+ expect(pollAge("2026-10-27T14:03:23.500Z", AT)).toBe("just now");
60
+ expect(pollAge("2026-10-27T14:03:22.854Z", AT)).toBe("1s ago");
61
+ expect(pollAge("2026-10-27T13:58:24.000Z", AT)).toBe("5m ago");
62
+ expect(pollAge("2026-10-27T11:03:24.000Z", AT)).toBe("3h ago");
63
+ expect(pollAge("2026-10-24T14:03:24.000Z", AT)).toBe("3d ago");
64
+ });
65
+
66
+ test("clamps a future timestamp and names an unreadable one", () => {
67
+ expect(pollAge("2026-10-27T14:04:00.000Z", AT)).toBe("just now");
68
+ expect(pollAge("not a date", AT)).toBe("at an unknown time");
69
+ });
70
+ });
71
+
72
+ describe("poll status", () => {
73
+ test("states the cadence and the last check while polling", () => {
74
+ const status = pollStatus(snapshot(), AT);
75
+ expect(status).toEqual({
76
+ tone: "active",
77
+ text: "Every 2s · checked 1s ago",
78
+ title:
79
+ "Poll interval 2s · last check 2026-10-27T14:03:22.854Z · next check 2026-10-27T14:03:24.854Z",
80
+ });
81
+ });
82
+
83
+ test("reads a stopped or paused worker as not polling", () => {
84
+ expect(pollStatus(null, AT)).toMatchObject({ tone: "idle", text: "Not polling" });
85
+ expect(pollStatus(snapshot({ lifecycle: "stopped", enabled: false }), AT)).toMatchObject({
86
+ tone: "idle",
87
+ text: "Not polling",
88
+ });
89
+ expect(pollStatus(snapshot({ lifecycle: "paused", paused: true }), AT)).toMatchObject({
90
+ tone: "idle",
91
+ text: "Polling paused",
92
+ });
93
+ expect(pollStatus(snapshot({ lifecycle: "pausing" }), AT)).toMatchObject({ text: "Polling paused" });
94
+ });
95
+
96
+ test("names a failing poll without losing the last check", () => {
97
+ const poll = snapshot().poll;
98
+ if (poll === null) throw new Error("fixture poll missing");
99
+ const status = pollStatus(snapshot({ poll: { ...poll, error: "HTTP 502" } }), AT);
100
+ expect(status).toMatchObject({ tone: "failed", text: "Poll failing · checked 1s ago" });
101
+ });
102
+
103
+ test("names a rate limit only while it is still in effect", () => {
104
+ const poll = snapshot().poll;
105
+ if (poll === null) throw new Error("fixture poll missing");
106
+ const limited = pollStatus(
107
+ snapshot({ poll: { ...poll, error: "slow down", rateLimitedUntil: "2026-10-27T14:04:00.000Z" } }),
108
+ AT,
109
+ );
110
+ expect(limited).toMatchObject({ tone: "limited", text: "Rate limited · checked 1s ago" });
111
+
112
+ const expired = pollStatus(
113
+ snapshot({ poll: { ...poll, error: "slow down", rateLimitedUntil: "2026-10-27T14:03:00.000Z" } }),
114
+ AT,
115
+ );
116
+ expect(expired).toMatchObject({ tone: "failed", text: "Poll failing · checked 1s ago" });
117
+ });
118
+
119
+ test("names an overdue check instead of claiming a live loop", () => {
120
+ const poll = snapshot().poll;
121
+ if (poll === null) throw new Error("fixture poll missing");
122
+ expect(
123
+ pollStatus(snapshot({ poll: { ...poll, lastPollAt: "2026-10-27T09:03:24.000Z" } }), AT),
124
+ ).toMatchObject({ tone: "stale", text: "Check overdue · checked 5h ago" });
125
+ });
126
+
127
+ test("states the first check before one has finished", () => {
128
+ const poll = snapshot().poll;
129
+ if (poll === null) throw new Error("fixture poll missing");
130
+ expect(pollStatus(snapshot({ poll: { ...poll, lastPollAt: null } }), AT)).toMatchObject({
131
+ text: "Every 2s · no check yet",
132
+ });
133
+ });
134
+
135
+ test("states a worker that has not published a poll yet", () => {
136
+ expect(pollStatus(snapshot({ poll: null }), AT)).toMatchObject({
137
+ tone: "idle",
138
+ text: "Starting up",
139
+ });
140
+ });
141
+ });
@@ -0,0 +1,88 @@
1
+ import type { IssuesSnapshot } from "./types";
2
+
3
+ /**
4
+ * The visual state of the Automation panel's poll line. The word in the text
5
+ * always carries the meaning; `tone` only colors the dot.
6
+ */
7
+ export type PollTone = "active" | "stale" | "limited" | "failed" | "idle";
8
+
9
+ export interface PollStatus {
10
+ tone: PollTone;
11
+ text: string;
12
+ title: string;
13
+ }
14
+
15
+ const SECOND = 1_000;
16
+ const MINUTE = 60 * SECOND;
17
+ const HOUR = 60 * MINUTE;
18
+ const DAY = 24 * HOUR;
19
+
20
+ /** Poll cadence as a short duration: `500ms`, `2s`, `1.5s`, `60s`. */
21
+ export function pollInterval(intervalMs: number): string {
22
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) return "unknown";
23
+ if (intervalMs < SECOND) return `${Math.round(intervalMs)}ms`;
24
+ const seconds = intervalMs / SECOND;
25
+ return `${Number.isInteger(seconds) ? seconds : Math.round(seconds * 10) / 10}s`;
26
+ }
27
+
28
+ /**
29
+ * How long ago a poll finished, at the resolution polling actually needs:
30
+ * seconds below a minute, then minutes, hours, and days. A timestamp in the
31
+ * future (clock skew) reads `just now` rather than a negative duration.
32
+ */
33
+ export function pollAge(value: string, now: number): string {
34
+ const timestamp = Date.parse(value);
35
+ if (Number.isNaN(timestamp)) return "at an unknown time";
36
+ const elapsed = Math.max(0, now - timestamp);
37
+ if (elapsed < SECOND) return "just now";
38
+ if (elapsed < MINUTE) return `${Math.floor(elapsed / SECOND)}s ago`;
39
+ if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
40
+ if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
41
+ return `${Math.floor(elapsed / DAY)}d ago`;
42
+ }
43
+
44
+ /**
45
+ * The Automation panel subtitle. It states whether the worker is polling, how
46
+ * often, and when it last checked; the exact timestamps stay in the tooltip so
47
+ * a precise time is one hover away without cluttering the header.
48
+ */
49
+ export function pollStatus(snapshot: IssuesSnapshot | null, now: number): PollStatus {
50
+ const poll = snapshot?.poll ?? null;
51
+ const interval = poll === null ? "unknown" : pollInterval(poll.intervalMs);
52
+ const last = poll?.lastPollAt ?? null;
53
+ const next = poll?.nextPollAt ?? null;
54
+ const title = [
55
+ `Poll interval ${interval}`,
56
+ `last check ${last ?? "never"}`,
57
+ `next check ${next ?? "not scheduled"}`,
58
+ ].join(" · ");
59
+
60
+ if (snapshot === null || !snapshot.present || !snapshot.enabled || snapshot.lifecycle === "stopped") {
61
+ return { tone: "idle", text: "Not polling", title: "The issues worker is stopped." };
62
+ }
63
+
64
+ if (snapshot.paused || snapshot.lifecycle === "paused" || snapshot.lifecycle === "pausing") {
65
+ return { tone: "idle", text: "Polling paused", title };
66
+ }
67
+
68
+ if (poll === null) {
69
+ return { tone: "idle", text: "Starting up", title: "The issues worker has not reported a poll yet." };
70
+ }
71
+
72
+ const checked = last === null ? "no check yet" : `checked ${pollAge(last, now)}`;
73
+ const limited = poll.rateLimitedUntil !== null && Date.parse(poll.rateLimitedUntil) > now;
74
+
75
+ if (limited) {
76
+ return { tone: "limited", text: `Rate limited · ${checked}`, title };
77
+ }
78
+ if (poll.error !== null) {
79
+ return { tone: "failed", text: `Poll failing · ${checked}`, title };
80
+ }
81
+ const lastAt = last === null ? null : Date.parse(last);
82
+ const overdue =
83
+ lastAt !== null && !Number.isNaN(lastAt) && now - lastAt > Math.max(3 * poll.intervalMs, 10 * SECOND);
84
+ if (overdue) {
85
+ return { tone: "stale", text: `Check overdue · ${checked}`, title };
86
+ }
87
+ return { tone: "active", text: `Every ${interval} · ${checked}`, title };
88
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { recordStatusTone } from "./record-status";
3
+
4
+ describe("record status tone", () => {
5
+ test("maps each decision status to its badge tone", () => {
6
+ expect(recordStatusTone("Accepted")).toBe("success");
7
+ expect(recordStatusTone(" accepted ")).toBe("success");
8
+ expect(recordStatusTone("Draft")).toBe("pending");
9
+ expect(recordStatusTone("Proposed")).toBe("pending");
10
+ expect(recordStatusTone("Superseded")).toBe("stale");
11
+ expect(recordStatusTone("Deprecated")).toBe("stale");
12
+ expect(recordStatusTone("Rejected")).toBe("error");
13
+ });
14
+
15
+ test("leaves a status it does not know neutral", () => {
16
+ expect(recordStatusTone("")).toBeUndefined();
17
+ expect(recordStatusTone("Deferred")).toBeUndefined();
18
+ });
19
+ });
@@ -0,0 +1,25 @@
1
+ /** Badge tones `app.css` styles for `.badge[data-tone]`. */
2
+ export type BadgeTone = "pending" | "success" | "warning" | "error" | "accent" | "stale";
3
+
4
+ /**
5
+ * The tone one decision record's status earns. A record's `status` is the
6
+ * frontmatter value rendered with a capital first letter, so the mapping is on
7
+ * the normalized word; an unrecognized status keeps the neutral badge rather
8
+ * than guessing.
9
+ */
10
+ export function recordStatusTone(status: string): BadgeTone | undefined {
11
+ switch (status.trim().toLowerCase()) {
12
+ case "accepted":
13
+ return "success";
14
+ case "draft":
15
+ case "proposed":
16
+ return "pending";
17
+ case "superseded":
18
+ case "deprecated":
19
+ return "stale";
20
+ case "rejected":
21
+ return "error";
22
+ default:
23
+ return undefined;
24
+ }
25
+ }
@@ -0,0 +1,164 @@
1
+ import { connection } from "./connection.svelte";
2
+ import { notifications } from "./notifications.svelte";
3
+ import type {
4
+ ScoutActivityEntry,
5
+ ScoutIssueRow,
6
+ ScoutLimits,
7
+ ScoutSnapshot,
8
+ ScoutSync,
9
+ } from "./types";
10
+
11
+ function isObject(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+
15
+ function isSnapshot(value: Record<string, unknown>): boolean {
16
+ return (
17
+ value.type === "scoutState" &&
18
+ typeof value.root === "string" &&
19
+ typeof value.lifecycle === "string" &&
20
+ typeof value.enabled === "boolean" &&
21
+ Array.isArray(value.issues) &&
22
+ isObject(value.sync) &&
23
+ isObject(value.limits)
24
+ );
25
+ }
26
+
27
+ function isEntry(value: unknown): value is ScoutActivityEntry {
28
+ return isObject(value) && typeof value.text === "string" && typeof value.append === "boolean";
29
+ }
30
+
31
+ function applyActivity(lines: string[], entries: ScoutActivityEntry[], replay: boolean): string[] {
32
+ const next = replay ? [] : [...lines];
33
+ for (const entry of entries) {
34
+ if (entry.append && next.length > 0) next[next.length - 1] += entry.text;
35
+ else next.push(entry.text);
36
+ }
37
+ return next;
38
+ }
39
+
40
+ const DEFAULT_LIMITS: ScoutLimits = { maxPerHour: 10, maxPending: 5 };
41
+ const EMPTY_SYNC: ScoutSync = { state: "stale", at: null, error: null };
42
+
43
+ /**
44
+ * The Auto Scout page's client state. It never polls and never starts a scan:
45
+ * the dashboard replays the worker's state and activity on connect, then
46
+ * streams both as the worker persists them. Controls only send commands.
47
+ */
48
+ export class ScoutStore {
49
+ snapshot = $state<ScoutSnapshot | null>(null);
50
+ activity = $state<string[]>([]);
51
+ /** The scan whose activity is shown, so a stale stream cannot overwrite it. */
52
+ activityScanId = $state<string | null>(null);
53
+ private lastNotice: string | null = null;
54
+ private lastError: string | null = null;
55
+
56
+ get lifecycle(): string {
57
+ return this.snapshot?.lifecycle ?? "stopped";
58
+ }
59
+
60
+ get repo(): string | null {
61
+ return this.snapshot?.repo ?? null;
62
+ }
63
+
64
+ /**
65
+ * The header's origin label. A missing snapshot is not evidence that the
66
+ * checkout has no origin, so only a received snapshot may say so.
67
+ */
68
+ get repoLabel(): string {
69
+ if (this.repo !== null) return this.repo;
70
+ return this.snapshot === null ? "…" : "No GitHub origin";
71
+ }
72
+
73
+ get attached(): boolean {
74
+ return this.snapshot?.attached === true;
75
+ }
76
+
77
+ get lifecycleActive(): boolean {
78
+ return this.snapshot?.enabled === true || this.attached;
79
+ }
80
+
81
+ get issues(): ScoutIssueRow[] {
82
+ return this.snapshot?.issues ?? [];
83
+ }
84
+
85
+ /** The sidebar count: open Auto Scout issues. */
86
+ get count(): number {
87
+ return this.issues.filter((issue) => issue.state === "pending").length;
88
+ }
89
+
90
+ get limits(): ScoutLimits {
91
+ const limits = this.snapshot?.limits;
92
+ return limits ?? DEFAULT_LIMITS;
93
+ }
94
+
95
+ get sync(): ScoutSync {
96
+ return this.snapshot?.sync ?? EMPTY_SYNC;
97
+ }
98
+
99
+ get scanning(): boolean {
100
+ return this.snapshot?.scan !== null && this.snapshot?.scan !== undefined;
101
+ }
102
+
103
+ get error(): string | null {
104
+ return this.snapshot?.lastError ?? null;
105
+ }
106
+
107
+ get canStart(): boolean {
108
+ return connection.open && !this.attached;
109
+ }
110
+
111
+ get canStop(): boolean {
112
+ return this.attached;
113
+ }
114
+
115
+ start(): void {
116
+ connection.send({ type: "startScout" });
117
+ }
118
+
119
+ stop(): void {
120
+ connection.send({ type: "stopScout" });
121
+ }
122
+
123
+ saveLimits(maxPerHour: number, maxPending: number): void {
124
+ connection.send({ type: "setScoutLimits", maxPerHour, maxPending });
125
+ }
126
+
127
+ handleEvent(message: Record<string, unknown>): void {
128
+ if (isSnapshot(message)) {
129
+ const next = message as unknown as ScoutSnapshot;
130
+ const first = this.snapshot === null;
131
+ const previousScan =
132
+ this.snapshot?.scan?.scanId ?? this.snapshot?.newestScanId ?? null;
133
+ const nextScan = next.scan?.scanId ?? next.newestScanId ?? null;
134
+ this.snapshot = next;
135
+ if (nextScan !== previousScan) {
136
+ this.activity = [];
137
+ this.activityScanId = nextScan;
138
+ }
139
+ // The first snapshot of a page load is replayed state, not news.
140
+ if (next.lastError !== this.lastError) {
141
+ this.lastError = next.lastError;
142
+ if (next.lastError !== null && !first) {
143
+ notifications.push("error", next.lastError);
144
+ }
145
+ }
146
+ if (next.notice !== this.lastNotice) {
147
+ this.lastNotice = next.notice;
148
+ if (!first && next.notice !== null && next.notice.startsWith("Filed ")) {
149
+ notifications.push("success", next.notice);
150
+ }
151
+ }
152
+ return;
153
+ }
154
+ if (message.type === "scoutActivity" && typeof message.scanId === "string") {
155
+ const current = this.snapshot?.scan?.scanId ?? this.snapshot?.newestScanId ?? null;
156
+ if (message.scanId !== current) return;
157
+ this.activityScanId = message.scanId;
158
+ const entries = Array.isArray(message.entries) ? message.entries.filter(isEntry) : [];
159
+ this.activity = applyActivity(this.activity, entries, message.replay === true);
160
+ }
161
+ }
162
+ }
163
+
164
+ export const scout = new ScoutStore();
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderSkillDocument, skillBody } from "./skill-document";
3
+
4
+ const skill = [
5
+ "---",
6
+ "name: fixture-conventions",
7
+ "description: Conventions every fixture implementation agent follows.",
8
+ "metadata:",
9
+ ' devboard-managed: "true"',
10
+ "---",
11
+ "",
12
+ "# Fixture conventions",
13
+ "",
14
+ "- Prefer the smallest observable change.",
15
+ "- Run `bun test` before pushing.",
16
+ ].join("\n");
17
+
18
+ describe("skillBody", () => {
19
+ test("drops the frontmatter and keeps the instructions", () => {
20
+ expect(skillBody(skill)).toBe(
21
+ [
22
+ "# Fixture conventions",
23
+ "",
24
+ "- Prefer the smallest observable change.",
25
+ "- Run `bun test` before pushing.",
26
+ ].join("\n"),
27
+ );
28
+ });
29
+
30
+ test("keeps a document without frontmatter whole", () => {
31
+ expect(skillBody("# Plain\n\n- One rule.")).toBe("# Plain\n\n- One rule.");
32
+ });
33
+
34
+ test("normalizes CRLF before reading the fence", () => {
35
+ expect(skillBody(skill.replaceAll("\n", "\r\n"))).toContain("# Fixture conventions");
36
+ expect(skillBody(skill.replaceAll("\n", "\r\n"))).not.toContain("devboard-managed");
37
+ });
38
+
39
+ test("treats an unclosed fence as ordinary content", () => {
40
+ expect(skillBody("---\nname: broken\n\n# Body")).toBe("---\nname: broken\n\n# Body");
41
+ });
42
+ });
43
+
44
+ describe("renderSkillDocument", () => {
45
+ test("renders headings, list items, and inline code as markup", () => {
46
+ const html = renderSkillDocument(skill);
47
+ expect(html).toContain("<h1>Fixture conventions</h1>");
48
+ expect(html).toContain("<li>Prefer the smallest observable change.</li>");
49
+ expect(html).toContain("<code>bun test</code>");
50
+ expect(html).not.toContain("devboard-managed");
51
+ });
52
+
53
+ test("renders fenced code without touching its text", () => {
54
+ const html = renderSkillDocument("```ts\nconst a = 1 < 2;\n```");
55
+ expect(html).toContain("language-ts");
56
+ expect(html).toContain("const a = 1 &lt; 2;");
57
+ });
58
+
59
+ test("shows raw HTML as text instead of executing it", () => {
60
+ const html = renderSkillDocument('<script>alert("x")</script>\n\nAfter.');
61
+ expect(html).not.toContain("<script>");
62
+ expect(html).toContain("&lt;script&gt;");
63
+ });
64
+
65
+ test("keeps a safe link and strips a script link to its label", () => {
66
+ expect(renderSkillDocument("[docs](https://example.com/x)")).toContain(
67
+ 'href="https://example.com/x" target="_blank" rel="noreferrer noopener"',
68
+ );
69
+ const script = renderSkillDocument("[click](javascript:alert(1))");
70
+ expect(script).not.toContain("javascript:");
71
+ expect(script).toContain("click");
72
+ });
73
+
74
+ test("returns no markup for a frontmatter-only skill", () => {
75
+ expect(renderSkillDocument("---\nname: empty\n---\n")).toBe("");
76
+ });
77
+ });