@dsdenes/devboard 0.1.560 → 0.1.563

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +729 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,203 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { parseForgeRemote, type ForgeRemote } from "../lib/forge";
5
+
6
+ /**
7
+ * Checkout facts the issues worker and its dashboard discover at runtime.
8
+ * Nothing here names a host, a user, or a repository: every value comes from
9
+ * git, the caller, or the runtime environment.
10
+ */
11
+
12
+ export interface CommandOutcome {
13
+ code: number;
14
+ stdout: string;
15
+ stderr: string;
16
+ }
17
+
18
+ export function git(directory: string, ...args: string[]): CommandOutcome {
19
+ try {
20
+ const result = Bun.spawnSync({
21
+ cmd: ["git", "-C", directory, ...args],
22
+ stdin: "ignore",
23
+ stdout: "pipe",
24
+ stderr: "pipe",
25
+ });
26
+ return {
27
+ code: result.exitCode ?? 1,
28
+ stdout: result.stdout.toString().trim(),
29
+ stderr: result.stderr.toString().trim(),
30
+ };
31
+ } catch (error) {
32
+ return { code: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
33
+ }
34
+ }
35
+
36
+ /** The checkout root containing `directory`, or undefined outside a checkout. */
37
+ export function topLevel(directory: string): string | undefined {
38
+ const result = git(directory, "rev-parse", "--show-toplevel");
39
+ return result.code === 0 && result.stdout.length > 0 ? result.stdout : undefined;
40
+ }
41
+
42
+ /** True when the checkout at `root` is a linked worktree, not the canonical one. */
43
+ export function isLinkedWorktree(root: string): boolean {
44
+ const absolute = git(root, "rev-parse", "--absolute-git-dir");
45
+ const common = git(root, "rev-parse", "--path-format=absolute", "--git-common-dir");
46
+ if (absolute.code !== 0 || common.code !== 0) return false;
47
+ return absolute.stdout.length > 0 && absolute.stdout !== common.stdout;
48
+ }
49
+
50
+ const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
51
+
52
+ /** `owner/name` from any git remote URL form, or undefined. */
53
+ export function repositoryFromUrl(url: string): string | undefined {
54
+ const trimmed = url.trim();
55
+ const scp = /^[A-Za-z0-9_.-]+@[^:/]+:(.+)$/.exec(trimmed);
56
+ const path = scp !== null ? scp[1]! : trimmed.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/]+\//, "");
57
+ const segments = path.split("/").filter((segment) => segment.length > 0);
58
+ if (segments.length < 2) return undefined;
59
+ const name = segments[segments.length - 1]!.replace(/\.git$/, "");
60
+ const owner = segments[segments.length - 2]!;
61
+ const repository = `${owner}/${name}`;
62
+ return REPOSITORY.test(repository) ? repository : undefined;
63
+ }
64
+
65
+ /** The remote URL of `origin`, if the checkout has one. */
66
+ export function originRemoteUrl(root: string): string | undefined {
67
+ const remote = git(root, "remote", "get-url", "origin");
68
+ return remote.code === 0 && remote.stdout.length > 0 ? remote.stdout : undefined;
69
+ }
70
+
71
+ /**
72
+ * True only for a GitHub remote. GitLab, a local path, and an unreadable URL
73
+ * are all refused: this harness supports one tracker and adds no webhook.
74
+ */
75
+ export function isGitHubRemote(url: string): boolean {
76
+ const trimmed = url.trim();
77
+ const scp = /^[A-Za-z0-9_.-]+@([^:/]+):/.exec(trimmed);
78
+ if (scp !== null) return scp[1]!.toLowerCase() === "github.com";
79
+ try {
80
+ const parsed = new URL(trimmed);
81
+ return parsed.hostname.toLowerCase() === "github.com";
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** The forge the checkout's origin names, or undefined outside a supported remote. */
88
+ export function originForge(
89
+ root: string,
90
+ environment: Record<string, string | undefined> = process.env,
91
+ ): ForgeRemote | undefined {
92
+ // A directory below a checkout must not inherit that checkout's remote: the
93
+ // caller's root is the host, and only its own top level names the origin.
94
+ const top = git(root, "rev-parse", "--show-toplevel");
95
+ if (top.code !== 0) return undefined;
96
+ const found = resolve(top.stdout);
97
+ if (found !== resolve(root)) return undefined;
98
+ const remote = originRemoteUrl(root);
99
+ return remote === undefined ? undefined : parseForgeRemote(remote, environment);
100
+ }
101
+
102
+ /** The `owner/name` the checkout's origin names, or undefined. */
103
+ export function originRepository(root: string): string | undefined {
104
+ const url = originRemoteUrl(root);
105
+ return url === undefined ? undefined : repositoryFromUrl(url);
106
+ }
107
+
108
+ /**
109
+ * A live `owner/name` lookup for a process that outlives the origin's setup.
110
+ * A remote can be added or re-pointed while a dashboard runs, so the answer
111
+ * is re-read at most every `ttlMs` instead of frozen at startup. The reads
112
+ * are cached: a dashboard polls its snapshot far more often than an origin
113
+ * can change.
114
+ */
115
+ export function cachedRepository(
116
+ root: string,
117
+ ttlMs = 10_000,
118
+ ): () => string | null {
119
+ let value: string | null = originRepository(root) ?? null;
120
+ let checkedAt = Date.now();
121
+ return () => {
122
+ const now = Date.now();
123
+ if (now - checkedAt >= ttlMs) {
124
+ value = originRepository(root) ?? null;
125
+ checkedAt = now;
126
+ }
127
+ return value;
128
+ };
129
+ }
130
+
131
+ export function isRepositoryName(value: string): boolean {
132
+ return REPOSITORY.test(value);
133
+ }
134
+
135
+ /** The harness directory a canonical checkout carries. */
136
+ export function harnessDirectory(root: string): string {
137
+ return join(root, ".devboard");
138
+ }
139
+
140
+ /**
141
+ * Validates a root exactly as the removed orchestrator did: a git checkout at
142
+ * that exact path, not a linked worktree, carrying `.devboard/`.
143
+ */
144
+ export function rootProblem(root: string): string | undefined {
145
+ const absolute = resolve(root);
146
+ if (!existsSync(absolute)) return `${absolute} does not exist`;
147
+ const top = topLevel(absolute);
148
+ if (top === undefined) return `${absolute} is not a git checkout`;
149
+ if (resolve(top) !== absolute) return `${absolute} is not the checkout root (${top} is)`;
150
+ if (isLinkedWorktree(absolute)) {
151
+ return `${absolute} is a linked git worktree, not the canonical checkout`;
152
+ }
153
+ if (!existsSync(harnessDirectory(absolute))) {
154
+ return `${absolute} has no .devboard directory`;
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ /** Resolves symlinks so a worker started from a link sees the real harness. */
160
+ export function realDirectory(path: string): string | undefined {
161
+ try {
162
+ return realpathSync(path);
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ /** Finds an executable the way a shell would, using the supplied environment. */
169
+ export function findOnPath(
170
+ name: string,
171
+ environment: Record<string, string | undefined> = process.env,
172
+ ): string | undefined {
173
+ const path = environment.PATH ?? "";
174
+ for (const directory of path.split(":")) {
175
+ if (directory.length === 0) continue;
176
+ const candidate = join(directory, name);
177
+ if (existsSync(candidate)) return candidate;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /** Every directory an omp command may be installed in, checkout first. */
183
+ export function ompCommandPaths(
184
+ root: string,
185
+ environment: Record<string, string | undefined> = process.env,
186
+ ): string[] {
187
+ const home = environment.HOME ?? homedir();
188
+ return [
189
+ join(resolve(root), ".omp", "commands"),
190
+ join(resolve(root), ".omp", "agent", "commands"),
191
+ join(home, ".omp", "commands"),
192
+ join(home, ".omp", "agent", "commands"),
193
+ ];
194
+ }
195
+
196
+ /** Every directory an omp extension may be installed in, checkout first. */
197
+ export function ompExtensionPaths(
198
+ root: string,
199
+ environment: Record<string, string | undefined> = process.env,
200
+ ): string[] {
201
+ const home = environment.HOME ?? homedir();
202
+ return [join(resolve(root), ".omp", "extensions"), join(home, ".omp", "extensions")];
203
+ }
@@ -0,0 +1,255 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ authEnvironment,
4
+ isAuthFailure,
5
+ resolveForgeAuth,
6
+ type Exec,
7
+ } from "../../lib/forge-auth";
8
+ import { configuredGiteaToken, giteaBaseUrl, type Forge } from "../../lib/forge";
9
+ import {
10
+ giteaCreateIssue,
11
+ giteaEnsureLabel,
12
+ type FetchLike,
13
+ } from "../../lib/gitea";
14
+ import { originForge } from "../repository";
15
+ import { ghCommand, runCommand } from "./preflight";
16
+ import { issueBodyDirectory } from "./paths";
17
+ import { writeAtomic } from "./state";
18
+ import type { ScoutRecommendation } from "./recommendation";
19
+
20
+ /**
21
+ * Filing one recommendation as one issue in the host origin. It is called only
22
+ * from the detached worker, and a failure throws so the scan records the reason
23
+ * and files nothing else.
24
+ */
25
+
26
+ export interface GhRunner {
27
+ (argv: string[], environment: Record<string, string>): Promise<{
28
+ code: number;
29
+ stdout: string;
30
+ stderr: string;
31
+ }>;
32
+ }
33
+
34
+ export const SCOUT_LABEL = "scout";
35
+
36
+ const LABEL_DESCRIPTION = "Significant improvement recommendation from Auto Scout";
37
+
38
+ function firstLine(text: string): string {
39
+ return (
40
+ text
41
+ .split("\n")
42
+ .map((line) => line.trim())
43
+ .find((line) => line.length > 0) ?? "no diagnostic"
44
+ );
45
+ }
46
+
47
+ function evidenceLine(entry: ScoutRecommendation["evidence"][number]): string {
48
+ const quote =
49
+ entry.quote === undefined ? "" : `\n > ${entry.quote.replace(/\n/g, "\n > ")}`;
50
+ return `- \`${entry.path}:${entry.lines}\`${quote}`;
51
+ }
52
+
53
+ /** The issue body: everything the implementer needs, and nothing else. */
54
+ export function issueBody(recommendation: ScoutRecommendation): string {
55
+ return [
56
+ `## Category`,
57
+ "",
58
+ recommendation.category,
59
+ "",
60
+ "## Summary",
61
+ "",
62
+ recommendation.summary,
63
+ "",
64
+ ...(recommendation.impact.length === 0
65
+ ? []
66
+ : ["## Impact", "", recommendation.impact, ""]),
67
+ "## Significance",
68
+ "",
69
+ `${recommendation.significance}/5`,
70
+ "",
71
+ recommendation.significanceReason,
72
+ "",
73
+ "## Evidence",
74
+ "",
75
+ ...recommendation.evidence.map(evidenceLine),
76
+ "",
77
+ "## Proposed change",
78
+ "",
79
+ recommendation.proposedChange,
80
+ "",
81
+ "## Files",
82
+ "",
83
+ ...recommendation.files.map((file) => `- \`${file}\``),
84
+ "",
85
+ "## Acceptance criteria",
86
+ "",
87
+ ...recommendation.acceptanceCriteria.map((criterion) => `- ${criterion}`),
88
+ "",
89
+ "---",
90
+ "",
91
+ "Filed by Auto Scout. Implement this issue the way any other open issue is implemented.",
92
+ "",
93
+ ].join("\n");
94
+ }
95
+
96
+ export interface FileIssueOptions {
97
+ repoDir: string;
98
+ /** The host origin's `owner/name`. */
99
+ repository: string;
100
+ recommendation: ScoutRecommendation;
101
+ environment?: Record<string, string | undefined>;
102
+ /** Injectable forge commands; defaults to the operator's `gh`. */
103
+ run?: GhRunner;
104
+ exec?: Exec;
105
+ now?: () => Date;
106
+ /**
107
+ * Absolute epoch deadline for the whole filing. Every probe and command is
108
+ * killed when it elapses; commands before it are bounded by what remains.
109
+ */
110
+ deadline?: number;
111
+ /** Aborting cancels an in-flight probe or command. */
112
+ signal?: AbortSignal;
113
+ /** Overrides origin forge detection; tests use it. */
114
+ forge?: Forge;
115
+ /** Gitea HTTP origin. */
116
+ baseUrl?: string;
117
+ /** Injectable transport for the Gitea API. */
118
+ giteaFetch?: FetchLike;
119
+ }
120
+
121
+ const bunExec: Exec = async (commandName, args, options) => {
122
+ const result = await runCommand([commandName, ...args], options.env, {
123
+ cwd: options.cwd,
124
+ ...(options.timeout === undefined ? {} : { timeoutMs: options.timeout }),
125
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
126
+ });
127
+ return {
128
+ stdout: result.stdout,
129
+ stderr: result.stderr,
130
+ code: result.code,
131
+ killed: result.killed ?? false,
132
+ };
133
+ };
134
+
135
+ async function fileGiteaIssue(
136
+ options: FileIssueOptions,
137
+ environment: Record<string, string | undefined>,
138
+ ): Promise<{ url: string; number: number }> {
139
+ const remote = originForge(options.repoDir, environment);
140
+ const baseUrl = options.baseUrl ?? giteaBaseUrl(remote, environment);
141
+ if (baseUrl === undefined || baseUrl.length === 0) {
142
+ throw new Error("filing a Gitea issue needs DEVBOARD_GITEA_URL");
143
+ }
144
+ const token = configuredGiteaToken(environment);
145
+ if (token === undefined) throw new Error("filing a Gitea issue needs GITEA_TOKEN");
146
+ const target = { baseUrl, project: options.repository, token };
147
+ const label = await giteaEnsureLabel(
148
+ target,
149
+ SCOUT_LABEL,
150
+ "0e8a16",
151
+ LABEL_DESCRIPTION,
152
+ options.giteaFetch,
153
+ );
154
+ const issue = await giteaCreateIssue(
155
+ target,
156
+ {
157
+ title: options.recommendation.title,
158
+ body: issueBody(options.recommendation),
159
+ labels: [label],
160
+ },
161
+ options.giteaFetch,
162
+ );
163
+ return { url: issue.url, number: issue.number };
164
+ }
165
+
166
+ /**
167
+ * Files one issue for one staged recommendation. The label is created when it
168
+ * is absent, the body is passed as a file, and the returned URL is the only
169
+ * fact recorded in the decisions.
170
+ */
171
+ export async function fileScoutIssue(options: FileIssueOptions): Promise<{ url: string; number: number }> {
172
+ const environment = options.environment ?? process.env;
173
+ const remote = originForge(options.repoDir, environment);
174
+ const forge = options.forge ?? remote?.forge ?? "github";
175
+ if (forge === "gitea") {
176
+ return await fileGiteaIssue(options, environment);
177
+ }
178
+ const auth = await resolveForgeAuth(options.exec ?? bunExec, {
179
+ forge,
180
+ host: remote?.host ?? "github.com",
181
+ project: options.repository,
182
+ cwd: options.repoDir,
183
+ required: "write",
184
+ env: environment,
185
+ ...(options.deadline === undefined ? {} : { deadline: options.deadline }),
186
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
187
+ });
188
+ if (isAuthFailure(auth)) throw new Error(auth.error.split("\n")[0] ?? auth.error);
189
+ const forgeEnvironment = authEnvironment(auth, environment);
190
+ const run =
191
+ options.run ??
192
+ ((argv, env) =>
193
+ ghCommand(argv, env, {
194
+ ...(options.deadline === undefined
195
+ ? {}
196
+ : { timeoutMs: Math.max(1, options.deadline - Date.now()) }),
197
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
198
+ }));
199
+
200
+ const existing = await run(["api", `repos/${options.repository}/labels/${SCOUT_LABEL}`], forgeEnvironment);
201
+ if (existing.code !== 0) {
202
+ const created = await run(
203
+ [
204
+ "label",
205
+ "create",
206
+ SCOUT_LABEL,
207
+ "--repo",
208
+ options.repository,
209
+ "--description",
210
+ LABEL_DESCRIPTION,
211
+ "--color",
212
+ "0e8a16",
213
+ ],
214
+ forgeEnvironment,
215
+ );
216
+ if (created.code !== 0) {
217
+ throw new Error(`could not create the scout label: ${firstLine(created.stderr)}`);
218
+ }
219
+ }
220
+
221
+ const stamp = (options.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, "-");
222
+ const bodyPath = join(issueBodyDirectory(options.repoDir), `${options.recommendation.id}-${stamp}.md`);
223
+ writeAtomic(bodyPath, issueBody(options.recommendation));
224
+
225
+ const created = await run(
226
+ [
227
+ "issue",
228
+ "create",
229
+ "--repo",
230
+ options.repository,
231
+ "--title",
232
+ options.recommendation.title,
233
+ "--body-file",
234
+ bodyPath,
235
+ "--label",
236
+ SCOUT_LABEL,
237
+ ],
238
+ forgeEnvironment,
239
+ );
240
+ if (created.code !== 0) {
241
+ throw new Error(`could not file the issue: ${firstLine(created.stderr)}`);
242
+ }
243
+ const url = created.stdout
244
+ .split("\n")
245
+ .map((line) => line.trim())
246
+ .find((line) => /^https?:\/\//.test(line));
247
+ if (url === undefined) {
248
+ throw new Error("the issue was created without a URL");
249
+ }
250
+ const number = /\/issues\/(\d+)\/?$/.exec(url);
251
+ if (number === null) {
252
+ throw new Error("the issue was created without an issue number");
253
+ }
254
+ return { url, number: Number(number[1]) };
255
+ }
@@ -0,0 +1,80 @@
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isRecord } from "../guards";
4
+ import { isValidLimit } from "./state";
5
+
6
+ /**
7
+ * The dashboard-to-scout channel. Commands are individual files created by
8
+ * atomic rename, so the worker never reads a half-written command, and their
9
+ * names sort in submission order. A worker consumes them from any dashboard
10
+ * attached to the same repository state. Only Stop and a limits change remain:
11
+ * filing needs no command.
12
+ */
13
+
14
+ export type ScoutCommand =
15
+ | { type: "stop" }
16
+ | { type: "limits"; maxPerHour: number; maxPending: number };
17
+
18
+ let sequence = 0;
19
+
20
+ export function writeCommand(directory: string, command: ScoutCommand): string {
21
+ mkdirSync(directory, { recursive: true });
22
+ sequence += 1;
23
+ const name = `${String(Date.now()).padStart(14, "0")}-${String(sequence).padStart(6, "0")}.json`;
24
+ const path = join(directory, name);
25
+ const temporary = `${path}.${process.pid}.tmp`;
26
+ writeFileSync(temporary, `${JSON.stringify(command)}\n`);
27
+ renameSync(temporary, path);
28
+ return path;
29
+ }
30
+
31
+ export function parseCommand(text: string): ScoutCommand | undefined {
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(text);
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ if (!isRecord(parsed)) return undefined;
39
+ switch (parsed.type) {
40
+ case "stop":
41
+ return { type: "stop" };
42
+ case "limits":
43
+ if (!isValidLimit(parsed.maxPerHour) || !isValidLimit(parsed.maxPending)) return undefined;
44
+ return {
45
+ type: "limits",
46
+ maxPerHour: parsed.maxPerHour,
47
+ maxPending: parsed.maxPending,
48
+ };
49
+ default:
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /** Reads and consumes every pending command in name order. */
55
+ export function takeCommands(directory: string): ScoutCommand[] {
56
+ let names: string[];
57
+ try {
58
+ names = readdirSync(directory).filter((name) => name.endsWith(".json")).sort();
59
+ } catch {
60
+ return [];
61
+ }
62
+ const commands: ScoutCommand[] = [];
63
+ for (const name of names) {
64
+ const path = join(directory, name);
65
+ let text: string;
66
+ try {
67
+ text = readFileSync(path, "utf8");
68
+ } catch {
69
+ continue;
70
+ }
71
+ try {
72
+ unlinkSync(path);
73
+ } catch {
74
+ // Another consumer already took it.
75
+ }
76
+ const command = parseCommand(text);
77
+ if (command !== undefined) commands.push(command);
78
+ }
79
+ return commands;
80
+ }