@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,1282 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { AcpConnection, type AcpActivity, type AcpQuestion } from "../../server/acp";
4
+ import { recordedChildAlive } from "../../server/state";
5
+ import { readHarnessVersion } from "../../server/version";
6
+ import { acquireLock, liveLockOwner } from "../lock";
7
+ import { originRepository } from "../repository";
8
+ import { appendActivityLine, writeActivity } from "../state";
9
+ import { fileScoutIssue } from "./actions";
10
+ import { takeCommands, type ScoutCommand } from "./control";
11
+ import {
12
+ createScoutGitHub,
13
+ shippedVersion,
14
+ type ScoutGitHubClient,
15
+ type ScoutGitHubIssue,
16
+ type ScoutRelease,
17
+ } from "./github";
18
+ import { ghCommand } from "./preflight";
19
+ import { originForge } from "../repository";
20
+ import { giteaBaseUrl } from "../../lib/forge";
21
+ import {
22
+ HOUR_MS,
23
+ SCAN_TIMEOUT_MS,
24
+ SIGNIFICANCE_THRESHOLD,
25
+ SYNC_INTERVAL_MS,
26
+ controlDirectory,
27
+ lockPath,
28
+ recommendationsPath,
29
+ scanDirectory,
30
+ settingsPath,
31
+ statePath,
32
+ } from "./paths";
33
+ import {
34
+ categoryFromBody,
35
+ normalizeTitle,
36
+ parseScanResult,
37
+ type ScanResult,
38
+ type ScoutRecommendation,
39
+ } from "./recommendation";
40
+ import {
41
+ buildRequest,
42
+ createScanDirectory,
43
+ promptFor,
44
+ readDraftGoalHeadings,
45
+ repairPrompt,
46
+ writeRequest,
47
+ } from "./scan";
48
+ import {
49
+ DEFAULT_LIMITS,
50
+ emptyState,
51
+ isValidLimit,
52
+ loadState,
53
+ readScanFile,
54
+ readSettings,
55
+ writeScanFile,
56
+ writeSettings,
57
+ writeState,
58
+ type ScoutIssueRow,
59
+ type ScoutLimits,
60
+ type ScoutScanFile,
61
+ type ScoutState,
62
+ } from "./state";
63
+ import type { AcpPeer, IssuesPeerOptions } from "../worker";
64
+
65
+ /**
66
+ * The detached worker for exactly one repository's Auto Scout. It owns the
67
+ * periodic GitHub read, the scan loop, the ACP peer, the repository lock, and
68
+ * the activity, so it outlives any dashboard process. A scan files significant
69
+ * recommendations directly as `scout` issues; nothing waits for an operator.
70
+ */
71
+
72
+ const TICK_MS = 100;
73
+ const DEFAULT_AGENT_COMMAND = "omp acp";
74
+ const FIRST_LINE_MAX = 400;
75
+ const MAX_ACTIVITY_LINE = 2000;
76
+ /** One periodic read, including its default `gh` calls, may not outlive this. */
77
+ const READ_TIMEOUT_MS = 30_000;
78
+
79
+ export interface ScoutWorkerOptions {
80
+ repoDir: string;
81
+ stateDir: string;
82
+ agentCommand?: string;
83
+ environment?: Record<string, string | undefined>;
84
+ /** The periodic GitHub read; 60 seconds in production. */
85
+ syncMs?: number;
86
+ /**
87
+ * One periodic GitHub read is abandoned after this many milliseconds; the
88
+ * sync records a failure and the next tick may read again. The default
89
+ * client applies the same deadline to each `gh` child.
90
+ */
91
+ readTimeoutMs?: number;
92
+ /**
93
+ * A scan whose agent produces no activity for this many milliseconds is
94
+ * closed as failed; the settle-and-file phase is bounded by the same value.
95
+ */
96
+ timeoutMs?: number;
97
+ now?: () => Date;
98
+ /** Injected for tests; defaults to the real ACP client. */
99
+ connect?: (options: IssuesPeerOptions) => AcpPeer;
100
+ /**
101
+ * Injected for tests; defaults to the checkout's `gh` reads. An injected
102
+ * client owns its own bound: `refresh` races the whole read against
103
+ * `readTimeoutMs`, but a call that never settles still leaks its pending
104
+ * work, so production clients must bound every command they start.
105
+ */
106
+ github?: ScoutGitHubClient;
107
+ fileIssue?: typeof fileScoutIssue;
108
+ harnessVersion?: string | null;
109
+ log?: (line: string) => void;
110
+ /** Runs after Stop has written its state and released the lock. */
111
+ onStopped?: () => void;
112
+ }
113
+
114
+ interface ActiveScan {
115
+ scanId: string;
116
+ directory: string;
117
+ requested: number;
118
+ prompt: string;
119
+ activity: string[];
120
+ peer: AcpPeer;
121
+ draftGoalHeadings: string[];
122
+ startedAt: string;
123
+ finished: boolean;
124
+ timer: Timer | undefined;
125
+ firstLine: string | null;
126
+ /** Issues filed so far, so a failed settle records the real count. */
127
+ filed: number;
128
+ /** Correction turns already requested for a rejected answer; at most one. */
129
+ repairs: number;
130
+ /** Present while reading and filing, where the scan timer no longer guards. */
131
+ settle?: {
132
+ controller: AbortController;
133
+ /** Epoch milliseconds shared by every read, probe, and filing. */
134
+ deadline: number;
135
+ timer: Timer;
136
+ finished: boolean;
137
+ };
138
+ }
139
+
140
+ /** The settle-and-file phase was aborted by its deadline or by Stop. */
141
+ class SettleAborted extends Error {
142
+ constructor() {
143
+ super("the scan stopped reading and filing");
144
+ }
145
+ }
146
+
147
+ interface GitHubRead {
148
+ issues: ScoutGitHubIssue[];
149
+ releases: ScoutRelease[];
150
+ openTitles: string[];
151
+ }
152
+
153
+ interface Capacity {
154
+ /** How many issues this cycle may create. */
155
+ batch: number;
156
+ windowCount: number;
157
+ openCount: number;
158
+ hourlyRoom: number;
159
+ pendingRoom: number;
160
+ /** When zero capacity may become positive again. */
161
+ blockedUntil: string | null;
162
+ }
163
+
164
+ /** Bounded one-line diagnostic from the peer's stderr. */
165
+ function boundedLine(text: string): string {
166
+ const line =
167
+ text
168
+ .split("\n")
169
+ .map((entry) => entry.trim())
170
+ .find((entry) => entry.length > 0) ?? "";
171
+ return line.length <= FIRST_LINE_MAX ? line : `${line.slice(0, FIRST_LINE_MAX - 1)}…`;
172
+ }
173
+
174
+ function detailOf(error: unknown): string {
175
+ return error instanceof Error ? error.message : String(error);
176
+ }
177
+
178
+ /** Newest creation first; the number breaks a same-instant tie. */
179
+ function newestFirst(rows: ScoutIssueRow[]): ScoutIssueRow[] {
180
+ return [...rows].sort((left, right) => {
181
+ const leftAt = Date.parse(left.createdAt);
182
+ const rightAt = Date.parse(right.createdAt);
183
+ if (Number.isFinite(leftAt) && Number.isFinite(rightAt) && leftAt !== rightAt) {
184
+ return rightAt - leftAt;
185
+ }
186
+ return right.number - left.number;
187
+ });
188
+ }
189
+
190
+ export class ScoutWorker {
191
+ readonly repoDir: string;
192
+ readonly stateDir: string;
193
+ readonly syncMs: number;
194
+ readonly readTimeoutMs: number;
195
+ readonly timeoutMs: number;
196
+ private readonly agentCommand: string;
197
+ private readonly environment: Record<string, string | undefined>;
198
+ private readonly clock: () => Date;
199
+ private readonly connect: NonNullable<ScoutWorkerOptions["connect"]>;
200
+ private readonly injectedGithub: ScoutGitHubClient | undefined;
201
+ private readonly fileIssue: typeof fileScoutIssue;
202
+ private readonly harnessVersion: string | null;
203
+ private readonly log: (line: string) => void;
204
+ private readonly onStopped: (() => void) | undefined;
205
+
206
+ private state: ScoutState;
207
+ private github: ScoutGitHubClient | undefined;
208
+ private active: ActiveScan | undefined;
209
+ private starting = false;
210
+ private stopping = false;
211
+ /** One consumer at a time, so two ticks never apply the same command. */
212
+ private consuming = false;
213
+ /** One GitHub read at a time. */
214
+ private syncing = false;
215
+ /** A scan is reading and filing; the periodic read waits for it. */
216
+ private filing = false;
217
+ /** The open titles from the last successful read, for deduplication. */
218
+ private openTitles: string[] = [];
219
+ private ticker: Timer | undefined;
220
+ private lock: { release: () => void } | undefined;
221
+ private lastSaved = "";
222
+ private nextSyncAt = 0;
223
+
224
+ constructor(options: ScoutWorkerOptions) {
225
+ this.repoDir = resolve(options.repoDir);
226
+ this.stateDir = resolve(options.stateDir);
227
+ this.syncMs = Math.max(1000, options.syncMs ?? SYNC_INTERVAL_MS);
228
+ this.readTimeoutMs = Math.max(1, options.readTimeoutMs ?? READ_TIMEOUT_MS);
229
+ this.timeoutMs = Math.max(1000, options.timeoutMs ?? SCAN_TIMEOUT_MS);
230
+ this.agentCommand =
231
+ options.agentCommand ??
232
+ options.environment?.AGENT_COMMAND ??
233
+ process.env.AGENT_COMMAND ??
234
+ DEFAULT_AGENT_COMMAND;
235
+ this.environment = options.environment ?? process.env;
236
+ this.clock = options.now ?? (() => new Date());
237
+ this.connect =
238
+ options.connect ?? ((peerOptions) => new AcpConnection(peerOptions) as unknown as AcpPeer);
239
+ this.injectedGithub = options.github;
240
+ this.fileIssue = options.fileIssue ?? fileScoutIssue;
241
+ this.harnessVersion =
242
+ options.harnessVersion ?? readHarnessVersion(resolve(import.meta.dir, "..", ".."));
243
+ this.log = options.log ?? (() => undefined);
244
+ this.onStopped = options.onStopped;
245
+ this.state = emptyState({ repo: "", root: this.repoDir, now: this.clock() });
246
+ }
247
+
248
+ get currentState(): ScoutState {
249
+ return this.state;
250
+ }
251
+
252
+ get scanning(): boolean {
253
+ return this.active !== undefined;
254
+ }
255
+
256
+ /** Takes the repository lock and publishes a fresh `idle` cycle. */
257
+ start(repository: string): boolean {
258
+ const lockResult = acquireLock(lockPath(this.repoDir), {
259
+ repo: this.repoDir,
260
+ stateDir: this.stateDir,
261
+ harnessVersion: this.harnessVersion,
262
+ argv: [...process.argv],
263
+ });
264
+ if (!lockResult.acquired) {
265
+ this.log(`already running for ${this.repoDir}`);
266
+ return false;
267
+ }
268
+ this.lock = lockResult;
269
+
270
+ const previous = loadState(statePath(this.stateDir, this.repoDir));
271
+ let lastError: string | null = null;
272
+ if (previous?.scan != null) {
273
+ this.closeInterruptedScan(previous.scan.scanId);
274
+ lastError = "the previous scan was interrupted; it is recorded as failed";
275
+ }
276
+
277
+ const now = this.clock();
278
+ const limits = readSettings(settingsPath(this.stateDir, this.repoDir)) ?? previous?.limits;
279
+ this.state = {
280
+ ...emptyState({ repo: repository, root: this.repoDir, limit: limits, now }),
281
+ lifecycle: "idle",
282
+ enabled: true,
283
+ worker: {
284
+ pid: process.pid,
285
+ startedAt: now.toISOString(),
286
+ stateDir: this.stateDir,
287
+ harnessVersion: this.harnessVersion,
288
+ argv: [...process.argv],
289
+ },
290
+ limits: limits ?? { ...DEFAULT_LIMITS },
291
+ issues: previous?.issues ?? [],
292
+ sync: previous?.sync ?? { state: "stale", at: null, error: null },
293
+ newestScanId: previous?.newestScanId ?? null,
294
+ lastError,
295
+ notice: previous?.notice ?? null,
296
+ updatedAt: now.toISOString(),
297
+ };
298
+ this.github =
299
+ this.injectedGithub ??
300
+ createScoutGitHub({
301
+ repository,
302
+ repoDir: this.repoDir,
303
+ environment: this.environment,
304
+ timeoutMs: this.readTimeoutMs,
305
+ ...this.forgeOptions(),
306
+ });
307
+ this.nextSyncAt = 0;
308
+ this.publish();
309
+ this.startTicker();
310
+ return true;
311
+ }
312
+
313
+ /** A scan interrupted by a dead worker is closed, never left `running`. */
314
+ private closeInterruptedScan(scanId: string): void {
315
+ const directory = scanDirectory(this.repoDir, scanId);
316
+ const scan = readScanFile(directory);
317
+ if (scan === undefined || scan.status !== "running") return;
318
+ if (scan.acp !== null && recordedChildAlive(scan.acp)) {
319
+ try {
320
+ process.kill(scan.acp.pid, "SIGKILL");
321
+ this.log(`terminated orphaned ACP child ${scan.acp.pid}`);
322
+ } catch {
323
+ // It exited between the proof and the signal.
324
+ }
325
+ }
326
+ try {
327
+ writeScanFile(directory, {
328
+ ...scan,
329
+ status: "failed",
330
+ finishedAt: this.clock().toISOString(),
331
+ error: "the worker that owned this scan exited",
332
+ });
333
+ } catch {
334
+ // The record is diagnostic; the new scan writes its own.
335
+ }
336
+ }
337
+
338
+ private publish(): void {
339
+ this.state.updatedAt = this.clock().toISOString();
340
+ const text = JSON.stringify(this.state);
341
+ if (text === this.lastSaved) return;
342
+ try {
343
+ writeState(statePath(this.stateDir, this.repoDir), this.state);
344
+ this.lastSaved = text;
345
+ } catch (error) {
346
+ this.log(`could not write state: ${detailOf(error)}`);
347
+ }
348
+ }
349
+
350
+ private startTicker(): void {
351
+ if (this.ticker !== undefined) return;
352
+ this.ticker = setInterval(() => {
353
+ void this.tick();
354
+ }, TICK_MS);
355
+ }
356
+
357
+ private async tick(): Promise<void> {
358
+ if (this.stopping) return;
359
+ await this.consumeCommands();
360
+ if (this.stopping || !this.state.enabled) return;
361
+ this.markStale();
362
+ if (this.syncing || this.filing) return;
363
+ if (this.clock().getTime() >= this.nextSyncAt) {
364
+ await this.refresh();
365
+ if (this.stopping || !this.state.enabled) return;
366
+ }
367
+ if (this.active !== undefined || this.starting) return;
368
+ if (this.state.sync.state !== "ok") return;
369
+ if (this.cooldownRemaining()) return;
370
+ const capacity = this.capacity();
371
+ if (capacity.batch === 0) {
372
+ this.block(capacity.blockedUntil);
373
+ return;
374
+ }
375
+ await this.beginScan();
376
+ }
377
+
378
+ /** A read that has not succeeded for two intervals is no longer trusted. */
379
+ private markStale(): void {
380
+ if (this.state.sync.state !== "ok") return;
381
+ const at = this.state.sync.at;
382
+ if (at === null) return;
383
+ const last = Date.parse(at);
384
+ if (!Number.isFinite(last)) return;
385
+ if (this.clock().getTime() - last <= 2 * this.syncMs) return;
386
+ this.state.sync = {
387
+ state: "stale",
388
+ at,
389
+ error: `no GitHub read has succeeded for more than ${Math.round((2 * this.syncMs) / 1000)} seconds`,
390
+ };
391
+ this.publish();
392
+ }
393
+
394
+ private cooldownRemaining(): boolean {
395
+ const until = this.state.nextAttemptAt;
396
+ if (until === null) return false;
397
+ const at = Date.parse(until);
398
+ return Number.isFinite(at) && this.clock().getTime() < at;
399
+ }
400
+
401
+ private block(until: string | null): void {
402
+ if (this.state.nextAttemptAt === until) return;
403
+ this.state.nextAttemptAt = until;
404
+ this.publish();
405
+ }
406
+
407
+ private async consumeCommands(): Promise<void> {
408
+ if (this.consuming) return;
409
+ this.consuming = true;
410
+ try {
411
+ const commands = takeCommands(controlDirectory(this.stateDir, this.repoDir));
412
+ for (const command of commands) {
413
+ if (this.stopping) return;
414
+ await this.apply(command);
415
+ }
416
+ } finally {
417
+ this.consuming = false;
418
+ }
419
+ }
420
+
421
+ private async apply(command: ScoutCommand): Promise<void> {
422
+ switch (command.type) {
423
+ case "stop": {
424
+ await this.stop();
425
+ return;
426
+ }
427
+ case "limits": {
428
+ if (!isValidLimit(command.maxPerHour) || !isValidLimit(command.maxPending)) {
429
+ this.state.lastError = "Auto Scout limits must be integers from 1 through 100.";
430
+ this.publish();
431
+ return;
432
+ }
433
+ this.state.limits = {
434
+ maxPerHour: command.maxPerHour,
435
+ maxPending: command.maxPending,
436
+ };
437
+ try {
438
+ writeSettings(settingsPath(this.stateDir, this.repoDir), this.state.limits);
439
+ } catch (error) {
440
+ this.log(`could not persist limits: ${detailOf(error)}`);
441
+ }
442
+ this.state.lastError = null;
443
+ this.state.notice =
444
+ `Limits saved: ${this.state.limits.maxPerHour} per hour, ` +
445
+ `${this.state.limits.maxPending} open.`;
446
+ this.state.nextAttemptAt = null;
447
+ this.publish();
448
+ return;
449
+ }
450
+ }
451
+ }
452
+
453
+ /* ------------------------------------------------------------------ */
454
+ /* Capacity */
455
+ /* ------------------------------------------------------------------ */
456
+
457
+ /**
458
+ * The effective batch: how many issues this cycle may create. The hourly
459
+ * window counts every Auto Scout issue created inside it, in any state, so a
460
+ * restart never forgets a filing.
461
+ */
462
+ private capacity(now: Date = this.clock()): Capacity {
463
+ const windowStart = now.getTime() - HOUR_MS;
464
+ let windowCount = 0;
465
+ let oldest = Number.POSITIVE_INFINITY;
466
+ let openCount = 0;
467
+ for (const issue of this.state.issues) {
468
+ const createdAt = Date.parse(issue.createdAt);
469
+ if (Number.isFinite(createdAt) && createdAt > windowStart) {
470
+ windowCount += 1;
471
+ if (createdAt < oldest) oldest = createdAt;
472
+ }
473
+ if (issue.state === "pending") openCount += 1;
474
+ }
475
+ const hourlyRoom = Math.max(0, this.state.limits.maxPerHour - windowCount);
476
+ const pendingRoom = Math.max(0, this.state.limits.maxPending - openCount);
477
+ const batch = Math.min(hourlyRoom, pendingRoom);
478
+ let blockedUntil: string | null = null;
479
+ if (batch === 0) {
480
+ const candidates: number[] = [];
481
+ if (pendingRoom === 0) candidates.push(now.getTime() + this.syncMs);
482
+ if (hourlyRoom === 0 && Number.isFinite(oldest)) candidates.push(oldest + HOUR_MS);
483
+ if (candidates.length === 0) candidates.push(now.getTime() + this.syncMs);
484
+ blockedUntil = new Date(Math.min(...candidates)).toISOString();
485
+ }
486
+ return { batch, windowCount, openCount, hourlyRoom, pendingRoom, blockedUntil };
487
+ }
488
+
489
+ /* ------------------------------------------------------------------ */
490
+ /* GitHub reads */
491
+ /* ------------------------------------------------------------------ */
492
+
493
+ private async readGitHub(active?: ActiveScan): Promise<GitHubRead> {
494
+ const github = active === undefined ? this.github : this.githubFor(active);
495
+ if (github === undefined) throw new Error("no GitHub client is configured");
496
+ const [issues, releases, openTitles] = await Promise.all([
497
+ github.listIssues(),
498
+ github.listReleases(),
499
+ github.listOpenIssueTitles(),
500
+ ]);
501
+ return { issues, releases, openTitles };
502
+ }
503
+
504
+ /**
505
+ * One periodic read, raced against its deadline. The default client bounds
506
+ * each `gh` child itself; this race also releases the worker from an
507
+ * injected client that never settles, so a stall can never leave `syncing`
508
+ * set forever.
509
+ */
510
+ private async readGitHubWithinDeadline(): Promise<GitHubRead> {
511
+ const timedOut = Promise.withResolvers<never>();
512
+ let timer: Timer | undefined;
513
+ timer = setTimeout(() => {
514
+ timedOut.reject(new Error(`the GitHub read did not finish within ${this.readTimeoutMs} ms`));
515
+ }, this.readTimeoutMs);
516
+ timer.unref?.();
517
+ try {
518
+ return await Promise.race([this.readGitHub(), timedOut.promise]);
519
+ } finally {
520
+ clearTimeout(timer);
521
+ }
522
+ }
523
+
524
+ /**
525
+ * The periodic check: every `scout` issue, every published release, and the
526
+ * open titles the next scan must not restate. A failed read keeps the last
527
+ * known rows, records the reason, and blocks filing until a read succeeds.
528
+ */
529
+ async refresh(): Promise<void> {
530
+ if (this.stopping || !this.state.enabled || this.syncing || this.filing) return;
531
+ this.syncing = true;
532
+ try {
533
+ let read: GitHubRead;
534
+ try {
535
+ read = await this.readGitHubWithinDeadline();
536
+ } catch (error) {
537
+ this.recordSyncFailure(detailOf(error));
538
+ return;
539
+ }
540
+ try {
541
+ await this.applyRead(read);
542
+ } catch (error) {
543
+ this.recordSyncFailure(detailOf(error));
544
+ return;
545
+ }
546
+ this.nextSyncAt = this.clock().getTime() + this.syncMs;
547
+ this.publish();
548
+ if (this.state.sync.state === "ok" && this.capacity().batch > 0) {
549
+ this.state.nextAttemptAt = null;
550
+ }
551
+ } finally {
552
+ this.syncing = false;
553
+ }
554
+ }
555
+
556
+ private recordSyncFailure(detail: string): void {
557
+ this.state.sync = { state: "error", at: this.state.sync.at, error: detail };
558
+ this.state.lastError = detail;
559
+ this.state.notice = `Could not read GitHub: ${detail}`;
560
+ this.state.nextAttemptAt = null;
561
+ this.nextSyncAt = this.clock().getTime() + this.syncMs;
562
+ this.publish();
563
+ }
564
+
565
+ private async applyRead(read: GitHubRead, active?: ActiveScan): Promise<void> {
566
+ const github = active === undefined ? this.github : this.githubFor(active);
567
+ const previous = new Map(this.state.issues.map((row) => [row.number, row]));
568
+ const rows: ScoutIssueRow[] = [];
569
+ for (const issue of read.issues) {
570
+ const before = previous.get(issue.number);
571
+ const state = issue.state === "closed" ? "implemented" : "pending";
572
+ const category = before?.category ?? categoryFromBody(issue.body);
573
+ let version: string | null = state === "implemented" ? (before?.version ?? null) : null;
574
+ if (state === "implemented" && version === null && github !== undefined) {
575
+ version = await shippedVersion(issue, github, read.releases);
576
+ }
577
+ rows.push({
578
+ number: issue.number,
579
+ title: issue.title,
580
+ url: issue.url,
581
+ createdAt: issue.createdAt,
582
+ state,
583
+ category,
584
+ version,
585
+ });
586
+ }
587
+ this.state.issues = newestFirst(rows);
588
+ this.openTitles = read.openTitles;
589
+ this.state.sync = { state: "ok", at: this.clock().toISOString(), error: null };
590
+ this.state.lastError = null;
591
+ }
592
+
593
+ /* ------------------------------------------------------------------ */
594
+ /* Scanning */
595
+ /* ------------------------------------------------------------------ */
596
+
597
+ /** One scan: one request file, one prompt, one validated answer. */
598
+ async beginScan(): Promise<void> {
599
+ if (this.stopping || this.active !== undefined || this.starting) return;
600
+ if (!this.state.enabled || this.state.sync.state !== "ok") return;
601
+ if (this.cooldownRemaining()) return;
602
+ const capacity = this.capacity();
603
+ if (capacity.batch === 0) {
604
+ this.block(capacity.blockedUntil);
605
+ return;
606
+ }
607
+ this.starting = true;
608
+ try {
609
+ const now = this.clock();
610
+ const draftGoalHeadings = readDraftGoalHeadings(this.repoDir);
611
+ const scan = createScanDirectory(this.repoDir, now);
612
+ const request = buildRequest({
613
+ scan,
614
+ repository: this.state.repo,
615
+ repoDir: this.repoDir,
616
+ requested: capacity.batch,
617
+ knownTitles: this.knownTitles(draftGoalHeadings),
618
+ draftGoalHeadings,
619
+ });
620
+ writeRequest(scan.directory, request);
621
+ const prompt = promptFor(scan.directory);
622
+ writeActivity(scan.directory, []);
623
+
624
+ let peer: AcpPeer;
625
+ try {
626
+ peer = this.connect({
627
+ command: this.agentCommand,
628
+ cwd: this.repoDir,
629
+ onActivity: (entry) => this.recordActivity(entry),
630
+ onQuestion: (question) => this.answerQuestion(question),
631
+ onStderr: (text) => this.recordStderr(text),
632
+ });
633
+ } catch (error) {
634
+ const detail = detailOf(error);
635
+ const failed = {
636
+ scanId: scan.scanId,
637
+ directory: scan.directory,
638
+ prompt,
639
+ startedAt: scan.startedAt,
640
+ };
641
+ this.writeScan(failed, "failed", { requested: capacity.batch, filed: 0, error: detail });
642
+ this.state.newestScanId = scan.scanId;
643
+ this.state.lastError = detail;
644
+ this.state.notice = `Scan failed: ${detail}`;
645
+ this.state.lifecycle = "failed";
646
+ this.state.nextAttemptAt = new Date(this.clock().getTime() + this.syncMs).toISOString();
647
+ this.publish();
648
+ return;
649
+ }
650
+
651
+ const active: ActiveScan = {
652
+ scanId: scan.scanId,
653
+ directory: scan.directory,
654
+ requested: capacity.batch,
655
+ prompt,
656
+ activity: [],
657
+ peer,
658
+ draftGoalHeadings,
659
+ startedAt: scan.startedAt,
660
+ finished: false,
661
+ timer: undefined,
662
+ firstLine: null,
663
+ filed: 0,
664
+ repairs: 0,
665
+ };
666
+ this.active = active;
667
+ this.state.scan = { scanId: scan.scanId, startedAt: scan.startedAt, requested: capacity.batch };
668
+ this.state.newestScanId = scan.scanId;
669
+ this.state.lifecycle = "scanning";
670
+ this.state.nextAttemptAt = null;
671
+ this.state.lastError = null;
672
+ this.publish();
673
+ this.writeScan(active, "running", { requested: capacity.batch, filed: 0 });
674
+
675
+ this.armScanTimer(active);
676
+
677
+ void peer
678
+ .run(prompt)
679
+ .then(() => this.completeScan(active))
680
+ .catch((error: unknown) => {
681
+ const detail =
682
+ detailOf(error).length > 0
683
+ ? detailOf(error)
684
+ : "the agent ended its turn with an error";
685
+ this.abandonScan(active, detail);
686
+ });
687
+ } finally {
688
+ this.starting = false;
689
+ }
690
+ }
691
+
692
+ /** Normalized titles the scan must not restate: issues in any state, open ones, drafts. */
693
+ private knownTitles(draftGoalHeadings: string[]): string[] {
694
+ const titles = new Set<string>();
695
+ for (const issue of this.state.issues) titles.add(normalizeTitle(issue.title));
696
+ for (const title of this.openTitles) titles.add(normalizeTitle(title));
697
+ for (const heading of draftGoalHeadings) titles.add(normalizeTitle(heading));
698
+ return [...titles].sort();
699
+ }
700
+
701
+ private recordActivity(entry: AcpActivity): void {
702
+ const active = this.active;
703
+ if (active === undefined || active.finished) return;
704
+ appendActivityLine(active.activity, entry.text, entry.append);
705
+ this.armScanTimer(active);
706
+ try {
707
+ writeActivity(active.directory, active.activity);
708
+ } catch {
709
+ // A write failure must not end the scan; the next update retries.
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Arms the agent-phase deadline. Every activity entry restarts it, so only
715
+ * an agent that has gone silent is abandoned; the settle-and-file phase
716
+ * keeps its own fixed timer.
717
+ */
718
+ private armScanTimer(active: ActiveScan): void {
719
+ clearTimeout(active.timer);
720
+ active.timer = undefined;
721
+ if (active.finished || this.active !== active) return;
722
+ active.timer = setTimeout(() => {
723
+ this.abandonScan(active, "the scan timed out");
724
+ }, this.timeoutMs);
725
+ active.timer.unref?.();
726
+ }
727
+
728
+ private recordScanLine(active: ActiveScan, text: string): void {
729
+ if (this.active !== active) return;
730
+ appendActivityLine(active.activity, text.slice(0, MAX_ACTIVITY_LINE), false);
731
+ try {
732
+ writeActivity(active.directory, active.activity);
733
+ } catch {
734
+ // Activity is diagnostic; a failed write never fails the scan.
735
+ }
736
+ }
737
+
738
+ private recordStderr(text: string): void {
739
+ const active = this.active;
740
+ if (active === undefined || active.finished) return;
741
+ if (active.firstLine !== null) return;
742
+ const line = boundedLine(text);
743
+ if (line.length > 0) active.firstLine = line;
744
+ }
745
+
746
+ /**
747
+ * A question never blocks a scan: the recommended option is chosen, or the
748
+ * first one when none is recommended, and both sides are recorded.
749
+ */
750
+ private answerQuestion(question: AcpQuestion): void {
751
+ const active = this.active;
752
+ if (active === undefined || active.finished) return;
753
+ this.recordScanLine(active, `question: ${question.message}`);
754
+ const option = question.options.find((entry) => entry.recommended) ?? question.options[0];
755
+ const value = option?.label ?? "";
756
+ const answered = active.peer.answer(question.id, value);
757
+ this.recordScanLine(
758
+ active,
759
+ answered ? `answer: ${value.length === 0 ? "(no options offered)" : value}` : "answer: failed",
760
+ );
761
+ this.publish();
762
+ }
763
+
764
+ private writeScan(
765
+ target: {
766
+ scanId: string;
767
+ directory: string;
768
+ prompt: string;
769
+ startedAt: string;
770
+ peer?: AcpPeer;
771
+ },
772
+ status: ScoutScanFile["status"],
773
+ extra: { requested: number; filed: number; error?: string; shortfall?: string },
774
+ ): void {
775
+ const acp = target.peer === undefined ? null : this.observedChild(target.peer);
776
+ const scan: ScoutScanFile = {
777
+ version: 2,
778
+ scanId: target.scanId,
779
+ repo: this.state.repo,
780
+ root: this.repoDir,
781
+ status,
782
+ startedAt: target.startedAt,
783
+ finishedAt: status === "running" ? null : this.clock().toISOString(),
784
+ requested: extra.requested,
785
+ filed: extra.filed,
786
+ prompt: target.prompt,
787
+ acp,
788
+ error: extra.error ?? null,
789
+ shortfall: extra.shortfall ?? null,
790
+ workerPid: process.pid,
791
+ harnessVersion: this.harnessVersion,
792
+ };
793
+ try {
794
+ writeScanFile(target.directory, scan);
795
+ } catch {
796
+ // The published state is the contract; the scan file is diagnostic.
797
+ }
798
+ }
799
+
800
+ /** The peer child as the process table sees it, for a later proof. */
801
+ private observedChild(peer: AcpPeer): ScoutScanFile["acp"] {
802
+ const pid = peer.pid;
803
+ if (pid === undefined) return null;
804
+ try {
805
+ const argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\u0000").filter(Boolean);
806
+ return argv.length === 0 ? null : { pid, argv, cwd: this.repoDir };
807
+ } catch {
808
+ return { pid, argv: [...peer.argv], cwd: this.repoDir };
809
+ }
810
+ }
811
+
812
+ private closePeer(active: ActiveScan): void {
813
+ try {
814
+ active.peer.close();
815
+ } catch {
816
+ // The peer already exited.
817
+ }
818
+ }
819
+
820
+ /** A scan that timed out or whose peer failed: nothing is filed. */
821
+ private abandonScan(active: ActiveScan, detail: string): void {
822
+ if (active.finished) return;
823
+ this.recordScanLine(active, `failed: ${detail}`);
824
+ active.finished = true;
825
+ clearTimeout(active.timer);
826
+ active.timer = undefined;
827
+ this.endSettle(active);
828
+ this.closePeer(active);
829
+ const error = active.firstLine ?? detail;
830
+ this.writeScan(active, "failed", {
831
+ requested: active.requested,
832
+ filed: 0,
833
+ error,
834
+ });
835
+ this.finishScan(active, error);
836
+ }
837
+
838
+ /** Starts the one deadline shared by every read, probe, and filing. */
839
+ private beginSettle(active: ActiveScan): void {
840
+ const timer = setTimeout(() => {
841
+ this.failSettle(active, "the scan timed out while reading and filing");
842
+ }, this.timeoutMs);
843
+ timer.unref?.();
844
+ active.settle = {
845
+ controller: new AbortController(),
846
+ deadline: Date.now() + this.timeoutMs,
847
+ timer,
848
+ finished: false,
849
+ };
850
+ }
851
+
852
+ /** The phase ended normally; its timer must never fire. */
853
+ private endSettle(active: ActiveScan): void {
854
+ if (active.settle === undefined || active.settle.finished) return;
855
+ active.settle.finished = true;
856
+ clearTimeout(active.settle.timer);
857
+ }
858
+
859
+ /**
860
+ * The deadline expired or Stop arrived: record one failure and abort every
861
+ * child and pending step. A Stop has already written its own state, so the
862
+ * failure is not recorded over it.
863
+ */
864
+ private failSettle(active: ActiveScan, detail: string): void {
865
+ const settle = active.settle;
866
+ if (settle === undefined || settle.finished) return;
867
+ settle.finished = true;
868
+ clearTimeout(settle.timer);
869
+ settle.controller.abort();
870
+ if (this.active !== active || this.stopping) return;
871
+ this.recordScanLine(active, `failed: ${detail}`);
872
+ this.writeScan(active, "failed", {
873
+ requested: active.requested,
874
+ filed: active.filed,
875
+ error: detail,
876
+ });
877
+ this.finishScan(active, detail);
878
+ }
879
+
880
+ /** True once no further step may touch this scan. */
881
+ private settled(active: ActiveScan): boolean {
882
+ return active.settle?.finished === true || this.active !== active;
883
+ }
884
+
885
+ /**
886
+ * Waits for one step but never past the phase deadline or Stop. The step
887
+ * itself is abandoned on abort; its `gh` child is killed by the deadline or
888
+ * the abort signal, so nothing outlives the phase.
889
+ */
890
+ private async within<T>(active: ActiveScan, work: Promise<T>): Promise<T> {
891
+ const settle = active.settle;
892
+ if (settle === undefined) return await work;
893
+ if (settle.controller.signal.aborted) throw new SettleAborted();
894
+ const aborted = Promise.withResolvers<never>();
895
+ settle.controller.signal.addEventListener(
896
+ "abort",
897
+ () => aborted.reject(new SettleAborted()),
898
+ { once: true },
899
+ );
900
+ return await Promise.race([work, aborted.promise]);
901
+ }
902
+
903
+ /** The origin forge and Gitea address, so the client never assumes GitHub. */
904
+ private forgeOptions(): {
905
+ forge?: "github" | "gitlab" | "gitea";
906
+ baseUrl?: string;
907
+ } {
908
+ const remote = originForge(this.repoDir, this.environment);
909
+ if (remote === undefined) return {};
910
+ return {
911
+ forge: remote.forge,
912
+ ...(remote.forge === "gitea"
913
+ ? { baseUrl: giteaBaseUrl(remote, this.environment) }
914
+ : {}),
915
+ };
916
+ }
917
+
918
+ /**
919
+ * The GitHub client for a settle step. The periodic client is shared with
920
+ * `refresh`, so only the settle path builds a bounded one.
921
+ */
922
+ private githubFor(active: ActiveScan): ScoutGitHubClient {
923
+ if (this.injectedGithub !== undefined) return this.injectedGithub;
924
+ const settle = active.settle;
925
+ return createScoutGitHub({
926
+ repository: this.state.repo,
927
+ repoDir: this.repoDir,
928
+ environment: this.environment,
929
+ ...this.forgeOptions(),
930
+ run: (argv, env) =>
931
+ ghCommand(argv, env, {
932
+ ...(settle === undefined
933
+ ? {}
934
+ : {
935
+ timeoutMs: Math.max(1, settle.deadline - Date.now()),
936
+ signal: settle.controller.signal,
937
+ }),
938
+ }),
939
+ });
940
+ }
941
+
942
+ private finishScan(active: ActiveScan, error: string | null): void {
943
+ this.endSettle(active);
944
+ this.filing = false;
945
+ this.active = undefined;
946
+ this.state.scan = null;
947
+ this.state.newestScanId = active.scanId;
948
+ this.state.lastError = error;
949
+ this.state.notice = error === null ? this.state.notice : `Scan failed: ${error}`;
950
+ this.state.lifecycle = error === null ? "idle" : "failed";
951
+ const now = this.clock();
952
+ const capacity = this.capacity(now);
953
+ this.state.nextAttemptAt =
954
+ capacity.batch > 0
955
+ ? new Date(now.getTime() + this.syncMs).toISOString()
956
+ : capacity.blockedUntil;
957
+ this.publish();
958
+ }
959
+
960
+ /**
961
+ * Reads and validates the answer file. A missing file, invalid JSON, and a
962
+ * rejected document are all the same kind of failure: the correction turn
963
+ * gets the message, and an uncorrected scan files nothing.
964
+ */
965
+ private readAnswer(
966
+ active: ActiveScan,
967
+ ): { ok: true; result: Extract<ScanResult, { ok: true }> } | { ok: false; error: string } {
968
+ let text: string;
969
+ try {
970
+ text = readFileSync(recommendationsPath(active.directory), "utf8");
971
+ } catch {
972
+ return { ok: false, error: "the agent wrote no recommendations.json" };
973
+ }
974
+ let parsed: unknown;
975
+ try {
976
+ parsed = JSON.parse(text);
977
+ } catch {
978
+ return { ok: false, error: "recommendations.json is not valid JSON" };
979
+ }
980
+ const result = parseScanResult(parsed, {
981
+ requested: active.requested,
982
+ repoDir: this.repoDir,
983
+ });
984
+ return result.ok ? { ok: true, result } : { ok: false, error: result.error };
985
+ }
986
+
987
+ /**
988
+ * Reads and validates the answer, asks the agent for one correction when the
989
+ * answer is rejected while the scan timer still guards the phase, re-reads
990
+ * GitHub immediately before filing, then files every significant, novel item
991
+ * the caps allow. A malformed or insignificant item never reaches `gh`; a
992
+ * failed read files nothing.
993
+ */
994
+ private async completeScan(active: ActiveScan): Promise<void> {
995
+ if (active.finished) return;
996
+
997
+ let answer = this.readAnswer(active);
998
+ if (!answer.ok) {
999
+ this.recordScanLine(active, `rejected: ${answer.error}`);
1000
+ if (active.repairs === 0) {
1001
+ active.repairs = 1;
1002
+ this.recordScanLine(active, "asked the agent to correct the answer");
1003
+ try {
1004
+ await active.peer.prompt(repairPrompt(active.directory, answer.error));
1005
+ } catch (error) {
1006
+ if (active.finished || this.active !== active) return;
1007
+ this.recordScanLine(active, `correction failed: ${detailOf(error)}`);
1008
+ }
1009
+ if (active.finished || this.active !== active) return;
1010
+ answer = this.readAnswer(active);
1011
+ if (!answer.ok) this.recordScanLine(active, `rejected: ${answer.error}`);
1012
+ }
1013
+ }
1014
+ if (!answer.ok) {
1015
+ this.writeScan(active, "failed", {
1016
+ requested: active.requested,
1017
+ filed: 0,
1018
+ error: answer.error,
1019
+ });
1020
+ this.finishScan(active, answer.error);
1021
+ return;
1022
+ }
1023
+
1024
+ // The answer is final: claim the terminal path before touching the
1025
+ // tracker, so the scan timer can no longer close a scan that is settling.
1026
+ active.finished = true;
1027
+ clearTimeout(active.timer);
1028
+ active.timer = undefined;
1029
+ this.closePeer(active);
1030
+ const result = answer.result;
1031
+
1032
+ // Immediately before filing, read every scout issue again. The periodic
1033
+ // read stands aside until this scan has finished filing, so a stale apply
1034
+ // can never drop a row that was just created. A failed read files nothing
1035
+ // and surfaces the reason; the next read retries. Every wait and command
1036
+ // shares one deadline, because the scan timer no longer guards this phase.
1037
+ this.filing = true;
1038
+ this.beginSettle(active);
1039
+ try {
1040
+ while (this.syncing) await this.within(active, Bun.sleep(5));
1041
+ let read: GitHubRead;
1042
+ try {
1043
+ read = await this.within(active, this.readGitHub(active));
1044
+ } catch (error) {
1045
+ if (error instanceof SettleAborted) return;
1046
+ const detail = detailOf(error);
1047
+ this.recordScanLine(active, `failed: ${detail}`);
1048
+ this.recordSyncFailure(detail);
1049
+ this.writeScan(active, "failed", {
1050
+ requested: active.requested,
1051
+ filed: active.filed,
1052
+ error: detail,
1053
+ });
1054
+ this.finishScan(active, detail);
1055
+ return;
1056
+ }
1057
+ try {
1058
+ await this.within(active, this.applyRead(read, active));
1059
+ } catch (error) {
1060
+ if (error instanceof SettleAborted) return;
1061
+ const detail = detailOf(error);
1062
+ this.recordScanLine(active, `failed: ${detail}`);
1063
+ this.recordSyncFailure(detail);
1064
+ this.writeScan(active, "failed", {
1065
+ requested: active.requested,
1066
+ filed: active.filed,
1067
+ error: detail,
1068
+ });
1069
+ this.finishScan(active, detail);
1070
+ return;
1071
+ }
1072
+ if (this.settled(active)) return;
1073
+ this.publish();
1074
+
1075
+ const known = new Set(this.knownTitles(active.draftGoalHeadings));
1076
+ const capacity = this.capacity();
1077
+ const limit = Math.min(active.requested, capacity.batch);
1078
+ let filingError: string | null = null;
1079
+ let lastFiled: { number: number; title: string } | null = null;
1080
+ for (const item of result.items) {
1081
+ if (this.settled(active) || this.stopping) break;
1082
+ if (item.significance < SIGNIFICANCE_THRESHOLD) {
1083
+ this.recordScanLine(
1084
+ active,
1085
+ `dropped “${item.title}” — significance ${item.significance}/5 is below ` +
1086
+ `${SIGNIFICANCE_THRESHOLD}`,
1087
+ );
1088
+ continue;
1089
+ }
1090
+ const key = normalizeTitle(item.title);
1091
+ if (known.has(key)) {
1092
+ this.recordScanLine(active, `dropped duplicate “${item.title}”`);
1093
+ continue;
1094
+ }
1095
+ if (active.filed >= limit) break;
1096
+ const settle = active.settle;
1097
+ let created: { url: string; number: number };
1098
+ try {
1099
+ created = await this.within(
1100
+ active,
1101
+ this.fileIssue({
1102
+ repoDir: this.repoDir,
1103
+ repository: this.state.repo,
1104
+ recommendation: item,
1105
+ environment: this.environment,
1106
+ ...(settle === undefined
1107
+ ? {}
1108
+ : { deadline: settle.deadline, signal: settle.controller.signal }),
1109
+ }),
1110
+ );
1111
+ } catch (error) {
1112
+ if (error instanceof SettleAborted) return;
1113
+ filingError = detailOf(error);
1114
+ this.recordScanLine(active, `failed to file “${item.title}”: ${filingError}`);
1115
+ break;
1116
+ }
1117
+ known.add(key);
1118
+ active.filed += 1;
1119
+ lastFiled = { number: created.number, title: item.title };
1120
+ const createdAt = this.clock().toISOString();
1121
+ this.state.issues = newestFirst([
1122
+ ...this.state.issues.filter((row) => row.number !== created.number),
1123
+ {
1124
+ number: created.number,
1125
+ title: item.title,
1126
+ url: created.url,
1127
+ createdAt,
1128
+ state: "pending",
1129
+ category: item.category,
1130
+ version: null,
1131
+ },
1132
+ ]);
1133
+ this.recordScanLine(active, `filed #${created.number} “${item.title}”`);
1134
+ this.publish();
1135
+ }
1136
+
1137
+ if (this.settled(active)) return;
1138
+ if (this.stopping) {
1139
+ this.filing = false;
1140
+ this.active = undefined;
1141
+ this.endSettle(active);
1142
+ this.writeScan(active, "stopped", { requested: active.requested, filed: active.filed });
1143
+ return;
1144
+ }
1145
+
1146
+ if (lastFiled !== null) {
1147
+ this.state.notice =
1148
+ active.filed === 1
1149
+ ? `Filed #${lastFiled.number} “${lastFiled.title}”`
1150
+ : `Filed ${active.filed} issues.`;
1151
+ }
1152
+
1153
+ const shortfall =
1154
+ active.filed < active.requested
1155
+ ? (filingError ??
1156
+ result.shortfall ??
1157
+ `filed ${active.filed} of the ${active.requested} requested`)
1158
+ : null;
1159
+ if (shortfall !== null) this.recordScanLine(active, `shortfall: ${shortfall}`);
1160
+ this.writeScan(active, filingError === null ? "succeeded" : "failed", {
1161
+ requested: active.requested,
1162
+ filed: active.filed,
1163
+ ...(filingError === null ? {} : { error: filingError }),
1164
+ ...(shortfall === null ? {} : { shortfall }),
1165
+ });
1166
+ this.finishScan(active, filingError);
1167
+ } catch (error) {
1168
+ if (error instanceof SettleAborted) return;
1169
+ throw error;
1170
+ }
1171
+ }
1172
+
1173
+ /** Stop: end the peer, keep the rows and limits, release the lock. */
1174
+ async stop(): Promise<void> {
1175
+ if (this.stopping) return;
1176
+ this.stopping = true;
1177
+ const active = this.active;
1178
+ if (active !== undefined) {
1179
+ if (active.settle !== undefined && !active.settle.finished) {
1180
+ // The scan timer no longer guards the filing phase; Stop aborts it
1181
+ // instead of waiting for a stalled read or filing to finish.
1182
+ active.settle.finished = true;
1183
+ clearTimeout(active.settle.timer);
1184
+ active.settle.controller.abort();
1185
+ this.filing = false;
1186
+ this.writeScan(active, "stopped", { requested: active.requested, filed: active.filed });
1187
+ } else if (!active.finished) {
1188
+ active.finished = true;
1189
+ clearTimeout(active.timer);
1190
+ active.timer = undefined;
1191
+ this.closePeer(active);
1192
+ this.writeScan(active, "stopped", { requested: active.requested, filed: 0 });
1193
+ }
1194
+ this.active = undefined;
1195
+ }
1196
+ this.state.enabled = false;
1197
+ this.state.scan = null;
1198
+ this.state.nextAttemptAt = null;
1199
+ this.state.notice = null;
1200
+ this.state.lifecycle = "stopped";
1201
+ this.publish();
1202
+ if (this.ticker !== undefined) {
1203
+ clearInterval(this.ticker);
1204
+ this.ticker = undefined;
1205
+ }
1206
+ this.lock?.release();
1207
+ this.lock = undefined;
1208
+ this.onStopped?.();
1209
+ }
1210
+
1211
+ async shutdown(): Promise<void> {
1212
+ await this.stop();
1213
+ }
1214
+ }
1215
+
1216
+ function parseArguments(
1217
+ args: string[],
1218
+ ): { repoDir: string; stateDir: string; syncMs?: number } | undefined {
1219
+ let repoDir: string | undefined;
1220
+ let stateDir: string | undefined;
1221
+ let syncMs: number | undefined;
1222
+ for (let index = 0; index < args.length; index += 1) {
1223
+ const argument = args[index];
1224
+ if (argument === "--repo" || argument === "--state-dir" || argument === "--sync") {
1225
+ const value = args[index + 1];
1226
+ index += 1;
1227
+ if (value === undefined) return undefined;
1228
+ if (argument === "--repo") repoDir = value;
1229
+ else if (argument === "--state-dir") stateDir = value;
1230
+ else {
1231
+ const parsed = Number(value);
1232
+ if (Number.isFinite(parsed) && parsed >= 1000) syncMs = parsed;
1233
+ }
1234
+ }
1235
+ }
1236
+ if (repoDir === undefined || stateDir === undefined) return undefined;
1237
+ return { repoDir, stateDir, ...(syncMs === undefined ? {} : { syncMs }) };
1238
+ }
1239
+
1240
+ async function main(): Promise<void> {
1241
+ const parsed = parseArguments(process.argv.slice(2));
1242
+ if (parsed === undefined) {
1243
+ console.error("usage: worker.ts --repo <checkout> --state-dir <dir> [--sync <ms>]");
1244
+ process.exit(2);
1245
+ }
1246
+ const repoDir = resolve(parsed.repoDir);
1247
+ const stateDir = resolve(parsed.stateDir);
1248
+ const repository = originRepository(repoDir) ?? "";
1249
+ if (repository.length === 0) {
1250
+ console.error("scout worker: the checkout's origin does not name a repository");
1251
+ process.exit(2);
1252
+ }
1253
+ if (liveLockOwner(lockPath(repoDir)) !== undefined) {
1254
+ console.error("scout worker: this checkout already has a live scout");
1255
+ process.exit(0);
1256
+ }
1257
+
1258
+ const environmentSync = Number(process.env.SCOUT_SYNC_MS);
1259
+ const syncMs =
1260
+ parsed.syncMs ??
1261
+ (Number.isFinite(environmentSync) && environmentSync >= 1000 ? environmentSync : undefined);
1262
+
1263
+ const worker = new ScoutWorker({
1264
+ repoDir,
1265
+ stateDir,
1266
+ ...(syncMs === undefined ? {} : { syncMs }),
1267
+ log: (line) => console.error(`scout: ${line}`),
1268
+ onStopped: () => process.exit(0),
1269
+ });
1270
+ if (!worker.start(repository)) process.exit(0);
1271
+
1272
+ const shutdown = (signal: NodeJS.Signals): void => {
1273
+ console.error(`scout: received ${signal}`);
1274
+ void worker.shutdown().finally(() => process.exit(0));
1275
+ };
1276
+ process.on("SIGTERM", shutdown);
1277
+ process.on("SIGINT", shutdown);
1278
+ }
1279
+
1280
+ if (import.meta.main) {
1281
+ await main();
1282
+ }