@dsdenes/devboard 0.1.560 → 0.1.563

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 +729 -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,877 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdir, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { activityDelta } from "./activity";
6
+ import { ScoutBridge } from "./scout";
7
+ import { parseCommand } from "../issues/scout/control";
8
+ import { acquireLock } from "../issues/lock";
9
+ import { writeActivity } from "../issues/state";
10
+ import {
11
+ closingCommitFromComments,
12
+ closingCommitFromTimeline,
13
+ earliestRelease,
14
+ parseJsonLines,
15
+ shippedVersion,
16
+ type ScoutGitHubClient,
17
+ type ScoutRelease,
18
+ } from "../issues/scout/github";
19
+ import {
20
+ DEFAULT_MAX_PENDING,
21
+ controlDirectory,
22
+ lockPath,
23
+ settingsPath,
24
+ statePath,
25
+ } from "../issues/scout/paths";
26
+ import { preflightScout, scoutCommandPaths, stateInsideRepository } from "../issues/scout/preflight";
27
+ import {
28
+ categoryFromBody,
29
+ normalizeTitle,
30
+ parseScanResult,
31
+ recommendationId,
32
+ } from "../issues/scout/recommendation";
33
+ import {
34
+ buildRequest,
35
+ createScanDirectory,
36
+ promptFor,
37
+ readDraftGoalHeadings,
38
+ writeRequest,
39
+ } from "../issues/scout/scan";
40
+ import {
41
+ DEFAULT_LIMITS,
42
+ migrateState,
43
+ parseSettings,
44
+ parseState,
45
+ readSettings,
46
+ writeSettings,
47
+ writeState,
48
+ type ScoutLimits,
49
+ type ScoutState,
50
+ } from "../issues/scout/state";
51
+ import {
52
+ createdDirectories,
53
+ makeRepository,
54
+ recommendation,
55
+ scanDocument,
56
+ sourceFiles,
57
+ temporaryDirectory,
58
+ type DraftRecommendation,
59
+ } from "../tests/fixtures/scout-helpers";
60
+
61
+ const findings = 12;
62
+ const requested = 5;
63
+
64
+ afterAll(async () => {
65
+ await Promise.all(
66
+ createdDirectories().map((directory) => rm(directory, { recursive: true, force: true })),
67
+ );
68
+ });
69
+
70
+ async function repository(): Promise<string> {
71
+ const root = await makeRepository({ files: sourceFiles(findings), origin: "acme/fixture" });
72
+ const home = await temporaryDirectory("scout-home");
73
+ await mkdir(join(home, ".omp", "commands"), { recursive: true });
74
+ await writeFile(
75
+ join(home, ".omp", "commands", "scout-recommendations.md"),
76
+ "# scout recommendations\n",
77
+ );
78
+ return root;
79
+ }
80
+
81
+ function items(count: number): DraftRecommendation[] {
82
+ return Array.from({ length: count }, (_value, index) => recommendation(index + 1));
83
+ }
84
+
85
+ describe("recommendation identity", () => {
86
+ test("the same finding keeps its id and a different category does not", () => {
87
+ const id = recommendationId("clarity", ["src/b.ts", "src/a.ts"], " A Title ");
88
+ expect(id).toMatch(/^[0-9a-f]{16}$/);
89
+ expect(recommendationId("clarity", ["src/a.ts", "src/b.ts"], "a title")).toBe(id);
90
+ expect(recommendationId("structure", ["src/a.ts", "src/b.ts"], "a title")).not.toBe(id);
91
+ expect(normalizeTitle(" A Title ")).toBe("a title");
92
+ });
93
+
94
+ test("the category is read from an issue body, or null when absent", () => {
95
+ expect(categoryFromBody("## Category\n\nrobustness\n\n## Summary\n\nx")).toBe("robustness");
96
+ expect(categoryFromBody("## Summary\n\nx")).toBeNull();
97
+ expect(categoryFromBody("## Category\n\n\n## Summary\n\nx")).toBeNull();
98
+ expect(categoryFromBody("## category\n\nnaming")).toBe("naming");
99
+ });
100
+ });
101
+
102
+ describe("scan result validation", () => {
103
+ test("accepts at most the requested count and derives ids and scores", async () => {
104
+ const root = await repository();
105
+ const result = parseScanResult(scanDocument(items(3)), { requested, repoDir: root });
106
+ expect(result.ok).toBe(true);
107
+ if (!result.ok) return;
108
+ expect(result.items).toHaveLength(3);
109
+ expect(result.items[0]?.id).toMatch(/^[0-9a-f]{16}$/);
110
+ expect(result.items[0]?.significance).toBe(5);
111
+ expect(result.items[0]?.significanceReason).toContain("third nested branch");
112
+ expect(result.shortfall).toBeNull();
113
+ });
114
+
115
+ test("refuses more recommendations than requested", async () => {
116
+ const root = await repository();
117
+ const result = parseScanResult(scanDocument(items(6)), { requested, repoDir: root });
118
+ expect(result.ok).toBe(false);
119
+ if (result.ok) return;
120
+ expect(result.error).toContain("6 recommendations");
121
+ expect(result.error).toContain(`${requested} requested`);
122
+ });
123
+
124
+ test("a missing significance score fails the whole answer", async () => {
125
+ const root = await repository();
126
+ const document = scanDocument([recommendation(1)]);
127
+ delete (document.recommendations as Record<string, unknown>[])[0]!.significance;
128
+ const result = parseScanResult(document, { requested, repoDir: root });
129
+ expect(result.ok).toBe(false);
130
+ if (result.ok) return;
131
+ expect(result.error).toContain("significance score from 1 to 5");
132
+ });
133
+
134
+ test("an out-of-range significance score fails the whole answer", async () => {
135
+ const root = await repository();
136
+ for (const value of [0, 6, 1.5, "4"]) {
137
+ const document = scanDocument([recommendation(1)]);
138
+ (document.recommendations as Record<string, unknown>[])[0]!.significance = value;
139
+ const result = parseScanResult(document, { requested, repoDir: root });
140
+ expect(result.ok).toBe(false);
141
+ if (result.ok) return;
142
+ expect(result.error).toContain("significance score");
143
+ }
144
+ });
145
+
146
+ test("a missing significance reason fails the whole answer", async () => {
147
+ const root = await repository();
148
+ const document = scanDocument([recommendation(1)]);
149
+ delete (document.recommendations as Record<string, unknown>[])[0]!.significanceReason;
150
+ const result = parseScanResult(document, { requested, repoDir: root });
151
+ expect(result.ok).toBe(false);
152
+ if (result.ok) return;
153
+ expect(result.error).toContain("significance reason");
154
+ });
155
+
156
+ test("a malformed item names itself", async () => {
157
+ const root = await repository();
158
+ const document = scanDocument(items(2));
159
+ const first = (document.recommendations as Record<string, unknown>[])[0]!;
160
+ delete first.category;
161
+ const result = parseScanResult(document, { requested, repoDir: root });
162
+ expect(result.ok).toBe(false);
163
+ if (result.ok) return;
164
+ expect(result.error).toContain('item 1 ("Simplify module 1 control flow")');
165
+ expect(result.error).toContain("category");
166
+ });
167
+
168
+ test("a title that spans more than one line is refused", async () => {
169
+ const root = await repository();
170
+ const document = scanDocument([recommendation(1)]);
171
+ (document.recommendations as Record<string, unknown>[])[0]!.title = "One line\nand another";
172
+ const result = parseScanResult(document, { requested, repoDir: root });
173
+ expect(result.ok).toBe(false);
174
+ if (result.ok) return;
175
+ expect(result.error).toContain("more than one line");
176
+ });
177
+
178
+ test("a path that escapes the repository is refused", async () => {
179
+ const root = await repository();
180
+ const document = scanDocument([recommendation(1)]);
181
+ (document.recommendations as Record<string, unknown>[])[0]!.evidence = [
182
+ { path: "../outside.ts", lines: "1" },
183
+ ];
184
+ const result = parseScanResult(document, { requested, repoDir: root });
185
+ expect(result.ok).toBe(false);
186
+ if (result.ok) return;
187
+ expect(result.error).toContain("escapes the repository");
188
+ });
189
+
190
+ test("a path under node_modules is refused", async () => {
191
+ const root = await repository();
192
+ const document = scanDocument([recommendation(1)]);
193
+ (document.recommendations as Record<string, unknown>[])[0]!.evidence = [
194
+ { path: "node_modules/pkg/index.js", lines: "1" },
195
+ ];
196
+ const result = parseScanResult(document, { requested, repoDir: root });
197
+ expect(result.ok).toBe(false);
198
+ if (result.ok) return;
199
+ expect(result.error).toContain("excluded tree");
200
+ });
201
+
202
+ test("a duplicate id inside one result is refused", async () => {
203
+ const root = await repository();
204
+ const first = recommendation(1);
205
+ const document = scanDocument([first, { ...first }]);
206
+ const result = parseScanResult(document, { requested, repoDir: root });
207
+ expect(result.ok).toBe(false);
208
+ if (result.ok) return;
209
+ expect(result.error).toContain("repeats the id of item 1");
210
+ expect(result.error).toContain(recommendationId("clarity", ["src/module-1.ts"], first.title));
211
+ });
212
+
213
+ test("an out-of-range line span is refused", async () => {
214
+ const root = await repository();
215
+ const document = scanDocument([recommendation(1)]);
216
+ (document.recommendations as Record<string, unknown>[])[0]!.evidence = [
217
+ { path: "src/module-1.ts", lines: "18-999" },
218
+ ];
219
+ const result = parseScanResult(document, { requested, repoDir: root });
220
+ expect(result.ok).toBe(false);
221
+ if (result.ok) return;
222
+ expect(result.error).toContain("outside the file's 20 line(s)");
223
+ });
224
+
225
+ test("a file list entry the evidence does not cite is refused", async () => {
226
+ const root = await repository();
227
+ const document = scanDocument([recommendation(1)]);
228
+ (document.recommendations as Record<string, unknown>[])[0]!.files = ["src/module-2.ts"];
229
+ const result = parseScanResult(document, { requested, repoDir: root });
230
+ expect(result.ok).toBe(false);
231
+ if (result.ok) return;
232
+ expect(result.error).toContain("which its evidence does not cite");
233
+ });
234
+
235
+ test("a non-empty shortfall is preserved and an empty one is dropped", async () => {
236
+ const root = await repository();
237
+ const withReason = parseScanResult(scanDocument(items(1), "Only one real gap."), {
238
+ requested,
239
+ repoDir: root,
240
+ });
241
+ expect(withReason.ok && withReason.shortfall).toBe("Only one real gap.");
242
+ const empty = parseScanResult(scanDocument(items(1), " "), {
243
+ requested,
244
+ repoDir: root,
245
+ });
246
+ expect(empty.ok && empty.shortfall).toBeNull();
247
+ });
248
+ });
249
+
250
+ describe("scan request", () => {
251
+ test("carries the requested batch, excluded roots, known titles, and headings", async () => {
252
+ const root = await repository();
253
+ await mkdir(join(root, ".devboard", "goal-proposals"), { recursive: true });
254
+ await writeFile(
255
+ join(root, ".devboard", "goal-proposals", "one.md"),
256
+ "# Proposed goal: Merge the two trackers\n\n> draft\n",
257
+ );
258
+ const scan = createScanDirectory(root, new Date("2026-10-27T10:00:00Z"));
259
+ const request = buildRequest({
260
+ scan,
261
+ repository: "acme/fixture",
262
+ repoDir: root,
263
+ requested: 3,
264
+ knownTitles: ["open issue", "existing scout issue"],
265
+ draftGoalHeadings: readDraftGoalHeadings(root),
266
+ });
267
+ expect(request.requested).toBe(3);
268
+ expect(request.significanceThreshold).toBe(4);
269
+ expect(request.repository).toBe("acme/fixture");
270
+ expect(request.repositoryPath).toBe(root);
271
+ expect(request.excludedRoots).toContain("node_modules");
272
+ expect(request.categories).toContain("correctness");
273
+ expect(request.knownTitles).toEqual(["open issue", "existing scout issue"]);
274
+ expect(request.draftGoalHeadings).toEqual(["Proposed goal: Merge the two trackers"]);
275
+ writeRequest(scan.directory, request);
276
+ const written = JSON.parse(readFileSync(join(scan.directory, "request.json"), "utf8"));
277
+ expect(written.version).toBe(2);
278
+ expect(written.requested).toBe(3);
279
+ expect(promptFor(scan.directory)).toBe(`/scout-recommendations ${scan.directory}`);
280
+ });
281
+ });
282
+
283
+ function release(tag: string, publishedAt: string): ScoutRelease {
284
+ return { tag, publishedAt };
285
+ }
286
+
287
+ function fakeGitHub(overrides: Partial<ScoutGitHubClient> = {}): ScoutGitHubClient {
288
+ return {
289
+ listIssues: async () => [],
290
+ listOpenIssueTitles: async () => [],
291
+ listReleases: async () => [],
292
+ closingCommit: async () => null,
293
+ resolveCommit: async () => null,
294
+ containingReleaseTags: async () => [],
295
+ ...overrides,
296
+ };
297
+ }
298
+
299
+ describe("shipped version", () => {
300
+ test("the earliest published release wins, with the tag name breaking a tie", () => {
301
+ const releases = [
302
+ release("v2.0.0", "2026-03-01T00:00:00Z"),
303
+ release("v1.5.0", "2026-02-01T00:00:00Z"),
304
+ release("v1.4.0", "2026-02-01T00:00:00Z"),
305
+ release("v1.3.0", "2026-01-01T00:00:00Z"),
306
+ ];
307
+ expect(earliestRelease(releases, ["v2.0.0", "v1.5.0", "v1.4.0"])).toBe("v1.4.0");
308
+ expect(earliestRelease(releases, [])).toBeNull();
309
+ });
310
+
311
+ test("the timeline's newest closing commit wins", () => {
312
+ expect(
313
+ closingCommitFromTimeline([
314
+ { event: "labeled" },
315
+ { event: "closed", commit_id: "aaaaaaa" },
316
+ { event: "reopened" },
317
+ { event: "closed", commit_id: "bbbbbbb" },
318
+ ]),
319
+ ).toBe("bbbbbbb");
320
+ expect(closingCommitFromTimeline([{ event: "closed", commit_id: null }])).toBeNull();
321
+ });
322
+
323
+ test("the newest comment with a SHA yields its last SHA", () => {
324
+ expect(
325
+ closingCommitFromComments([
326
+ "Fixed in deadbeefcafe",
327
+ "still discussing",
328
+ "Merged as 1234567 and follow-up abcdef123456",
329
+ ]),
330
+ ).toBe("abcdef123456");
331
+ expect(closingCommitFromComments(["no sha here"])).toBeNull();
332
+ });
333
+
334
+ test("a closed issue resolves its commit and earliest containing release", async () => {
335
+ const client = fakeGitHub({
336
+ closingCommit: async () => "abc1234",
337
+ resolveCommit: async () => "a".repeat(40),
338
+ containingReleaseTags: async () => ["v1.0.0", "v1.1.0"],
339
+ });
340
+ const version = await shippedVersion(
341
+ {
342
+ number: 4,
343
+ title: "t",
344
+ url: "u",
345
+ state: "closed",
346
+ createdAt: "2026-01-01T00:00:00Z",
347
+ body: "## Category\n\nclarity\n",
348
+ },
349
+ client,
350
+ [release("v1.1.0", "2026-02-01T00:00:00Z"), release("v1.0.0", "2026-01-01T00:00:00Z")],
351
+ );
352
+ expect(version).toBe("v1.0.0");
353
+ });
354
+
355
+ test("a blank version is the answer when nothing resolves", async () => {
356
+ const issue = {
357
+ number: 4,
358
+ title: "t",
359
+ url: "u",
360
+ state: "closed" as const,
361
+ createdAt: "2026-01-01T00:00:00Z",
362
+ body: "",
363
+ };
364
+ expect(await shippedVersion(issue, fakeGitHub(), [])).toBeNull();
365
+ expect(
366
+ await shippedVersion(
367
+ issue,
368
+ fakeGitHub({
369
+ closingCommit: async () => "abc1234",
370
+ resolveCommit: async () => "a".repeat(40),
371
+ containingReleaseTags: async () => [],
372
+ }),
373
+ [release("v1.0.0", "2026-01-01T00:00:00Z")],
374
+ ),
375
+ ).toBeNull();
376
+ });
377
+
378
+ test("an open issue never has a shipped version", async () => {
379
+ const version = await shippedVersion(
380
+ {
381
+ number: 1,
382
+ title: "t",
383
+ url: "u",
384
+ state: "open",
385
+ createdAt: "2026-01-01T00:00:00Z",
386
+ body: "",
387
+ },
388
+ fakeGitHub({ closingCommit: async () => "abc1234" }),
389
+ [release("v1.0.0", "2026-01-01T00:00:00Z")],
390
+ );
391
+ expect(version).toBeNull();
392
+ });
393
+
394
+ test("gh NDJSON output is parsed line by line", () => {
395
+ expect(parseJsonLines('{"a":1}\n\n{"a":2}\n')).toEqual([{ a: 1 }, { a: 2 }]);
396
+ expect(parseJsonLines("not json")).toEqual([]);
397
+ });
398
+ });
399
+
400
+ describe("state and settings", () => {
401
+ test("limits round-trip and an invalid document is refused", async () => {
402
+ const root = await repository();
403
+ const path = settingsPath(join(root, ".devboard", ".tmp", "state"), root);
404
+ const limits: ScoutLimits = { maxPerHour: 4, maxPending: 2 };
405
+ writeSettings(path, limits);
406
+ expect(readSettings(path)).toEqual(limits);
407
+ expect(parseSettings('{"maxPerHour":0,"maxPending":2}')).toBeUndefined();
408
+ expect(parseSettings('{"maxPerHour":2.5,"maxPending":2}')).toBeUndefined();
409
+ expect(parseSettings("not json")).toBeUndefined();
410
+ });
411
+
412
+ test("a version-1 board migrates, discarding every staged recommendation", async () => {
413
+ const root = await repository();
414
+ const staged = items(3).map((item, index) => ({
415
+ ...item,
416
+ id: `id${index}`,
417
+ stagedAt: "2026-01-01T00:00:00Z",
418
+ }));
419
+ const legacy = {
420
+ version: 1,
421
+ repo: "acme/fixture",
422
+ root,
423
+ lifecycle: "idle",
424
+ enabled: true,
425
+ worker: {
426
+ pid: 1,
427
+ startedAt: "2026-01-01T00:00:00Z",
428
+ stateDir: join(root, ".devboard", ".tmp", "state"),
429
+ harnessVersion: null,
430
+ argv: [],
431
+ },
432
+ staged,
433
+ scan: { scanId: "scan-1", startedAt: "2026-01-01T00:00:00Z", requested: 9 },
434
+ newestScanId: "scan-1",
435
+ shortfall: null,
436
+ nextAttemptAt: null,
437
+ lastError: null,
438
+ notice: null,
439
+ decisions: [],
440
+ updatedAt: "2026-01-01T00:00:00Z",
441
+ };
442
+ const migrated = migrateState(JSON.stringify(legacy));
443
+ expect(migrated).toBeDefined();
444
+ if (migrated === undefined) return;
445
+ expect(migrated.version).toBe(2);
446
+ expect(migrated.issues).toEqual([]);
447
+ expect(migrated.limits).toEqual(DEFAULT_LIMITS);
448
+ expect(migrated.limits.maxPending).toBe(DEFAULT_MAX_PENDING);
449
+ expect(migrated.scan).toEqual(legacy.scan);
450
+ expect(migrated.newestScanId).toBe("scan-1");
451
+ expect(migrated.notice).toContain("Discarded 3 staged recommendations");
452
+ expect(migrated.notice).toContain("nothing was filed");
453
+ expect(migrated.worker?.pid).toBe(1);
454
+ expect(parseState(JSON.stringify(migrated))).toEqual(migrated);
455
+ });
456
+
457
+ test("a version-1 file with nothing staged migrates without a discard notice", async () => {
458
+ const root = await repository();
459
+ const legacy = {
460
+ version: 1,
461
+ repo: "acme/fixture",
462
+ root,
463
+ lifecycle: "idle",
464
+ enabled: true,
465
+ worker: null,
466
+ staged: [],
467
+ scan: null,
468
+ newestScanId: null,
469
+ shortfall: null,
470
+ nextAttemptAt: null,
471
+ lastError: null,
472
+ notice: null,
473
+ decisions: [],
474
+ updatedAt: "2026-01-01T00:00:00Z",
475
+ };
476
+ const migrated = migrateState(JSON.stringify(legacy));
477
+ expect(migrated?.notice).toBeNull();
478
+ expect(migrated?.issues).toEqual([]);
479
+ });
480
+
481
+ test("a version-2 state round-trips through the published file", async () => {
482
+ const root = await repository();
483
+ const stateDir = join(root, ".devboard", ".tmp", "state");
484
+ const path = statePath(stateDir, root);
485
+ const state = migrateState(
486
+ JSON.stringify({
487
+ version: 1,
488
+ repo: "acme/fixture",
489
+ root,
490
+ lifecycle: "idle",
491
+ enabled: true,
492
+ worker: null,
493
+ staged: [],
494
+ scan: null,
495
+ newestScanId: null,
496
+ shortfall: null,
497
+ nextAttemptAt: null,
498
+ lastError: null,
499
+ notice: null,
500
+ decisions: [],
501
+ updatedAt: "2026-01-01T00:00:00Z",
502
+ }),
503
+ );
504
+ expect(state).toBeDefined();
505
+ if (state === undefined) return;
506
+ writeState(path, state);
507
+ expect(JSON.parse(readFileSync(path, "utf8")).version).toBe(2);
508
+ });
509
+ });
510
+
511
+ describe("bridge", () => {
512
+ test("a poll that extends the last line and adds a line delivers both", async () => {
513
+ const root = await repository();
514
+ const stateDir = join(root, ".devboard", ".tmp", "state");
515
+ const scan = createScanDirectory(root, new Date("2026-01-01T00:00:00Z"));
516
+ const state: ScoutState = {
517
+ version: 2,
518
+ repo: "acme/fixture",
519
+ root,
520
+ lifecycle: "idle",
521
+ enabled: true,
522
+ worker: null,
523
+ limits: { ...DEFAULT_LIMITS },
524
+ issues: [],
525
+ sync: { state: "stale", at: null, error: null },
526
+ scan: null,
527
+ newestScanId: scan.scanId,
528
+ nextAttemptAt: null,
529
+ lastError: null,
530
+ notice: null,
531
+ updatedAt: "2026-01-01T00:00:00Z",
532
+ };
533
+ writeState(statePath(stateDir, root), state);
534
+ writeActivity(scan.directory, ["a"]);
535
+ const events: unknown[] = [];
536
+ const bridge = new ScoutBridge({
537
+ repoDir: root,
538
+ stateDir,
539
+ agentCommand: "stub",
540
+ runningVersion: "0.1.0",
541
+ harnessDir: join(import.meta.dir, ".."),
542
+ send: (event) => events.push(event),
543
+ pollMs: 60_000,
544
+ spawnWorker: () => undefined,
545
+ });
546
+ try {
547
+ bridge.refresh();
548
+ writeActivity(scan.directory, ["ab", "c"]);
549
+ bridge.refresh();
550
+ const activity = events.filter(
551
+ (event) =>
552
+ typeof event === "object" &&
553
+ event !== null &&
554
+ "type" in event &&
555
+ event.type === "scoutActivity",
556
+ );
557
+ expect(activity).toEqual([
558
+ {
559
+ type: "scoutActivity",
560
+ scanId: scan.scanId,
561
+ replay: true,
562
+ entries: [{ text: "a", append: false }],
563
+ },
564
+ {
565
+ type: "scoutActivity",
566
+ scanId: scan.scanId,
567
+ entries: [
568
+ { text: "b", append: true },
569
+ { text: "c", append: false },
570
+ ],
571
+ },
572
+ ]);
573
+ bridge.refresh();
574
+ expect(
575
+ events.filter(
576
+ (event) =>
577
+ typeof event === "object" &&
578
+ event !== null &&
579
+ "type" in event &&
580
+ event.type === "scoutActivity",
581
+ ),
582
+ ).toHaveLength(2);
583
+ } finally {
584
+ bridge.dispose();
585
+ }
586
+ });
587
+
588
+ test("limits persist without a worker and the snapshot reports them", async () => {
589
+ const root = await repository();
590
+ const stateDir = join(root, ".devboard", ".tmp", "state");
591
+ const bridge = new ScoutBridge({
592
+ repoDir: root,
593
+ stateDir,
594
+ agentCommand: "stub",
595
+ runningVersion: "0.1.0",
596
+ harnessDir: join(import.meta.dir, ".."),
597
+ send: () => undefined,
598
+ pollMs: 60_000,
599
+ spawnWorker: () => undefined,
600
+ });
601
+ expect(bridge.setLimits(20, 3)).toEqual({ ok: true });
602
+ expect(readSettings(settingsPath(stateDir, root))).toEqual({
603
+ maxPerHour: 20,
604
+ maxPending: 3,
605
+ });
606
+ expect(bridge.snapshot().limits).toEqual({ maxPerHour: 20, maxPending: 3 });
607
+ expect(bridge.snapshot().issues).toEqual([]);
608
+ expect(bridge.snapshot().sync.state).toBe("stale");
609
+ for (const [perHour, pending] of [
610
+ [0, 3],
611
+ [20, 0],
612
+ [101, 3],
613
+ [20, 101],
614
+ [2.5, 3],
615
+ ]) {
616
+ const refused = bridge.setLimits(perHour, pending);
617
+ expect(refused.ok).toBe(false);
618
+ }
619
+ expect(readSettings(settingsPath(stateDir, root))).toEqual({
620
+ maxPerHour: 20,
621
+ maxPending: 3,
622
+ });
623
+ bridge.dispose();
624
+ });
625
+
626
+ test("a live worker with a legacy state reports its lifecycle and can be stopped", async () => {
627
+ const root = await repository();
628
+ const stateDir = join(root, ".devboard", ".tmp", "state");
629
+ const lock = acquireLock(lockPath(root), {
630
+ repo: root,
631
+ stateDir,
632
+ harnessVersion: null,
633
+ argv: [],
634
+ });
635
+ expect(lock.acquired).toBe(true);
636
+ if (!lock.acquired) return;
637
+ try {
638
+ const legacy = {
639
+ version: 1,
640
+ repo: "acme/fixture",
641
+ root,
642
+ lifecycle: "idle",
643
+ enabled: true,
644
+ worker: {
645
+ pid: process.pid,
646
+ startedAt: "2026-01-01T00:00:00Z",
647
+ stateDir,
648
+ harnessVersion: "0.1.0",
649
+ argv: [],
650
+ },
651
+ staged: items(3).map((item, index) => ({
652
+ ...item,
653
+ id: `id${index}`,
654
+ stagedAt: "2026-01-01T00:00:00Z",
655
+ })),
656
+ scan: null,
657
+ newestScanId: null,
658
+ shortfall: null,
659
+ nextAttemptAt: null,
660
+ lastError: null,
661
+ notice: null,
662
+ decisions: [],
663
+ updatedAt: "2026-01-01T00:00:00Z",
664
+ };
665
+ mkdirSync(dirname(statePath(stateDir, root)), { recursive: true });
666
+ writeFileSync(statePath(stateDir, root), JSON.stringify(legacy));
667
+ const bridge = new ScoutBridge({
668
+ repoDir: root,
669
+ stateDir,
670
+ agentCommand: "stub",
671
+ runningVersion: "0.1.0",
672
+ harnessDir: join(import.meta.dir, ".."),
673
+ send: () => undefined,
674
+ pollMs: 60_000,
675
+ spawnWorker: () => undefined,
676
+ });
677
+ const snapshot = bridge.snapshot();
678
+ expect(snapshot.attached).toBe(true);
679
+ expect(snapshot.present).toBe(true);
680
+ expect(snapshot.lifecycle).toBe("idle");
681
+ expect(snapshot.enabled).toBe(true);
682
+ expect(snapshot.worker?.pid).toBe(process.pid);
683
+ expect(snapshot.notice).toContain("Discarded 3 staged recommendations");
684
+ expect(bridge.stop()).toEqual({ ok: true });
685
+ const files = readdirSync(controlDirectory(stateDir, root));
686
+ expect(files).toHaveLength(1);
687
+ expect(
688
+ JSON.parse(readFileSync(join(controlDirectory(stateDir, root), files[0]!), "utf8")),
689
+ ).toEqual({ type: "stop" });
690
+ bridge.dispose();
691
+ } finally {
692
+ lock.release();
693
+ }
694
+ });
695
+
696
+ test("a live worker without readable state can still be stopped", async () => {
697
+ const root = await repository();
698
+ const stateDir = join(root, ".devboard", ".tmp", "state");
699
+ const lock = acquireLock(lockPath(root), {
700
+ repo: root,
701
+ stateDir,
702
+ harnessVersion: null,
703
+ argv: [],
704
+ });
705
+ expect(lock.acquired).toBe(true);
706
+ if (!lock.acquired) return;
707
+ try {
708
+ const bridge = new ScoutBridge({
709
+ repoDir: root,
710
+ stateDir,
711
+ agentCommand: "stub",
712
+ runningVersion: "0.1.0",
713
+ harnessDir: join(import.meta.dir, ".."),
714
+ send: () => undefined,
715
+ pollMs: 60_000,
716
+ spawnWorker: () => undefined,
717
+ });
718
+ const snapshot = bridge.snapshot();
719
+ expect(snapshot.attached).toBe(true);
720
+ expect(snapshot.present).toBe(false);
721
+ expect(bridge.stop()).toEqual({ ok: true });
722
+ expect(readdirSync(controlDirectory(stateDir, root))).toHaveLength(1);
723
+ bridge.dispose();
724
+ } finally {
725
+ lock.release();
726
+ }
727
+ });
728
+
729
+ test("the bridge exposes no decision path", () => {
730
+ const bridge = new ScoutBridge({
731
+ repoDir: process.cwd(),
732
+ stateDir: join(process.cwd(), ".tmp"),
733
+ agentCommand: "stub",
734
+ runningVersion: "0.1.0",
735
+ harnessDir: join(import.meta.dir, ".."),
736
+ send: () => undefined,
737
+ pollMs: 60_000,
738
+ spawnWorker: () => undefined,
739
+ });
740
+ expect("decide" in bridge).toBe(false);
741
+ expect("approve" in bridge).toBe(false);
742
+ bridge.dispose();
743
+ });
744
+ });
745
+
746
+ describe("command surface", () => {
747
+ test("Stop and limits parse; approve, draft, and reject do not", () => {
748
+ expect(parseCommand('{"type":"stop"}')).toEqual({ type: "stop" });
749
+ expect(parseCommand('{"type":"limits","maxPerHour":3,"maxPending":2}')).toEqual({
750
+ type: "limits",
751
+ maxPerHour: 3,
752
+ maxPending: 2,
753
+ });
754
+ expect(parseCommand('{"type":"limits","maxPerHour":0,"maxPending":2}')).toBeUndefined();
755
+ expect(parseCommand('{"type":"approve","id":"abc"}')).toBeUndefined();
756
+ expect(parseCommand('{"type":"draft","id":"abc"}')).toBeUndefined();
757
+ expect(parseCommand('{"type":"reject","id":"abc"}')).toBeUndefined();
758
+ });
759
+ });
760
+
761
+ describe("preflight", () => {
762
+ /**
763
+ * A PATH that always carries the two tools preflight looks up, so the test
764
+ * exercises the fact it names rather than the host's installed binaries.
765
+ */
766
+ async function pathWithTools(): Promise<string> {
767
+ const bin = await temporaryDirectory("scout-bin");
768
+ await writeFile(join(bin, "omp"), "");
769
+ await writeFile(join(bin, "gh"), "");
770
+ return bin;
771
+ }
772
+
773
+ test("a state directory outside the repository is refused by name", async () => {
774
+ const root = await repository();
775
+ const outside = await temporaryDirectory("scout-state");
776
+ expect(stateInsideRepository(root, outside)).toBe(false);
777
+ const result = await preflightScout({
778
+ repoDir: root,
779
+ stateDir: outside,
780
+ environment: { PATH: await pathWithTools(), HOME: outside },
781
+ });
782
+ expect(result.ok).toBe(false);
783
+ if (result.ok) return;
784
+ expect(result.message).toContain("STATE_DIR");
785
+ expect(result.message).toContain("is not inside REPO_DIR");
786
+ });
787
+
788
+ test("a missing scan command is named with the paths it looked in", async () => {
789
+ const root = await repository();
790
+ const home = await temporaryDirectory("scout-home");
791
+ const environment = { PATH: await pathWithTools(), HOME: home };
792
+ const result = await preflightScout({
793
+ repoDir: root,
794
+ stateDir: join(root, ".devboard", ".tmp", "state"),
795
+ environment,
796
+ });
797
+ expect(result.ok).toBe(false);
798
+ if (result.ok) return;
799
+ expect(result.message).toContain("/scout-recommendations command is not installed");
800
+ expect(result.message).toContain(scoutCommandPaths(root, environment)[0]!);
801
+ });
802
+
803
+ test("a checkout with no GitHub origin is refused", async () => {
804
+ const root = await makeRepository({ files: sourceFiles(1), origin: null });
805
+ const result = await preflightScout({
806
+ repoDir: root,
807
+ stateDir: join(root, ".devboard", ".tmp", "state"),
808
+ environment: { PATH: await pathWithTools(), HOME: root },
809
+ });
810
+ expect(result.ok).toBe(false);
811
+ if (result.ok) return;
812
+ expect(result.message).toContain("origin does not name a supported repository");
813
+ });
814
+
815
+ test("a write permission is required, and a granted one passes", async () => {
816
+ const root = await repository();
817
+ const home = await temporaryDirectory("scout-home");
818
+ await mkdir(join(home, ".omp", "commands"), { recursive: true });
819
+ await writeFile(join(home, ".omp", "commands", "scout-recommendations.md"), "# command\n");
820
+ const environment = { PATH: await pathWithTools(), HOME: home };
821
+ const deny = await preflightScout({
822
+ repoDir: root,
823
+ stateDir: join(root, ".devboard", ".tmp", "state"),
824
+ environment,
825
+ run: async () => ({ code: 0, stdout: "false", stderr: "" }),
826
+ });
827
+ expect(deny.ok).toBe(false);
828
+ if (!deny.ok) expect(deny.message).toContain("did not return true");
829
+ const allow = await preflightScout({
830
+ repoDir: root,
831
+ stateDir: join(root, ".devboard", ".tmp", "state"),
832
+ environment,
833
+ run: async () => ({ code: 0, stdout: "true", stderr: "" }),
834
+ });
835
+ expect(allow).toEqual({ ok: true, repository: "acme/fixture" });
836
+ });
837
+ });
838
+
839
+ describe("activity delta", () => {
840
+ test("appends growth, continues a streamed chunk, and replays a trim", () => {
841
+ expect(activityDelta([], ["a"])).toEqual({
842
+ replay: true,
843
+ entries: [{ text: "a", append: false }],
844
+ });
845
+ expect(activityDelta(["a"], ["a", "b"])).toEqual({
846
+ replay: false,
847
+ entries: [{ text: "b", append: false }],
848
+ });
849
+ expect(activityDelta(["x", "ab"], ["x", "abcd"])).toEqual({
850
+ replay: false,
851
+ entries: [{ text: "cd", append: true }],
852
+ });
853
+ expect(activityDelta(["a", "b"], ["b"])).toEqual({
854
+ replay: true,
855
+ entries: [{ text: "b", append: false }],
856
+ });
857
+ expect(activityDelta(["a"], ["a"])).toEqual({ replay: false, entries: [] });
858
+ });
859
+
860
+ test("an extended last line and a new line in one poll are both emitted", () => {
861
+ expect(activityDelta(["a"], ["ab", "c"])).toEqual({
862
+ replay: false,
863
+ entries: [
864
+ { text: "b", append: true },
865
+ { text: "c", append: false },
866
+ ],
867
+ });
868
+ expect(activityDelta(["x", "a"], ["x", "abcd", "c", "d"])).toEqual({
869
+ replay: false,
870
+ entries: [
871
+ { text: "bcd", append: true },
872
+ { text: "c", append: false },
873
+ { text: "d", append: false },
874
+ ],
875
+ });
876
+ });
877
+ });