@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,311 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { JOURNAL_KINDS, journalFiles, type JournalKind } from "../server/journal";
5
+ import { processSnapshot, type Instance } from "./discovery";
6
+
7
+ /** The evidence window is the ten minutes before cycle start, both ends inclusive. */
8
+ export const WINDOW_MS = 10 * 60 * 1000;
9
+
10
+ export interface Window {
11
+ start: Date;
12
+ end: Date;
13
+ }
14
+
15
+ export interface EvidenceEvent {
16
+ /** File the evidence came from. */
17
+ source: string;
18
+ /** A journal kind, or `omp-log` for omp's own process log. */
19
+ kind: string;
20
+ /** ISO timestamp used for the window decision. */
21
+ ts: string;
22
+ text: string;
23
+ /** Logical run, lane, or peer the journal record named. */
24
+ scope?: string;
25
+ /** Severity token an `omp-log` record carried. */
26
+ level?: "error" | "warn" | "fatal";
27
+ /** Project the evidence belongs to, when attribution resolved it. */
28
+ instance?: Instance;
29
+ }
30
+
31
+ export interface Collection {
32
+ events: EvidenceEvent[];
33
+ /** Records with an unparseable timestamp or malformed shape. */
34
+ malformed: number;
35
+ /** omp log lines outside every instance's working tree. */
36
+ unattributed: number;
37
+ }
38
+
39
+ export interface WindowOptions {
40
+ window: Window;
41
+ }
42
+
43
+ /** Returns the parsed instant, or undefined when the timestamp is unusable. */
44
+ export function parseTimestamp(value: unknown): number | undefined {
45
+ if (typeof value !== "string" || value.length === 0) return undefined;
46
+ const parsed = Date.parse(value);
47
+ return Number.isFinite(parsed) ? parsed : undefined;
48
+ }
49
+
50
+ function normalizedPath(root: string, path: string): string {
51
+ const resolved = resolve(path);
52
+ const local = relative(resolve(root), resolved);
53
+ return local.length === 0 || local.startsWith("..") ? resolved : local;
54
+ }
55
+
56
+ /** In-window events of one journal file, with the identity used to merge copies. */
57
+ interface JournalFileEvidence {
58
+ events: EvidenceEvent[];
59
+ /** Record identity parallel to `events`. */
60
+ identities: string[];
61
+ malformed: number;
62
+ }
63
+
64
+ /**
65
+ * Reads one JSONL journal file. Records inside the window are returned;
66
+ * malformed records are counted, never guessed. An unreadable file is empty.
67
+ */
68
+ function readJournalFile(
69
+ path: string,
70
+ options: WindowOptions & { root: string },
71
+ instance: Instance,
72
+ ): JournalFileEvidence {
73
+ let source: string;
74
+ try {
75
+ source = readFileSync(path, "utf8");
76
+ } catch {
77
+ return { events: [], identities: [], malformed: 0 };
78
+ }
79
+ const events: EvidenceEvent[] = [];
80
+ const identities: string[] = [];
81
+ let malformed = 0;
82
+ for (const line of source.split("\n")) {
83
+ if (line.trim().length === 0) continue;
84
+ let record: Record<string, unknown>;
85
+ try {
86
+ const parsed: unknown = JSON.parse(line);
87
+ if (typeof parsed !== "object" || parsed === null) throw new Error("not an object");
88
+ record = parsed as Record<string, unknown>;
89
+ } catch {
90
+ malformed += 1;
91
+ continue;
92
+ }
93
+ const ts = parseTimestamp(record.ts);
94
+ if (ts === undefined) {
95
+ malformed += 1;
96
+ continue;
97
+ }
98
+ if (ts < options.window.start.getTime() || ts > options.window.end.getTime()) continue;
99
+ const kind = record.kind;
100
+ if (typeof kind !== "string" || !JOURNAL_KINDS.includes(kind as JournalKind)) continue;
101
+ if (typeof record.text !== "string") continue;
102
+ const scope = typeof record.scope === "string" && record.scope.length > 0 ? record.scope : "";
103
+ events.push({
104
+ source: normalizedPath(options.root, path),
105
+ kind,
106
+ ts: new Date(ts).toISOString(),
107
+ text: record.text,
108
+ ...(scope.length > 0 ? { scope } : {}),
109
+ instance,
110
+ });
111
+ identities.push(JSON.stringify([ts, kind, scope, record.text]));
112
+ }
113
+ return { events, identities, malformed };
114
+ }
115
+
116
+ /**
117
+ * Reads one project's JSONL journal, live and rotated. The files are the
118
+ * serving harness's, so a dashboard from another checkout contributes its own
119
+ * records. Rotated journals are read oldest first, and a record present in
120
+ * both a rotated file and the live one is counted once: rotation moves records
121
+ * rather than copying them, so a copy is the same record. Malformed records
122
+ * count across every file read.
123
+ */
124
+ export function readJournalEvidence(
125
+ instance: Instance,
126
+ options: WindowOptions & { root: string },
127
+ ): { events: EvidenceEvent[]; malformed: number } {
128
+ const events: EvidenceEvent[] = [];
129
+ let malformed = 0;
130
+ /** Most occurrences of one identity any earlier file held. */
131
+ const seen = new Map<string, number>();
132
+ for (const path of journalFiles(instance.journal, options.window.start)) {
133
+ const file = readJournalFile(path, options, instance);
134
+ malformed += file.malformed;
135
+ const occurrences = new Map<string, number>();
136
+ file.events.forEach((event, index) => {
137
+ const identity = file.identities[index] ?? "";
138
+ const occurrence = occurrences.get(identity) ?? 0;
139
+ occurrences.set(identity, occurrence + 1);
140
+ // Copies beyond what an earlier file already held are the same record.
141
+ if (occurrence < (seen.get(identity) ?? 0)) return;
142
+ events.push(event);
143
+ });
144
+ for (const [identity, count] of occurrences) {
145
+ seen.set(identity, Math.max(seen.get(identity) ?? 0, count));
146
+ }
147
+ }
148
+ return { events, malformed };
149
+ }
150
+
151
+ export interface ProcessInfo {
152
+ /** Process ids from the parent upward, nearest first. */
153
+ ancestors: number[];
154
+ }
155
+
156
+ export interface OmpOptions extends WindowOptions {
157
+ logsDir?: string;
158
+ instances: Instance[];
159
+ /** Pids of the watchdog and its descendants. */
160
+ selfPids: Set<number>;
161
+ /** Injectable process lookup; defaults to `/proc`. */
162
+ processInfo?: (pid: number) => ProcessInfo | undefined;
163
+ }
164
+
165
+ interface OmpRecord {
166
+ timestamp?: unknown;
167
+ level?: unknown;
168
+ pid?: unknown;
169
+ message?: unknown;
170
+ error?: unknown;
171
+ [key: string]: unknown;
172
+ }
173
+
174
+ function describeOmpLine(record: OmpRecord): string {
175
+ const parts = [record.message, record.error]
176
+ .filter((part): part is string => typeof part === "string" && part.length > 0)
177
+ .join(": ");
178
+ return parts.length > 0 ? parts : JSON.stringify(record);
179
+ }
180
+
181
+ /**
182
+ * An omp line belongs to an instance only when its pid descends from that
183
+ * dashboard's server process. A shared working directory never attributes a
184
+ * line: an operator's own omp session in the project runs from the same
185
+ * directory while being unrelated to the dashboard, and its warnings are not
186
+ * dashboard evidence.
187
+ */
188
+ function attribution(
189
+ pid: number,
190
+ instance: Instance[],
191
+ processInfo: (pid: number) => ProcessInfo | undefined,
192
+ ): Instance | undefined {
193
+ const info = processInfo(pid);
194
+ if (info === undefined) return undefined;
195
+ return instance.find((candidate) => info.ancestors.includes(candidate.pid));
196
+ }
197
+
198
+ /**
199
+ * Reads omp's own process logs. A line is evidence for an instance only when
200
+ * its pid descends from that instance's server; a shared working directory
201
+ * never attributes a line, and the watchdog's own pids never become evidence.
202
+ */
203
+ export function readOmpEvidence(options: OmpOptions): {
204
+ events: EvidenceEvent[];
205
+ malformed: number;
206
+ unattributed: number;
207
+ } {
208
+ const logsDir = options.logsDir ?? join(homedir(), ".omp", "logs");
209
+ const processInfo =
210
+ options.processInfo ??
211
+ ((pid: number) => {
212
+ const snapshot = processSnapshot(pid);
213
+ return snapshot === undefined ? undefined : { ancestors: snapshot.ancestors };
214
+ });
215
+ let names: string[];
216
+ try {
217
+ names = readdirSync(logsDir).filter((name) => /^omp\..*\.log$/.test(name));
218
+ } catch {
219
+ return { events: [], malformed: 0, unattributed: 0 };
220
+ }
221
+ let malformed = 0;
222
+ let unattributed = 0;
223
+ const events: EvidenceEvent[] = [];
224
+ for (const name of names) {
225
+ const path = join(logsDir, name);
226
+ try {
227
+ if (statSync(path).mtimeMs < options.window.start.getTime()) continue;
228
+ } catch {
229
+ continue;
230
+ }
231
+ let source: string;
232
+ try {
233
+ source = readFileSync(path, "utf8");
234
+ } catch {
235
+ continue;
236
+ }
237
+ for (const line of source.split("\n")) {
238
+ if (line.trim().length === 0) continue;
239
+ let record: OmpRecord;
240
+ try {
241
+ const parsed: unknown = JSON.parse(line);
242
+ if (typeof parsed !== "object" || parsed === null) throw new Error("not an object");
243
+ record = parsed as OmpRecord;
244
+ } catch {
245
+ malformed += 1;
246
+ continue;
247
+ }
248
+ const ts = parseTimestamp(record.timestamp);
249
+ if (ts === undefined) {
250
+ malformed += 1;
251
+ continue;
252
+ }
253
+ if (ts < options.window.start.getTime() || ts > options.window.end.getTime()) continue;
254
+ const level = typeof record.level === "string" ? record.level.toLowerCase() : "";
255
+ if (level !== "error" && level !== "warn" && level !== "fatal") continue;
256
+ const pid = Number(record.pid);
257
+ if (!Number.isInteger(pid) || pid <= 0) {
258
+ malformed += 1;
259
+ continue;
260
+ }
261
+ if (options.selfPids.has(pid)) continue;
262
+ const ancestors = processInfo(pid)?.ancestors ?? [];
263
+ if (ancestors.some((ancestor) => options.selfPids.has(ancestor))) continue;
264
+ const owner = attribution(pid, options.instances, processInfo);
265
+ if (owner === undefined) {
266
+ unattributed += 1;
267
+ continue;
268
+ }
269
+ events.push({
270
+ source: path,
271
+ kind: "omp-log",
272
+ ts: new Date(ts).toISOString(),
273
+ text: describeOmpLine(record),
274
+ level,
275
+ instance: owner,
276
+ });
277
+ }
278
+ }
279
+ return { events, malformed, unattributed };
280
+ }
281
+
282
+ export interface CollectOptions extends WindowOptions {
283
+ root: string;
284
+ instances: Instance[];
285
+ selfPids: Set<number>;
286
+ logsDir?: string;
287
+ processInfo?: (pid: number) => ProcessInfo | undefined;
288
+ }
289
+
290
+ /** Collects the journal and omp evidence for one cycle window. */
291
+ export function collectEvidence(options: CollectOptions): Collection {
292
+ const events: EvidenceEvent[] = [];
293
+ let malformed = 0;
294
+ for (const instance of options.instances) {
295
+ const journal = readJournalEvidence(instance, options);
296
+ events.push(...journal.events);
297
+ malformed += journal.malformed;
298
+ }
299
+ const omp = readOmpEvidence(options);
300
+ events.push(...omp.events);
301
+ malformed += omp.malformed;
302
+ return { events, malformed, unattributed: omp.unattributed };
303
+ }
304
+
305
+ /** An evidence line without its timestamp or incidental whitespace. */
306
+ export function normalizeEvidenceLine(text: string): string {
307
+ return text
308
+ .replace(/^\d{4}-\d{2}-\d{2}T[^\s]+/, "")
309
+ .replace(/\s+/g, " ")
310
+ .trim();
311
+ }
@@ -0,0 +1,397 @@
1
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { pruneJournal } from "../server/journal";
4
+ import { collectEvidence, WINDOW_MS, type Collection, type Window } from "./collect";
5
+ import { coveredStateDirs, runCleanup, type CleanupReport } from "./cleanup";
6
+ import { discoverInstances, type Instance } from "./discovery";
7
+ import { analyzeEvidence } from "./findings";
8
+ import { runGh, type GhRun, type IssueInput } from "./github";
9
+ import { createWatchdogDelivery, type WatchdogDelivery } from "./delivery";
10
+ import {
11
+ loadLedger,
12
+ markCommented,
13
+ markFiled,
14
+ markPending,
15
+ recordFindings,
16
+ saveLedger,
17
+ type LedgerEntry,
18
+ } from "./ledger";
19
+
20
+ /**
21
+ * One watchdog cycle: discover live dashboards, collect the previous ten
22
+ * minutes of machine-checkable evidence, and file or comment one GitHub issue
23
+ * per distinct signal. It never spawns an agent, creates a worktree, commits,
24
+ * merges, pushes, or publishes.
25
+ */
26
+
27
+ /** How long an existing issue waits before a recurrence comments on it. */
28
+ export const COMMENT_INTERVAL_MS = 24 * 60 * 60 * 1000;
29
+
30
+ export interface CycleContext {
31
+ root: string;
32
+ harness: string;
33
+ stateDir: string;
34
+ runDir: string;
35
+ now: Date;
36
+ window: Window;
37
+ log: (line: string) => void;
38
+ }
39
+
40
+ export interface CycleDeps {
41
+ discover: (root: string) => Instance[];
42
+ collect: (root: string, instances: Instance[], window: Window) => Collection;
43
+ gh: GhRun;
44
+ delivery?: WatchdogDelivery;
45
+ }
46
+
47
+ export interface ReportInstance {
48
+ pid: number;
49
+ port: number;
50
+ repoDir: string;
51
+ harness: string;
52
+ instrumented: boolean;
53
+ journal: string;
54
+ legacyLog?: string;
55
+ }
56
+
57
+ export interface ReportFinding {
58
+ category: string;
59
+ ts: string;
60
+ quote: string;
61
+ fingerprint: string;
62
+ instances: { repoDir: string; port: number; pid: number; journal: string }[];
63
+ }
64
+
65
+ export interface ReportIssue {
66
+ url: string;
67
+ category: string;
68
+ fingerprint: string;
69
+ line: string;
70
+ }
71
+
72
+ export type CycleAction = "none" | "duplicate" | "filed" | "commented" | "failed";
73
+
74
+ export interface Report {
75
+ cycle: string;
76
+ window: { start: string; end: string; minutes: number };
77
+ instances: ReportInstance[];
78
+ findings: ReportFinding[];
79
+ action: CycleAction;
80
+ issues: ReportIssue[];
81
+ /** Fingerprints still waiting for a successful filing. */
82
+ pending: number;
83
+ /** This cycle's report; the issue body points at it. */
84
+ reportPath: string;
85
+ reason?: string;
86
+ malformed: number;
87
+ unattributed: number;
88
+ /** Age-based retention over every covered state directory. */
89
+ cleanup: CleanupReport;
90
+ }
91
+
92
+ export interface CycleOptions {
93
+ root?: string;
94
+ now?: Date;
95
+ stateDir?: string;
96
+ dryRun?: boolean;
97
+ deps?: Partial<CycleDeps>;
98
+ }
99
+
100
+ export interface CycleResult {
101
+ report: Report;
102
+ exitCode: number;
103
+ runDir?: string;
104
+ }
105
+
106
+ function stamp(now: Date): string {
107
+ return now.toISOString().replace(/[:.]/g, "-");
108
+ }
109
+
110
+ function isFiled(entry: LedgerEntry | undefined): boolean {
111
+ return entry !== undefined && entry.disposition.startsWith("filed:");
112
+ }
113
+
114
+ /** Runs one watchdog cycle. The caller owns the singleton lock. */
115
+ export async function runCycle(options: CycleOptions = {}): Promise<CycleResult> {
116
+ const root = resolve(options.root ?? join(import.meta.dir, "..", ".."));
117
+ const harness = join(root, ".devboard");
118
+ const now = options.now ?? new Date();
119
+ const window: Window = {
120
+ start: new Date(now.getTime() - WINDOW_MS),
121
+ end: now,
122
+ };
123
+ const stateDir = resolve(options.stateDir ?? join(harness, ".tmp", "watchdog"));
124
+ const runDir = join(stateDir, "runs", stamp(now));
125
+ const reportPath = join(runDir, "report.json");
126
+ const deps: CycleDeps = {
127
+ discover: () => discoverInstances({ selfPids: new Set([process.pid]) }),
128
+ collect: (target, instances, collectedWindow) =>
129
+ collectEvidence({
130
+ root: target,
131
+ instances,
132
+ window: collectedWindow,
133
+ selfPids: new Set([process.pid]),
134
+ }),
135
+ gh: runGh,
136
+ ...options.deps,
137
+ };
138
+ const dryRun = options.dryRun === true;
139
+ if (!dryRun) mkdirSync(runDir, { recursive: true });
140
+ const coordinatorLog = dryRun ? undefined : join(runDir, "coordinator.log");
141
+ const context: CycleContext = {
142
+ root,
143
+ harness,
144
+ stateDir,
145
+ runDir,
146
+ now,
147
+ window,
148
+ log(line: string) {
149
+ const stamped = `${new Date().toISOString()} ${line}\n`;
150
+ if (coordinatorLog !== undefined) {
151
+ try {
152
+ appendFileSync(coordinatorLog, stamped);
153
+ } catch {
154
+ // Reporting must not fail the cycle.
155
+ }
156
+ }
157
+ process.stderr.write(`watchdog: ${line}\n`);
158
+ },
159
+ };
160
+
161
+ const ledger = loadLedger(stateDir);
162
+ const instances = deps.discover(root);
163
+ context.log(`discovered ${instances.length} instance(s)`);
164
+
165
+ const covered = coveredStateDirs(root, instances);
166
+ if (!dryRun) {
167
+ for (const entry of covered) {
168
+ const removed = pruneJournal(entry.stateDir, {
169
+ now,
170
+ windowStart: window.start,
171
+ });
172
+ if (removed.length > 0) {
173
+ context.log(`pruned ${removed.length} old journal file(s) in ${entry.stateDir}`);
174
+ }
175
+ }
176
+ }
177
+
178
+ const cleanup = runCleanup({
179
+ root,
180
+ now,
181
+ dryRun,
182
+ instances,
183
+ covered,
184
+ watchdogRunDir: runDir,
185
+ });
186
+ for (const entry of cleanup.roots) {
187
+ context.log(
188
+ `cleanup ${entry.stateDir}: ${dryRun ? "would remove" : "removed"} ` +
189
+ `${entry.files} file(s), ${entry.directories} directory/directories, ` +
190
+ `${entry.bytes} byte(s)` +
191
+ (entry.errors.length > 0 ? `, ${entry.errors.length} error(s)` : ""),
192
+ );
193
+ for (const error of entry.errors) context.log(`cleanup ${entry.stateDir}: ${error}`);
194
+ }
195
+
196
+ const collection = deps.collect(root, instances, window);
197
+ const findings = analyzeEvidence(collection.events, { window });
198
+ context.log(
199
+ `collected ${collection.events.length} event(s), ${findings.length} finding(s), ` +
200
+ `${collection.malformed} malformed, ${collection.unattributed} unattributed`,
201
+ );
202
+ recordFindings(ledger, findings, now);
203
+
204
+ const report: Report = {
205
+ cycle: now.toISOString(),
206
+ window: {
207
+ start: window.start.toISOString(),
208
+ end: window.end.toISOString(),
209
+ minutes: WINDOW_MS / 60 / 1000,
210
+ },
211
+ instances: instances.map((instance) => ({
212
+ pid: instance.pid,
213
+ port: instance.port,
214
+ repoDir: instance.repoDir,
215
+ harness: instance.harness,
216
+ instrumented: instance.instrumented,
217
+ journal: instance.journal,
218
+ ...(instance.legacyLog === undefined ? {} : { legacyLog: instance.legacyLog }),
219
+ })),
220
+ findings: findings.map((finding) => ({
221
+ category: finding.category,
222
+ ts: finding.ts,
223
+ quote: finding.quote,
224
+ fingerprint: finding.fingerprint,
225
+ instances: finding.instances.map((instance) => ({
226
+ repoDir: instance.repoDir,
227
+ port: instance.port,
228
+ pid: instance.pid,
229
+ journal: instance.journal,
230
+ })),
231
+ })),
232
+ action: "none",
233
+ issues: [],
234
+ pending: ledger.pendingFiling.length,
235
+ reportPath,
236
+ malformed: collection.malformed,
237
+ unattributed: collection.unattributed,
238
+ cleanup,
239
+ };
240
+
241
+ if (dryRun) {
242
+ const settled = findings.every((finding) => isFiled(ledger.findings[finding.fingerprint]));
243
+ if (findings.length === 0 && ledger.pendingFiling.length === 0) {
244
+ report.reason = "no finding in the window";
245
+ } else if (settled && ledger.pendingFiling.length === 0) {
246
+ report.action = "duplicate";
247
+ report.reason = "every finding already has an issue";
248
+ } else {
249
+ report.reason =
250
+ `dry run: ${findings.length} finding(s) and ` +
251
+ `${ledger.pendingFiling.length} pending filing(s) not attempted`;
252
+ }
253
+ return { report, exitCode: 0 };
254
+ }
255
+
256
+ let delivery = deps.delivery;
257
+ const deliveryFor = () => {
258
+ delivery ??= createWatchdogDelivery({ root, gh: deps.gh });
259
+ return delivery;
260
+ };
261
+ const queue = [
262
+ ...ledger.pendingFiling.filter((fingerprint) => !isFiled(ledger.findings[fingerprint])),
263
+ ...findings
264
+ .map((finding) => finding.fingerprint)
265
+ .filter(
266
+ (fingerprint) =>
267
+ !isFiled(ledger.findings[fingerprint]) &&
268
+ !ledger.pendingFiling.includes(fingerprint),
269
+ ),
270
+ ];
271
+
272
+ const issueInput = (fingerprint: string, entry: LedgerEntry): IssueInput => ({
273
+ category: entry.category,
274
+ fingerprint,
275
+ line: entry.line,
276
+ firstSeen: entry.firstSeen,
277
+ lastSeen: entry.lastSeen,
278
+ count: entry.count,
279
+ window,
280
+ instances: entry.instances,
281
+ reportPath,
282
+ });
283
+
284
+ let failure: string | undefined;
285
+ let commented = 0;
286
+
287
+ // Pending filings are retried first: a failed `gh` call never loses evidence.
288
+ for (const fingerprint of queue) {
289
+ const entry = ledger.findings[fingerprint];
290
+ if (entry === undefined) {
291
+ ledger.pendingFiling = ledger.pendingFiling.filter((pending) => pending !== fingerprint);
292
+ continue;
293
+ }
294
+ const filed = await deliveryFor().create(issueInput(fingerprint, entry));
295
+ if (filed.ok) {
296
+ markFiled(ledger, fingerprint, filed.url, now);
297
+ report.issues.push({
298
+ url: filed.url,
299
+ category: entry.category,
300
+ fingerprint,
301
+ line: entry.line,
302
+ });
303
+ context.log(`filed ${filed.url} for ${entry.category}: ${entry.line}`);
304
+ continue;
305
+ }
306
+ failure ??= filed.reason;
307
+ markPending(ledger, fingerprint);
308
+ context.log(`filing ${entry.category} failed: ${filed.reason}`);
309
+ }
310
+
311
+ const filedThisCycle = new Set(report.issues.map((issue) => issue.fingerprint));
312
+ for (const finding of findings) {
313
+ const entry = ledger.findings[finding.fingerprint];
314
+ if (entry === undefined || entry.url === undefined) continue;
315
+ if (filedThisCycle.has(finding.fingerprint)) continue;
316
+ const baseline = Date.parse(entry.lastCommentAt ?? entry.filedAt ?? "");
317
+ if (!Number.isFinite(baseline) || now.getTime() - baseline < COMMENT_INTERVAL_MS) continue;
318
+ const comment = await deliveryFor().comment(
319
+ entry.url,
320
+ issueInput(finding.fingerprint, entry),
321
+ );
322
+ if (comment.ok) {
323
+ markCommented(ledger, finding.fingerprint, now);
324
+ commented += 1;
325
+ context.log(`commented on ${entry.url}`);
326
+ continue;
327
+ }
328
+ failure ??= comment.reason;
329
+ context.log(`comment on ${entry.url} failed: ${comment.reason}`);
330
+ }
331
+
332
+ if (failure !== undefined) {
333
+ report.action = "failed";
334
+ report.reason = failure;
335
+ } else if (report.issues.length > 0) {
336
+ report.action = "filed";
337
+ report.reason = `filed ${report.issues.length} issue(s)`;
338
+ } else if (commented > 0) {
339
+ report.action = "commented";
340
+ report.reason = `commented on ${commented} issue(s)`;
341
+ } else if (findings.length > 0 || ledger.pendingFiling.length > 0) {
342
+ report.action = "duplicate";
343
+ report.reason = "every finding already has an issue";
344
+ } else {
345
+ report.reason = "no finding in the window";
346
+ }
347
+ report.pending = ledger.pendingFiling.length;
348
+
349
+ saveLedger(context.stateDir, ledger);
350
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
351
+ writeFileSync(join(context.stateDir, "latest.json"), `${JSON.stringify(report, null, 2)}\n`);
352
+ writeFileSync(join(context.runDir, "report.md"), renderMarkdown(report));
353
+ context.log(`cycle ${report.action}: ${report.reason ?? "no reason"}`);
354
+ return {
355
+ report,
356
+ exitCode: report.action === "failed" ? 1 : 0,
357
+ runDir: context.runDir,
358
+ };
359
+ }
360
+
361
+ function renderMarkdown(report: Report): string {
362
+ const lines = [
363
+ `# Watchdog cycle ${report.cycle}`,
364
+ "",
365
+ `- Window: ${report.window.start} .. ${report.window.end} (${report.window.minutes} minutes)`,
366
+ `- Action: ${report.action}`,
367
+ `- Reason: ${report.reason ?? "none"}`,
368
+ `- Findings: ${report.findings.length}`,
369
+ `- Pending filings: ${report.pending}`,
370
+ "",
371
+ "## Instances",
372
+ "",
373
+ ...(report.instances.length === 0
374
+ ? ["No dashboard was running."]
375
+ : report.instances.map(
376
+ (instance) =>
377
+ `- port ${instance.port}: ${instance.repoDir} (pid ${instance.pid}, ` +
378
+ `${instance.instrumented ? "instrumented" : "uninstrumented"})`,
379
+ )),
380
+ "",
381
+ "## Findings",
382
+ "",
383
+ ...(report.findings.length === 0
384
+ ? ["None."]
385
+ : report.findings.map(
386
+ (finding) => `- ${finding.category} at ${finding.ts}: ${finding.quote}`,
387
+ )),
388
+ "",
389
+ "## Issues",
390
+ "",
391
+ ...(report.issues.length === 0
392
+ ? ["None."]
393
+ : report.issues.map((issue) => `- ${issue.url} (${issue.category})`)),
394
+ "",
395
+ ];
396
+ return `${lines.join("\n")}\n`;
397
+ }