@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,249 @@
1
+ import { readFileSync, readlinkSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { journalKey } from "./journal";
4
+
5
+ /**
6
+ * Durable dashboard state for one host repository. The server writes the whole
7
+ * state before it broadcasts any event, and a replacement process restores it,
8
+ * so a graceful stop, a launcher restart, or a SIGKILL loses no state a client
9
+ * has already seen. One file per repository keyed like the journal keeps two
10
+ * repositories that share a harness from reading each other's state.
11
+ */
12
+
13
+ /** Envelope version. A file with another version is preserved, never read. */
14
+ export const STATE_VERSION = 1;
15
+
16
+ /** Restored activity is bounded to the first of these limits. */
17
+ export const ACTIVITY_MAX_ENTRIES = 1000;
18
+ export const ACTIVITY_MAX_BYTES = 1024 * 1024;
19
+
20
+ /** Replaces trimmed lines so the reader knows the log is older than it looks. */
21
+ export const ACTIVITY_TRIMMED_MARKER = "Earlier activity was trimmed.";
22
+
23
+ /** Total serialized state one repository may keep with every activity bound full. */
24
+ export const STATE_MAX_BYTES = 32 * 1024 * 1024;
25
+
26
+ /** The 24-hex repository key that names this repository's state file. */
27
+ export const stateKey = journalKey;
28
+
29
+ export function stateFile(stateDir: string, repoDir: string): string {
30
+ return join(stateDir, "state", `${stateKey(repoDir)}.json`);
31
+ }
32
+
33
+ export type StateLoad =
34
+ | { kind: "missing" }
35
+ /** The file parsed and named this version; `value` is the envelope's state. */
36
+ | { kind: "loaded"; value: unknown }
37
+ /** Unusable bytes; they were preserved aside when the rename succeeded. */
38
+ | { kind: "damaged"; reason: string; preserved: string | undefined };
39
+
40
+ /** Moves unusable bytes aside so they are never overwritten or deleted. */
41
+ function preserve(path: string): string | undefined {
42
+ const aside = `${path}.damaged-${Date.now()}`;
43
+ try {
44
+ renameSync(path, aside);
45
+ return aside;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ /** Moves a state file whose contents cannot be used out of the way, unread. */
52
+ export function preserveStateFile(path: string): string | undefined {
53
+ return preserve(path);
54
+ }
55
+
56
+ export function readStateFile(path: string): StateLoad {
57
+ let source: string;
58
+ try {
59
+ source = readFileSync(path, "utf8");
60
+ } catch (error) {
61
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
62
+ return {
63
+ kind: "damaged",
64
+ reason: `could not read ${path}: ${error instanceof Error ? error.message : String(error)}`,
65
+ preserved: preserve(path),
66
+ };
67
+ }
68
+
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(source);
72
+ } catch (error) {
73
+ return {
74
+ kind: "damaged",
75
+ reason: `the state file is not valid JSON: ${
76
+ error instanceof Error ? error.message : String(error)
77
+ }`,
78
+ preserved: preserve(path),
79
+ };
80
+ }
81
+
82
+ if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
83
+ return {
84
+ kind: "damaged",
85
+ reason: "the state file is not a state envelope",
86
+ preserved: preserve(path),
87
+ };
88
+ }
89
+
90
+ const version = parsed.version;
91
+ if (version !== STATE_VERSION) {
92
+ return {
93
+ kind: "damaged",
94
+ reason:
95
+ typeof version === "number"
96
+ ? `the state file has unsupported version ${version}`
97
+ : "the state file has no version",
98
+ preserved: preserve(path),
99
+ };
100
+ }
101
+
102
+ if (!("state" in parsed)) {
103
+ return {
104
+ kind: "damaged",
105
+ reason: "the state envelope holds no state",
106
+ preserved: preserve(path),
107
+ };
108
+ }
109
+ const value = parsed.state;
110
+ if (typeof value !== "object" || value === null) {
111
+ return {
112
+ kind: "damaged",
113
+ reason: "the state envelope holds no state",
114
+ preserved: preserve(path),
115
+ };
116
+ }
117
+
118
+ return { kind: "loaded", value };
119
+ }
120
+
121
+ /**
122
+ * Replaces the state file atomically: the last complete state stays readable at
123
+ * every instant, and a crash during the write leaves the previous state.
124
+ */
125
+ export function writeStateFile(path: string, text: string): void {
126
+ mkdirSync(dirname(path), { recursive: true });
127
+ // A per-process name keeps two writers for different repositories apart even
128
+ // when they share one directory.
129
+ const temporary = `${path}.${process.pid}.tmp`;
130
+ try {
131
+ writeFileSync(temporary, text);
132
+ renameSync(temporary, path);
133
+ } catch (error) {
134
+ try {
135
+ unlinkSync(temporary);
136
+ } catch {
137
+ // The temporary file was never created or is already gone.
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ function byteLength(text: string): number {
144
+ return Buffer.byteLength(text, "utf8");
145
+ }
146
+
147
+ export function activityWithinBounds(store: string[]): boolean {
148
+ if (store.length > ACTIVITY_MAX_ENTRIES) return false;
149
+ let bytes = 0;
150
+ for (const line of store) {
151
+ bytes += byteLength(line);
152
+ if (bytes > ACTIVITY_MAX_BYTES) return false;
153
+ }
154
+ return true;
155
+ }
156
+
157
+ /**
158
+ * Keeps the newest activity entries inside the bound and prefixes one marker so
159
+ * the trimmed log never looks complete. The newest entry is always kept, even
160
+ * when it alone exceeds the byte bound.
161
+ */
162
+ export function trimActivity(store: string[]): void {
163
+ if (activityWithinBounds(store)) return;
164
+
165
+ const markerBytes = byteLength(ACTIVITY_TRIMMED_MARKER);
166
+ const kept: string[] = [];
167
+ let bytes = markerBytes;
168
+ for (let index = store.length - 1; index >= 0; index -= 1) {
169
+ const line = store[index];
170
+ if (line === undefined) continue;
171
+ const size = byteLength(line);
172
+ const entries = kept.length + 2;
173
+ if (kept.length > 0 && (entries > ACTIVITY_MAX_ENTRIES || bytes + size > ACTIVITY_MAX_BYTES)) {
174
+ break;
175
+ }
176
+ kept.unshift(line);
177
+ bytes += size;
178
+ }
179
+
180
+ store.splice(0, store.length, ACTIVITY_TRIMMED_MARKER, ...kept);
181
+ }
182
+
183
+ /** Records one activity line, or one streamed delta, inside the activity bound. */
184
+ export function appendActivity(store: string[], text: string, append: boolean): void {
185
+ if (append && store.length > 0) {
186
+ store[store.length - 1] += text;
187
+ } else {
188
+ store.push(text);
189
+ }
190
+ trimActivity(store);
191
+ }
192
+
193
+ /** One ACP child recorded in the state so a replacement can prove and end it. */
194
+ export interface RecordedChild {
195
+ pid: number;
196
+ argv: string[];
197
+ cwd: string;
198
+ }
199
+
200
+ /** True only when the pid still runs exactly the recorded command in the recorded cwd. */
201
+ export function recordedChildAlive(child: RecordedChild): boolean {
202
+ try {
203
+ process.kill(child.pid, 0);
204
+ } catch {
205
+ return false;
206
+ }
207
+ let cmdline: string;
208
+ try {
209
+ cmdline = readFileSync(`/proc/${child.pid}/cmdline`, "utf8");
210
+ } catch {
211
+ // Without proof from the process table the child is left alone.
212
+ return false;
213
+ }
214
+ const argv = cmdline.split("\u0000").filter((entry) => entry.length > 0);
215
+ if (argv.length !== child.argv.length) return false;
216
+ for (const [index, entry] of child.argv.entries()) {
217
+ if (argv[index] !== entry) return false;
218
+ }
219
+ try {
220
+ return readlinkSync(`/proc/${child.pid}/cwd`) === child.cwd;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Ends every recorded child whose process table entry proves it is the peer the
228
+ * killed process left behind, and waits until it is gone. A pid that cannot be
229
+ * proven is never signalled.
230
+ */
231
+ export async function terminateRecordedChildren(
232
+ children: RecordedChild[],
233
+ timeoutMs = 5000,
234
+ ): Promise<RecordedChild[]> {
235
+ const proven = children.filter((child) => recordedChildAlive(child));
236
+ for (const child of proven) {
237
+ try {
238
+ process.kill(child.pid, "SIGKILL");
239
+ } catch {
240
+ // It exited between the check and the signal.
241
+ }
242
+ }
243
+
244
+ const deadline = Date.now() + timeoutMs;
245
+ while (Date.now() < deadline && proven.some((child) => recordedChildAlive(child))) {
246
+ await Bun.sleep(20);
247
+ }
248
+ return proven;
249
+ }
@@ -0,0 +1,369 @@
1
+ import type { Subprocess } from "bun";
2
+ import {
3
+ authEnvironment,
4
+ isAuthFailure,
5
+ resolveForgeAuth,
6
+ type Exec,
7
+ type Forge,
8
+ type ResolvedAuth,
9
+ } from "../lib/forge-auth.ts";
10
+ import { configuredGiteaToken, giteaBaseUrl, parseForgeRemote } from "../lib/forge";
11
+ import { giteaListIssues } from "../lib/gitea";
12
+
13
+ export interface OpenEpic {
14
+ number: number;
15
+ title: string;
16
+ url: string;
17
+ }
18
+
19
+ /**
20
+ * Reads every open epic on the origin tracker. A lookup may return one epic or
21
+ * `undefined` for the single-epic callers tests use; the lifecycle normalizes
22
+ * both shapes with `normalizeEpics`.
23
+ */
24
+ export type EpicLookup = (
25
+ repoDir: string,
26
+ ) => Promise<OpenEpic[] | OpenEpic | undefined>;
27
+
28
+ /** Normalizes a tracker answer to the list shape the lifecycle consumes. */
29
+ export function normalizeEpics(value: OpenEpic[] | OpenEpic | undefined): OpenEpic[] {
30
+ if (value === undefined) return [];
31
+ return Array.isArray(value) ? value : [value];
32
+ }
33
+
34
+ interface Remote {
35
+ forge: Forge;
36
+ host: string;
37
+ project: string;
38
+ baseUrl?: string;
39
+ }
40
+
41
+ interface CommandResult {
42
+ stdout: string;
43
+ stderr: string;
44
+ exitCode: number;
45
+ }
46
+
47
+ interface LabelEntry {
48
+ name?: unknown;
49
+ }
50
+
51
+ interface IssueEntry {
52
+ number?: unknown;
53
+ iid?: unknown;
54
+ title?: unknown;
55
+ url?: unknown;
56
+ web_url?: unknown;
57
+ }
58
+
59
+ function baseEnvironment(): Record<string, string> {
60
+ const environment: Record<string, string> = {};
61
+ for (const [key, value] of Object.entries(process.env)) {
62
+ if (typeof value === "string") environment[key] = value;
63
+ }
64
+ return {
65
+ ...environment,
66
+ GH_PROMPT_DISABLED: "1",
67
+ GH_NO_UPDATE_NOTIFIER: "1",
68
+ GIT_TERMINAL_PROMPT: "0",
69
+ GIT_ASKPASS: "true",
70
+ NO_COLOR: "1",
71
+ };
72
+ }
73
+
74
+ const bunExec: Exec = async (commandName, args, options) => {
75
+ try {
76
+ const process = Bun.spawn({
77
+ cmd: [commandName, ...args],
78
+ cwd: options.cwd,
79
+ env: options.env,
80
+ stdin: "ignore",
81
+ stdout: "pipe",
82
+ stderr: "pipe",
83
+ });
84
+ const [stdout, stderr, code] = await Promise.all([
85
+ new Response(process.stdout).text(),
86
+ new Response(process.stderr).text(),
87
+ process.exited,
88
+ ]);
89
+ return { stdout, stderr, code };
90
+ } catch (error) {
91
+ return {
92
+ stdout: "",
93
+ stderr: error instanceof Error ? error.message : String(error),
94
+ code: -1,
95
+ killed: false,
96
+ };
97
+ }
98
+ };
99
+
100
+ async function command(
101
+ cwd: string,
102
+ argv: string[],
103
+ env: Record<string, string> = baseEnvironment(),
104
+ ): Promise<CommandResult> {
105
+ let process: Subprocess<"ignore", "pipe", "pipe">;
106
+ try {
107
+ process = Bun.spawn(argv, {
108
+ cwd,
109
+ env,
110
+ stdin: "ignore",
111
+ stdout: "pipe",
112
+ stderr: "pipe",
113
+ });
114
+ } catch (error) {
115
+ throw new Error(
116
+ `Could not run ${argv[0]}: ${error instanceof Error ? error.message : String(error)}`,
117
+ );
118
+ }
119
+
120
+ const [stdout, stderr, exitCode] = await Promise.all([
121
+ new Response(process.stdout).text(),
122
+ new Response(process.stderr).text(),
123
+ process.exited,
124
+ ]);
125
+ return { stdout, stderr, exitCode };
126
+ }
127
+
128
+ async function commandJson<T>(
129
+ cwd: string,
130
+ argv: string[],
131
+ description: string,
132
+ env?: Record<string, string>,
133
+ ): Promise<T> {
134
+ const result = await command(cwd, argv, env);
135
+ if (result.exitCode !== 0) {
136
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`;
137
+ throw new Error(`Could not ${description}: ${detail}`);
138
+ }
139
+
140
+ const source = result.stdout.trim();
141
+ if (source.length === 0) return [] as T;
142
+ try {
143
+ return JSON.parse(source) as T;
144
+ } catch {
145
+ throw new Error(`Could not ${description}: unexpected CLI output.`);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Reads the origin remote from the repository and classifies it. The forge is
151
+ * decided by the hostname, and the project path keeps any GitLab subgroup.
152
+ */
153
+ export function classifyRemote(remote: string): Remote | undefined {
154
+ const parsed = parseForgeRemote(remote);
155
+ if (parsed === undefined) return undefined;
156
+ return {
157
+ forge: parsed.forge,
158
+ host: parsed.host,
159
+ project: parsed.project,
160
+ ...(parsed.forge === "gitea"
161
+ ? { baseUrl: giteaBaseUrl(parsed) }
162
+ : {}),
163
+ };
164
+ }
165
+
166
+ function toEpic(entry: IssueEntry): OpenEpic | undefined {
167
+ const number =
168
+ typeof entry.number === "number"
169
+ ? entry.number
170
+ : typeof entry.iid === "number"
171
+ ? entry.iid
172
+ : undefined;
173
+ const title = typeof entry.title === "string" ? entry.title : undefined;
174
+ const url =
175
+ typeof entry.url === "string"
176
+ ? entry.url
177
+ : typeof entry.web_url === "string"
178
+ ? entry.web_url
179
+ : undefined;
180
+ if (number === undefined || title === undefined) return undefined;
181
+ return { number, title, url: url ?? "" };
182
+ }
183
+
184
+ function allEpics(entries: unknown): OpenEpic[] {
185
+ if (!Array.isArray(entries)) return [];
186
+ const epics: OpenEpic[] = [];
187
+ for (const entry of entries) {
188
+ if (typeof entry !== "object" || entry === null) continue;
189
+ const epic = toEpic(entry as IssueEntry);
190
+ if (epic !== undefined) epics.push(epic);
191
+ }
192
+ return epics;
193
+ }
194
+
195
+ async function githubEpics(
196
+ cwd: string,
197
+ project: string,
198
+ env: Record<string, string>,
199
+ ): Promise<OpenEpic[]> {
200
+ const labels = await commandJson<LabelEntry[]>(
201
+ cwd,
202
+ ["gh", "label", "list", "--search", "epic", "--limit", "100", "--json", "name"],
203
+ "list tracker labels",
204
+ env,
205
+ );
206
+ const label = labels.find(
207
+ (entry) => typeof entry.name === "string" && entry.name.toLowerCase() === "epic",
208
+ );
209
+
210
+ if (label !== undefined && typeof label.name === "string") {
211
+ const issues = await commandJson<unknown>(
212
+ cwd,
213
+ [
214
+ "gh",
215
+ "issue",
216
+ "list",
217
+ "--label",
218
+ label.name,
219
+ "--state",
220
+ "open",
221
+ "--json",
222
+ "number,title,url",
223
+ "--limit",
224
+ "100",
225
+ ],
226
+ "list open epics",
227
+ env,
228
+ );
229
+ return allEpics(issues);
230
+ }
231
+
232
+ const issues = await commandJson<unknown>(
233
+ cwd,
234
+ [
235
+ "gh",
236
+ "search",
237
+ "issues",
238
+ "--repo",
239
+ project,
240
+ "--match",
241
+ "body",
242
+ "--state",
243
+ "open",
244
+ "devboard-epic",
245
+ "--json",
246
+ "number,title,url",
247
+ "--limit",
248
+ "100",
249
+ ],
250
+ "search open epics",
251
+ env,
252
+ );
253
+ return allEpics(issues);
254
+ }
255
+
256
+ async function gitlabEpics(cwd: string, env: Record<string, string>): Promise<OpenEpic[]> {
257
+ const labels = await commandJson<LabelEntry[]>(
258
+ cwd,
259
+ ["glab", "label", "list", "--output", "json"],
260
+ "list tracker labels",
261
+ env,
262
+ );
263
+ const label = labels.find(
264
+ (entry) => typeof entry.name === "string" && entry.name.toLowerCase() === "epic",
265
+ );
266
+
267
+ if (label !== undefined && typeof label.name === "string") {
268
+ const issues = await commandJson<unknown>(
269
+ cwd,
270
+ ["glab", "issue", "list", "--label", label.name, "--output", "json"],
271
+ "list open epics",
272
+ env,
273
+ );
274
+ return allEpics(issues);
275
+ }
276
+
277
+ const issues = await commandJson<unknown>(
278
+ cwd,
279
+ ["glab", "issue", "list", "--search", "devboard-epic", "--in", "description", "--output", "json"],
280
+ "search open epics",
281
+ env,
282
+ );
283
+ return allEpics(issues);
284
+ }
285
+
286
+ async function giteaEpics(remote: Remote): Promise<OpenEpic[]> {
287
+ if (remote.baseUrl === undefined || remote.baseUrl.length === 0) {
288
+ throw new Error("The Gitea address is not configured; set DEVBOARD_GITEA_URL.");
289
+ }
290
+ const token = configuredGiteaToken(process.env);
291
+ const issues = await giteaListIssues(
292
+ {
293
+ baseUrl: remote.baseUrl,
294
+ project: remote.project,
295
+ ...(token === undefined ? {} : { token }),
296
+ },
297
+ "open",
298
+ );
299
+ return issues
300
+ .filter(
301
+ (issue) =>
302
+ issue.labels.some((label) => label.toLowerCase() === "epic") ||
303
+ issue.body.includes("devboard-epic"),
304
+ )
305
+ .map((issue) => ({ number: issue.number, title: issue.title, url: issue.url }));
306
+ }
307
+
308
+ const AUTH_CACHE_MS = 60_000;
309
+ const authCache = new Map<string, { auth: ResolvedAuth; expires: number }>();
310
+
311
+ /**
312
+ * The lifecycle re-reads the tracker on every lane transition and settle
313
+ * attempt. Re-probing every account on each read would add seconds and several
314
+ * API calls, so a successful selection is reused for a short window.
315
+ */
316
+ async function resolveTrackerAuth(
317
+ host: string,
318
+ project: string,
319
+ forge: Forge,
320
+ repoDir: string,
321
+ ): Promise<ResolvedAuth> {
322
+ const key = `${forge}:${host}:${project}`;
323
+ const cached = authCache.get(key);
324
+ if (cached !== undefined && cached.expires > Date.now()) return cached.auth;
325
+
326
+ const auth = await resolveForgeAuth(bunExec, {
327
+ forge,
328
+ host,
329
+ project,
330
+ cwd: repoDir,
331
+ required: "read",
332
+ env: process.env,
333
+ });
334
+ if (isAuthFailure(auth)) throw new Error(auth.error);
335
+ authCache.set(key, { auth, expires: Date.now() + AUTH_CACHE_MS });
336
+ return auth;
337
+ }
338
+
339
+ /**
340
+ * Finds every open epic on the origin tracker. An issue is an epic when it is
341
+ * open and either carries the label `epic` ignoring case or contains the
342
+ * `devboard-epic` marker in its body. Only read-only CLI queries run.
343
+ */
344
+ export async function findOpenEpics(repoDir: string): Promise<OpenEpic[]> {
345
+ const remote = await command(repoDir, ["git", "remote", "get-url", "origin"]);
346
+ if (remote.exitCode !== 0) {
347
+ const detail = remote.stderr.trim() || `exit code ${remote.exitCode}`;
348
+ throw new Error(`Could not read the origin remote: ${detail}`);
349
+ }
350
+
351
+ const classified = classifyRemote(remote.stdout);
352
+ if (classified === undefined) {
353
+ throw new Error("The origin remote does not name a supported repository.");
354
+ }
355
+
356
+ if (classified.forge === "gitea") return await giteaEpics(classified);
357
+
358
+ const auth = await resolveTrackerAuth(
359
+ classified.host,
360
+ classified.project,
361
+ classified.forge,
362
+ repoDir,
363
+ );
364
+ const env = authEnvironment(auth);
365
+
366
+ return classified.forge === "github"
367
+ ? await githubEpics(repoDir, classified.project, env)
368
+ : await gitlabEpics(repoDir, env);
369
+ }