@dsdenes/devboard 0.1.559 → 0.1.562

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +709 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,143 @@
1
+ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { GOAL_PROPOSALS_DIRECTORY } from "../../server/goal-proposal";
4
+ import { runIdentifier } from "../worker";
5
+ import {
6
+ CATEGORIES,
7
+ EXCLUDED_ROOTS,
8
+ SIGNIFICANCE_THRESHOLD,
9
+ recommendationsPath,
10
+ requestPath,
11
+ scanDirectory,
12
+ scansDirectory,
13
+ } from "./paths";
14
+ import { writeAtomic } from "./state";
15
+
16
+ /**
17
+ * One scan's request: written before the prompt and read by the agent, so the
18
+ * contract between the worker and `/scout-recommendations` is a file, not a
19
+ * convention. The request names how many issues may be filed, what to ignore,
20
+ * and which titles already exist, so the agent never restates a known finding.
21
+ */
22
+
23
+ export const SCAN_PROMPT = "/scout-recommendations";
24
+
25
+ export interface ScanRequest {
26
+ version: 2;
27
+ scanId: string;
28
+ repository: string;
29
+ repositoryPath: string;
30
+ /** How many significant findings this scan may turn into issues. */
31
+ requested: number;
32
+ categories: string[];
33
+ excludedRoots: string[];
34
+ /** Normalized titles of existing Auto Scout issues and open issues. */
35
+ knownTitles: string[];
36
+ draftGoalHeadings: string[];
37
+ /** Findings below this score are not filed. */
38
+ significanceThreshold: number;
39
+ createdAt: string;
40
+ }
41
+
42
+ export interface CreatedScan {
43
+ scanId: string;
44
+ directory: string;
45
+ startedAt: string;
46
+ }
47
+
48
+ /** A UTC timestamp name, made unique when two scans start in one millisecond. */
49
+ export function createScanDirectory(repoDir: string, now: Date): CreatedScan {
50
+ const base = scansDirectory(repoDir);
51
+ mkdirSync(base, { recursive: true });
52
+ const stamp = runIdentifier(now);
53
+ for (let attempt = 0; attempt < 100; attempt += 1) {
54
+ const scanId = attempt === 0 ? stamp : `${stamp}-${attempt}`;
55
+ const directory = scanDirectory(repoDir, scanId);
56
+ try {
57
+ mkdirSync(directory);
58
+ return { scanId, directory, startedAt: now.toISOString() };
59
+ } catch (error) {
60
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
61
+ }
62
+ }
63
+ throw new Error(`could not create a scan directory under ${base}`);
64
+ }
65
+
66
+ export function buildRequest(options: {
67
+ scan: CreatedScan;
68
+ repository: string;
69
+ repoDir: string;
70
+ requested: number;
71
+ knownTitles: string[];
72
+ draftGoalHeadings: string[];
73
+ }): ScanRequest {
74
+ return {
75
+ version: 2,
76
+ scanId: options.scan.scanId,
77
+ repository: options.repository,
78
+ repositoryPath: resolve(options.repoDir),
79
+ requested: options.requested,
80
+ categories: [...CATEGORIES],
81
+ excludedRoots: [...EXCLUDED_ROOTS],
82
+ knownTitles: [...options.knownTitles],
83
+ draftGoalHeadings: [...options.draftGoalHeadings],
84
+ significanceThreshold: SIGNIFICANCE_THRESHOLD,
85
+ createdAt: options.scan.startedAt,
86
+ };
87
+ }
88
+
89
+ export function writeRequest(directory: string, request: ScanRequest): string {
90
+ const path = requestPath(directory);
91
+ writeAtomic(path, `${JSON.stringify(request, null, 2)}\n`);
92
+ return path;
93
+ }
94
+
95
+ export function promptFor(directory: string): string {
96
+ return `${SCAN_PROMPT} ${directory}`;
97
+ }
98
+
99
+ /**
100
+ * The one correction turn a scan gets after its answer is rejected. It quotes
101
+ * the rejection and restates the two output rules the model most often
102
+ * misses, so a fixable mistake costs a turn instead of the whole finding.
103
+ */
104
+ export function repairPrompt(directory: string, rejection: string): string {
105
+ return [
106
+ `Auto Scout rejected the scan answer at ${recommendationsPath(directory)}: ${rejection}`,
107
+ "",
108
+ `Rewrite ${recommendationsPath(directory)} so it satisfies the scan prompt's output ` +
109
+ "contract, changing only what the rejection names.",
110
+ `Every \`evidence[].path\` and every \`files\` entry is relative to the \`repositoryPath\` ` +
111
+ `in ${requestPath(directory)}: a file nested under a directory keeps those leading ` +
112
+ "segments in its path. Each cited path must name an existing regular file and each " +
113
+ "line span must lie inside that file.",
114
+ "Never modify the repository and write nothing else. Finish in this turn.",
115
+ ].join("\n");
116
+ }
117
+
118
+ /** The `# heading` of every draft under `<REPO_DIR>/.devboard/goal-proposals/`. */
119
+ export function readDraftGoalHeadings(repoDir: string): string[] {
120
+ const directory = join(resolve(repoDir), GOAL_PROPOSALS_DIRECTORY);
121
+ let names: string[];
122
+ try {
123
+ names = readdirSync(directory).filter((name) => name.endsWith(".md")).sort();
124
+ } catch {
125
+ return [];
126
+ }
127
+ const headings: string[] = [];
128
+ for (const name of names) {
129
+ let text: string;
130
+ try {
131
+ text = readFileSync(join(directory, name), "utf8");
132
+ } catch {
133
+ continue;
134
+ }
135
+ for (const line of text.split("\n")) {
136
+ const trimmed = line.trim();
137
+ if (trimmed.length === 0) continue;
138
+ if (trimmed.startsWith("# ")) headings.push(trimmed.slice(2).trim());
139
+ break;
140
+ }
141
+ }
142
+ return headings;
143
+ }
@@ -0,0 +1,448 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { isInteger, isRecord, nullableString } from "../guards";
4
+ import {
5
+ DEFAULT_MAX_PER_HOUR,
6
+ DEFAULT_MAX_PENDING,
7
+ LIMIT_MAX,
8
+ LIMIT_MIN,
9
+ SCAN_VERSION,
10
+ STATE_VERSION,
11
+ scanFilePath,
12
+ } from "./paths";
13
+
14
+ /**
15
+ * The scout worker's published state. It is the whole contract between the
16
+ * detached worker and whatever dashboard is attached: the worker writes it
17
+ * atomically before publishing anything, and a dashboard only ever reads it.
18
+ * A file this module cannot understand is renamed aside, never deleted; a
19
+ * version-1 file is migrated instead, discarding every staged recommendation.
20
+ */
21
+
22
+ export type ScoutLifecycle = "stopped" | "idle" | "scanning" | "failed";
23
+ export type ScoutScanStatus = "running" | "succeeded" | "failed" | "stopped";
24
+ export type ScoutIssueState = "pending" | "implemented";
25
+ export type ScoutSyncState = "ok" | "error" | "stale";
26
+
27
+ export interface ScoutWorkerRecord {
28
+ pid: number;
29
+ startedAt: string;
30
+ stateDir: string;
31
+ harnessVersion: string | null;
32
+ argv: string[];
33
+ }
34
+
35
+ export interface ScoutAcpChild {
36
+ pid: number;
37
+ argv: string[];
38
+ cwd: string;
39
+ }
40
+
41
+ /** The adjustable caps the dashboard persists and the worker enforces. */
42
+ export interface ScoutLimits {
43
+ maxPerHour: number;
44
+ maxPending: number;
45
+ }
46
+
47
+ /** One Auto Scout issue, as GitHub last reported it. */
48
+ export interface ScoutIssueRow {
49
+ number: number;
50
+ title: string;
51
+ url: string;
52
+ createdAt: string;
53
+ /** An open issue is `pending`; a closed one is `implemented`. */
54
+ state: ScoutIssueState;
55
+ /** The recommendation's category, the body's `## Category`, or null. */
56
+ category: string | null;
57
+ /** The earliest published release that shipped the closing commit, or null. */
58
+ version: string | null;
59
+ }
60
+
61
+ /** The last periodic GitHub read and whether it succeeded. */
62
+ export interface ScoutSync {
63
+ state: ScoutSyncState;
64
+ /** The last successful read, or null before one. */
65
+ at: string | null;
66
+ /** Why the last read failed, or null. */
67
+ error: string | null;
68
+ }
69
+
70
+ export interface ScoutScanRecord {
71
+ scanId: string;
72
+ startedAt: string;
73
+ /** How many recommendations the request asked for. */
74
+ requested: number;
75
+ }
76
+
77
+ export interface ScoutState {
78
+ version: 2;
79
+ repo: string;
80
+ root: string;
81
+ lifecycle: ScoutLifecycle;
82
+ enabled: boolean;
83
+ worker: ScoutWorkerRecord | null;
84
+ limits: ScoutLimits;
85
+ /** Every Auto Scout issue known from GitHub, newest creation first. */
86
+ issues: ScoutIssueRow[];
87
+ sync: ScoutSync;
88
+ scan: ScoutScanRecord | null;
89
+ /** The newest scan, running or finished, so its activity can be replayed. */
90
+ newestScanId: string | null;
91
+ nextAttemptAt: string | null;
92
+ lastError: string | null;
93
+ notice: string | null;
94
+ updatedAt: string;
95
+ }
96
+
97
+ export const DEFAULT_LIMITS: ScoutLimits = Object.freeze({
98
+ maxPerHour: DEFAULT_MAX_PER_HOUR,
99
+ maxPending: DEFAULT_MAX_PENDING,
100
+ });
101
+
102
+ export function isValidLimit(value: unknown): value is number {
103
+ return isInteger(value) && value >= LIMIT_MIN && value <= LIMIT_MAX;
104
+ }
105
+
106
+ export function isLimits(value: unknown): value is ScoutLimits {
107
+ return isRecord(value) && isValidLimit(value.maxPerHour) && isValidLimit(value.maxPending);
108
+ }
109
+
110
+ function isIssueState(value: unknown): value is ScoutIssueState {
111
+ return value === "pending" || value === "implemented";
112
+ }
113
+
114
+ function isIssueRow(value: unknown): value is ScoutIssueRow {
115
+ return (
116
+ isRecord(value) &&
117
+ isInteger(value.number) &&
118
+ value.number > 0 &&
119
+ typeof value.title === "string" &&
120
+ typeof value.url === "string" &&
121
+ typeof value.createdAt === "string" &&
122
+ isIssueState(value.state) &&
123
+ nullableString(value.category) &&
124
+ nullableString(value.version)
125
+ );
126
+ }
127
+
128
+ function isSync(value: unknown): value is ScoutSync {
129
+ return (
130
+ isRecord(value) &&
131
+ (value.state === "ok" || value.state === "error" || value.state === "stale") &&
132
+ nullableString(value.at) &&
133
+ nullableString(value.error)
134
+ );
135
+ }
136
+
137
+ function isWorker(value: unknown): value is ScoutWorkerRecord {
138
+ return (
139
+ isRecord(value) &&
140
+ isInteger(value.pid) &&
141
+ typeof value.startedAt === "string" &&
142
+ typeof value.stateDir === "string" &&
143
+ nullableString(value.harnessVersion) &&
144
+ Array.isArray(value.argv) &&
145
+ value.argv.every((entry) => typeof entry === "string")
146
+ );
147
+ }
148
+
149
+ function isScan(value: unknown): value is ScoutScanRecord {
150
+ return (
151
+ isRecord(value) &&
152
+ typeof value.scanId === "string" &&
153
+ typeof value.startedAt === "string" &&
154
+ isInteger(value.requested)
155
+ );
156
+ }
157
+
158
+ function isLifecycle(value: unknown): value is ScoutLifecycle {
159
+ return value === "stopped" || value === "idle" || value === "scanning" || value === "failed";
160
+ }
161
+
162
+ export function emptyState(options: {
163
+ repo: string;
164
+ root: string;
165
+ lifecycle?: ScoutLifecycle;
166
+ limit?: ScoutLimits;
167
+ now?: Date;
168
+ }): ScoutState {
169
+ return {
170
+ version: STATE_VERSION,
171
+ repo: options.repo,
172
+ root: options.root,
173
+ lifecycle: options.lifecycle ?? "stopped",
174
+ enabled: false,
175
+ worker: null,
176
+ limits: options.limit ?? { ...DEFAULT_LIMITS },
177
+ issues: [],
178
+ sync: { state: "stale", at: null, error: null },
179
+ scan: null,
180
+ newestScanId: null,
181
+ nextAttemptAt: null,
182
+ lastError: null,
183
+ notice: null,
184
+ updatedAt: (options.now ?? new Date()).toISOString(),
185
+ };
186
+ }
187
+
188
+ /** Parses one version-2 document; `undefined` means the bytes are not ours. */
189
+ export function parseState(text: string): ScoutState | undefined {
190
+ let parsed: unknown;
191
+ try {
192
+ parsed = JSON.parse(text);
193
+ } catch {
194
+ return undefined;
195
+ }
196
+ if (!isRecord(parsed) || parsed.version !== STATE_VERSION) return undefined;
197
+ if (typeof parsed.repo !== "string" || typeof parsed.root !== "string") return undefined;
198
+ if (!isLifecycle(parsed.lifecycle)) return undefined;
199
+ if (typeof parsed.enabled !== "boolean") return undefined;
200
+ if (parsed.worker !== null && !isWorker(parsed.worker)) return undefined;
201
+ if (!isLimits(parsed.limits)) return undefined;
202
+ if (!Array.isArray(parsed.issues) || !parsed.issues.every(isIssueRow)) return undefined;
203
+ if (!isSync(parsed.sync)) return undefined;
204
+ if (parsed.scan !== null && !isScan(parsed.scan)) return undefined;
205
+ if (!nullableString(parsed.newestScanId)) return undefined;
206
+ if (!nullableString(parsed.nextAttemptAt) || !nullableString(parsed.lastError)) return undefined;
207
+ if (!nullableString(parsed.notice)) return undefined;
208
+ if (typeof parsed.updatedAt !== "string") return undefined;
209
+
210
+ return {
211
+ version: STATE_VERSION,
212
+ repo: parsed.repo,
213
+ root: parsed.root,
214
+ lifecycle: parsed.lifecycle,
215
+ enabled: parsed.enabled,
216
+ worker: parsed.worker as ScoutWorkerRecord | null,
217
+ limits: parsed.limits,
218
+ issues: parsed.issues,
219
+ sync: parsed.sync,
220
+ scan: parsed.scan as ScoutScanRecord | null,
221
+ newestScanId: parsed.newestScanId,
222
+ nextAttemptAt: parsed.nextAttemptAt,
223
+ lastError: parsed.lastError,
224
+ notice: parsed.notice,
225
+ updatedAt: parsed.updatedAt,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * A version-1 board kept only its staging array and its decisions. The worker
231
+ * and scan facts are carried forward, the staged recommendations are discarded
232
+ * without filing anything, and both limits take their defaults. The notice
233
+ * names the discard so the operator sees what the upgrade dropped.
234
+ */
235
+ export function migrateState(text: string): ScoutState | undefined {
236
+ let parsed: unknown;
237
+ try {
238
+ parsed = JSON.parse(text);
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ if (!isRecord(parsed) || parsed.version !== 1) return undefined;
243
+ if (typeof parsed.repo !== "string" || typeof parsed.root !== "string") return undefined;
244
+ if (!isLifecycle(parsed.lifecycle)) return undefined;
245
+ if (typeof parsed.enabled !== "boolean") return undefined;
246
+ if (parsed.worker !== null && !isWorker(parsed.worker)) return undefined;
247
+ if (!Array.isArray(parsed.staged)) return undefined;
248
+ if (parsed.scan !== null && !isScan(parsed.scan)) return undefined;
249
+ if (!nullableString(parsed.newestScanId)) return undefined;
250
+ if (!nullableString(parsed.lastError) || !nullableString(parsed.notice)) return undefined;
251
+ if (typeof parsed.updatedAt !== "string") return undefined;
252
+
253
+ const discarded = parsed.staged.length;
254
+ return {
255
+ version: STATE_VERSION,
256
+ repo: parsed.repo,
257
+ root: parsed.root,
258
+ lifecycle: parsed.lifecycle,
259
+ enabled: parsed.enabled,
260
+ worker: parsed.worker as ScoutWorkerRecord | null,
261
+ limits: { ...DEFAULT_LIMITS },
262
+ issues: [],
263
+ sync: { state: "stale", at: null, error: null },
264
+ scan: parsed.scan as ScoutScanRecord | null,
265
+ newestScanId: parsed.newestScanId,
266
+ nextAttemptAt: null,
267
+ lastError: parsed.lastError,
268
+ notice:
269
+ discarded === 0
270
+ ? parsed.notice
271
+ : `Discarded ${discarded} staged recommendation${discarded === 1 ? "" : "s"} from the ` +
272
+ "previous Auto Scout version; nothing was filed.",
273
+ updatedAt: parsed.updatedAt,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Reads without side effects; the dashboard uses this. A version-1 file is
279
+ * migrated in memory, so a dashboard upgraded ahead of its worker still reads
280
+ * the worker's lifecycle and can stop it.
281
+ */
282
+ export function readState(path: string): ScoutState | undefined {
283
+ try {
284
+ const text = readFileSync(path, "utf8");
285
+ return parseState(text) ?? migrateState(text);
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ /** Reads and migrates, moving an unusable file aside, preserved. */
292
+ export function loadState(path: string): ScoutState | undefined {
293
+ if (!existsSync(path)) return undefined;
294
+ let text: string;
295
+ try {
296
+ text = readFileSync(path, "utf8");
297
+ } catch {
298
+ return undefined;
299
+ }
300
+ const state = parseState(text) ?? migrateState(text);
301
+ if (state !== undefined) return state;
302
+ quarantine(path);
303
+ return undefined;
304
+ }
305
+
306
+ export function quarantine(path: string): string | undefined {
307
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
308
+ for (let attempt = 0; attempt < 10; attempt += 1) {
309
+ const target = `${path}.invalid-${stamp}${attempt === 0 ? "" : `-${attempt}`}`;
310
+ if (existsSync(target)) continue;
311
+ try {
312
+ renameSync(path, target);
313
+ return target;
314
+ } catch {
315
+ return undefined;
316
+ }
317
+ }
318
+ return undefined;
319
+ }
320
+
321
+ /** Temporary file plus rename: no reader ever sees a prefix of a state. */
322
+ export function writeState(path: string, state: ScoutState): void {
323
+ writeAtomic(path, `${JSON.stringify(state, null, 2)}\n`);
324
+ }
325
+
326
+ export function writeAtomic(path: string, content: string): void {
327
+ mkdirSync(dirname(path), { recursive: true });
328
+ const temporary = `${path}.${process.pid}.tmp`;
329
+ writeFileSync(temporary, content);
330
+ renameSync(temporary, path);
331
+ }
332
+
333
+ /* ------------------------------------------------------------------ */
334
+ /* Persisted limits */
335
+ /* ------------------------------------------------------------------ */
336
+
337
+ export function parseSettings(text: string): ScoutLimits | undefined {
338
+ let parsed: unknown;
339
+ try {
340
+ parsed = JSON.parse(text);
341
+ } catch {
342
+ return undefined;
343
+ }
344
+ return isLimits(parsed) ? { maxPerHour: parsed.maxPerHour, maxPending: parsed.maxPending } : undefined;
345
+ }
346
+
347
+ export function readSettings(path: string): ScoutLimits | undefined {
348
+ try {
349
+ return parseSettings(readFileSync(path, "utf8"));
350
+ } catch {
351
+ return undefined;
352
+ }
353
+ }
354
+
355
+ export function writeSettings(path: string, limits: ScoutLimits): void {
356
+ writeAtomic(path, `${JSON.stringify(limits, null, 2)}\n`);
357
+ }
358
+
359
+ /* ------------------------------------------------------------------ */
360
+ /* Scan records */
361
+ /* ------------------------------------------------------------------ */
362
+
363
+ export interface ScoutScanFile {
364
+ version: 2;
365
+ scanId: string;
366
+ repo: string;
367
+ root: string;
368
+ status: ScoutScanStatus;
369
+ startedAt: string;
370
+ finishedAt: string | null;
371
+ requested: number;
372
+ filed: number;
373
+ prompt: string;
374
+ acp: ScoutAcpChild | null;
375
+ error: string | null;
376
+ shortfall: string | null;
377
+ workerPid: number;
378
+ harnessVersion: string | null;
379
+ }
380
+
381
+ function isAcp(value: unknown): value is ScoutAcpChild {
382
+ return (
383
+ isRecord(value) &&
384
+ isInteger(value.pid) &&
385
+ Array.isArray(value.argv) &&
386
+ value.argv.every((entry) => typeof entry === "string") &&
387
+ typeof value.cwd === "string"
388
+ );
389
+ }
390
+
391
+ /** Reads a version-2 record; a version-1 record's `staged` becomes `filed`. */
392
+ export function parseScanFile(text: string): ScoutScanFile | undefined {
393
+ let parsed: unknown;
394
+ try {
395
+ parsed = JSON.parse(text);
396
+ } catch {
397
+ return undefined;
398
+ }
399
+ if (!isRecord(parsed)) return undefined;
400
+ if (parsed.version !== 1 && parsed.version !== SCAN_VERSION) return undefined;
401
+ if (typeof parsed.scanId !== "string" || typeof parsed.repo !== "string") return undefined;
402
+ if (typeof parsed.root !== "string") return undefined;
403
+ if (
404
+ parsed.status !== "running" &&
405
+ parsed.status !== "succeeded" &&
406
+ parsed.status !== "failed" &&
407
+ parsed.status !== "stopped"
408
+ ) {
409
+ return undefined;
410
+ }
411
+ if (typeof parsed.startedAt !== "string" || !nullableString(parsed.finishedAt)) return undefined;
412
+ if (!isInteger(parsed.requested)) return undefined;
413
+ const filed = parsed.version === 1 ? parsed.staged : parsed.filed;
414
+ if (!isInteger(filed)) return undefined;
415
+ if (typeof parsed.prompt !== "string") return undefined;
416
+ if (parsed.acp !== null && !isAcp(parsed.acp)) return undefined;
417
+ if (!nullableString(parsed.error) || !nullableString(parsed.shortfall)) return undefined;
418
+ if (!isInteger(parsed.workerPid) || !nullableString(parsed.harnessVersion)) return undefined;
419
+ return {
420
+ version: SCAN_VERSION,
421
+ scanId: parsed.scanId,
422
+ repo: parsed.repo,
423
+ root: parsed.root,
424
+ status: parsed.status,
425
+ startedAt: parsed.startedAt,
426
+ finishedAt: parsed.finishedAt,
427
+ requested: parsed.requested,
428
+ filed,
429
+ prompt: parsed.prompt,
430
+ acp: parsed.acp as ScoutAcpChild | null,
431
+ error: parsed.error,
432
+ shortfall: parsed.shortfall,
433
+ workerPid: parsed.workerPid,
434
+ harnessVersion: parsed.harnessVersion,
435
+ };
436
+ }
437
+
438
+ export function readScanFile(directory: string): ScoutScanFile | undefined {
439
+ try {
440
+ return parseScanFile(readFileSync(scanFilePath(directory), "utf8"));
441
+ } catch {
442
+ return undefined;
443
+ }
444
+ }
445
+
446
+ export function writeScanFile(directory: string, scan: ScoutScanFile): void {
447
+ writeAtomic(scanFilePath(directory), `${JSON.stringify(scan, null, 2)}\n`);
448
+ }