@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,235 @@
1
+ # AGENTS.md
2
+
3
+ Two layers. Identify yours before reading or editing.
4
+
5
+ ```
6
+ <repo root>/
7
+ .devboard/ <- harness: interface, prompts, wrappers, install.sh
8
+ adrs/ <- host data
9
+ GOAL.md <- host data
10
+ .omp/ <- installed harness wrappers
11
+ ... <- host project
12
+ ```
13
+
14
+ | Path | Identity |
15
+ | --- | --- |
16
+ | `.devboard/` | Agentic development harness. Project-agnostic. Owns the browser interface and its toolchain. Works *on* the host. Never the host product. |
17
+ | `.devboard/omp/` | Canonical agent wrappers; installed into the host's `.omp/`. |
18
+ | `adrs/`, `GOAL.md` | Host data at the root; never harness artifacts. |
19
+ | everything else | Host project. |
20
+
21
+ ## Containment boundary
22
+
23
+ The whole project — host, harness, toolchain, tests, fixtures, caches, logs, temporary
24
+ files, and worktrees — lives inside `<repo root>`. Nothing may be created, written, or kept
25
+ outside it: not under `/tmp`, not in a home directory, not in a parent, sibling, or other
26
+ checkout. This applies to every agent, script, and service, including throwaway
27
+ verification, "safe" sandbox copies, and manual test data.
28
+
29
+ - Point every path variable (`ADRS_DIR`, `REPO_DIR`, `GOAL_FILE`, `STATIC_DIR`) at a
30
+ location inside `<repo root>`.
31
+ - When a tool defaults outside the root, override it to a directory inside the root; never
32
+ accept the default.
33
+ - Asked to place a file, artifact, cache, log, or worktree outside the root? Refuse and
34
+ propose the in-repo path.
35
+ - Running system binaries (`bun`, `git`, `gh`, `omp`) is not a violation; writing their
36
+ output outside the root is.
37
+
38
+ A temporary directory is still project data. Create it under `<repo root>` (for example
39
+ under `.worktree/`), never outside.
40
+
41
+ ## Scope
42
+
43
+ Default to the host project. "the repo", "the project", "the code", and unnamed files all mean the host project.
44
+
45
+ Enter harness mode only when the request names the harness, "devboard", or a path inside `.devboard/`.
46
+
47
+ | Mode | Trigger | Edit |
48
+ | --- | --- | --- |
49
+ | host (default) | everything else | host project; leave `.devboard/` untouched |
50
+ | harness | explicit harness / `.devboard` reference | `.devboard/` only; host files untouched unless named |
51
+
52
+ A request naming both layers is two scopes; change each side by its own rules.
53
+ Never edit `.devboard/` for a host request. Never edit host files for a harness request. If a task couples them, surface it and state the scope change.
54
+
55
+ ## Harness rules
56
+
57
+ 1. **Containment.** Keep every harness file inside `.devboard/`: code, scripts, prompts, tests, fixtures, docs, configs, CI glue, artifacts. Nothing harness-related is created outside `<repo root>` — no temporary files, caches, logs, or test data. Asked to place one outside? Refuse; propose the `.devboard/` path. Host files *invoking* the harness at runtime are fine.
58
+ 2. **Portability.** Harness code moves between unrelated hosts. Bake in nothing host-specific: no absolute paths, project names, identifiers, remotes, secrets, language/layout/build/test-runner assumptions. Discover host facts at runtime, take them as arguments, or read them from config under `.devboard/`. Emit host paths relative to the repo root.
59
+ 3. **Fixed location, fixed name.** Install at `<repo root>/.devboard`, spelled exactly that. Never rename or relocate per host.
60
+ 4. **Host-neutral deletion.** Deleting `.devboard/` must leave host build, tests, and run paths working. No host hard-dependency on the harness beyond `.devboard/` itself.
61
+ 5. **The one exception.** `AGENTS.md` sits at the repo root by design: agents must find it there. Host-agnostic, copied verbatim between hosts, not a harness artifact. Nothing else gets this treatment.
62
+
63
+ ## Interface
64
+
65
+ `.devboard/` owns the browser interface and everything needed to build, test, and run it: the Bun
66
+ server, the Svelte client, the tests and fixtures, `package.json`, `bun.lock`, the build
67
+ configuration, `index.html`, the `Dockerfile`, and `README.md`. Run its commands from `.devboard`:
68
+ `bun install`, `bun test`, `bun run build`, `bun run start`; build the container from the
69
+ `.devboard` context. The interface is a harness tool, not the host product.
70
+
71
+ The harness may define its own toolchain, but it must not assume host facts: no host language,
72
+ package manager, test runner, build system, or layout. Host data lives at the root — the decision
73
+ records in `adrs/` and the goal document `GOAL.md` — and reaches the interface only through paths
74
+ the host supplies: `ADRS_DIR`, `REPO_DIR`, `GOAL_FILE`, `STATIC_DIR`, `AGENT_COMMAND`. The
75
+ lifecycle prompts under `.devboard/prompts/` likewise treat the repository root as the host and
76
+ exclude `.devboard/` and `.worktree/` from every survey and every write.
77
+
78
+ ## Releases and installation
79
+
80
+ The harness is the public npm package `@dsdenes/devboard`. One tarball carries the launcher, the
81
+ server, the client sources and built bundle, the prompts, the omp wrappers and extensions, the
82
+ templates, and the installer. npm is the authority for install and update versions and artifacts;
83
+ no local checkout, running dashboard, or required release tarball is involved. GitHub is only a
84
+ push mirror of this repository; it carries no release the dashboard reads.
85
+
86
+ A host installs a real npm prefix at `<host>/.devboard` with
87
+ `npx @dsdenes/devboard install`. `<host>/.omp/commands`, `<host>/.omp/extensions`, and
88
+ `<host>/AGENTS.md` are real copies from the installed package; no harness path is a symlink.
89
+ The canonical `.devboard/` in this checkout is the source tree and is never an npm install.
90
+
91
+ Every running dashboard checks the newest npm version at startup and every 60 seconds, installs a
92
+ newer artifact, and keeps serving the version it loaded. It reports `versionState` (`running`,
93
+ `installed`, `latest`, `stale`) and shows the reload control while `stale` is true. Only clicking
94
+ that control restarts the server from the installed package and reloads the page. When npm cannot
95
+ resolve or serve the artifact and `DEVBOARD_GITEA_URL` is configured, install and update fall back
96
+ to the Gitea release tarball and its `.sha256` for the same version. A failed download, checksum,
97
+ authentication, or install leaves the installed package and the running server intact; the
98
+ journal records the failure and doctor reports it.
99
+
100
+ Harness-owned issue reporting uses only the configured Gitea API. Without `DEVBOARD_GITEA_URL`
101
+ the report controls are not rendered and `POST /api/feedback` answers HTTP 503 naming that
102
+ variable; no GitHub request is made. Host-forge behavior for GitHub, GitLab, and Gitea origins is
103
+ unchanged.
104
+
105
+ ## Publish after every change
106
+
107
+ Publishing is a release, not a restart. Merging into `main` and pushing triggers the
108
+ `.gitea/workflows/devboard-release.yml` workflow, which builds, tests, packs the complete public
109
+ package, publishes `@dsdenes/devboard@<version>` to npmjs, and creates the Gitea release with
110
+ notes plus the same tarball and its `.sha256` as a fallback. Nothing is published under
111
+ `@dsdenes/devboard-harness`.
112
+
113
+ A change is not finished until the workflow for the pushed commit succeeds, the npm package and
114
+ Gitea release assets exist, and the installed hosts have picked the release up. After the push, run
115
+ from the integration checkout — never from a worktree:
116
+
117
+ ```bash
118
+ cd .devboard
119
+ bun run publish
120
+ ```
121
+
122
+ `bun run publish` is the verification path. It:
123
+
124
+ 1. refuses a worktree, a branch other than `main`, or a checkout ahead of or diverged from its
125
+ canonical remote;
126
+ 2. fetches the remote and fast-forwards `main` to it; uncommitted work is never discarded — an
127
+ overlapping local change stops the run and names the blocking paths;
128
+ 3. runs `bun install --frozen-lockfile` and `bun run build`, then proves `dist/index.html` is
129
+ newer than every client source and that every referenced asset exists;
130
+ 4. waits for the `devboard-release.yml` run of `HEAD` and requires a successful conclusion;
131
+ 5. verifies the npm package and the Gitea release's fallback tarball and `.sha256`;
132
+ 6. pushes the `github` mirror remote and proves its `main` names the published commit;
133
+ 7. reports every running dashboard's running and installed versions without restarting it.
134
+
135
+ Options: `--wait-timeout N` bounds the workflow and propagation wait (default 1800 seconds).
136
+ It never restarts a dashboard, never writes into a host, and exits nonzero when the npm package
137
+ or the release assets are missing. Installed dashboards update themselves; an operator clicks the
138
+ reload control when they want the new code.
139
+
140
+ The integration checkout is advanced and rebuilt after a push; it is never a place for feature
141
+ work. If its uncommitted changes block the fast-forward, preserve them (copy or patch them
142
+ aside, apply the published revision, reapply the changes) and rerun `publish`. Never discard
143
+ them and never bypass the fast-forward.
144
+
145
+ Guardrails:
146
+
147
+ - Never rebuild only a worktree or only the client and call the change done.
148
+ - Never kill, spawn, or relaunch a dashboard server by hand; install, update, and the reload
149
+ control are the only paths.
150
+ - Never copy the harness into a host or symlink a host path to this checkout; install owns the
151
+ host prefix.
152
+ - Stale output is the first suspect, not the last: when the UI behaves like an older revision,
153
+ compare `versionState` and the installed package version before debugging client code.
154
+ - A UI check against a bundle or server older than the changed source is not a passing
155
+ verification.
156
+
157
+ ## Wrappers
158
+
159
+ `.devboard/omp/` is canonical: agent commands live in `omp/commands/` and extensions in
160
+ `omp/extensions/`, never in the root `.omp/`. `.devboard/install.sh` copies every file under
161
+ `.devboard/omp/` to the same relative path under `<host>/.omp/`. The host path is an argument or
162
+ is discovered with `git rev-parse --show-toplevel`; the script refuses a host without a
163
+ `.devboard/` directory, refuses to overwrite a differing file without `--force`, never touches
164
+ unrelated files, and is safe to rerun. Edit `.devboard/omp/`, rerun `install.sh`, and never
165
+ hand-edit the installed `.omp/`.
166
+
167
+ ## Cross-boundary changes
168
+
169
+ Split, never mix: host-specific detail in host files; generic mechanism in `.devboard/` taking that detail as input. A host name, path, or stack inside `.devboard/` is a bug. A harness dependency outside it is a bug.
170
+
171
+ ## Worktree
172
+
173
+ Do all work — host and harness — in an isolated git worktree: `<repo root>/.worktree/<name>`.
174
+ Never outside the repo root. Never edit the opened checkout. One worktree per task; edit, build,
175
+ and test inside it.
176
+
177
+ Done means merged into main **and pushed to the upstream that tracks it**. A local commit, a local
178
+ merge, or a deleted worktree branch is not done. Before deleting the worktree, verify the push:
179
+ `git ls-remote <remote> refs/heads/main` must name the integration commit, and
180
+ `git status --short --branch` must report no commits ahead of the upstream. A local merge that is
181
+ never pushed is a failed task, even when every test passes. If a push is rejected, fetch the new
182
+ tip, integrate it, resolve every conflict yourself, rerun the checks it invalidates, and push
183
+ again. Never finish, hand off, or delete the worktree with local-only changes.
184
+
185
+ After the push, advance the integration checkout to the published revision without discarding
186
+ its uncommitted work, then run `cd .devboard && bun run publish` so every dashboard that serves
187
+ the checkout runs the new bundle and server. Deleting the worktree before the integration
188
+ checkout is published and verified is a failed task.
189
+
190
+ ## Line length
191
+
192
+ Never create a line longer than 768 bytes (UTF-8) in a repository text file. Hard-wrap
193
+ prose, prompts, and Markdown list items; do not rely on a later reader re-opening a
194
+ truncated line. Aim for 100 columns or less. Generated files such as lockfiles are exempt;
195
+ never hand-edit them.
196
+
197
+ ## Working rules
198
+
199
+ - State mode and edit root before the first edit.
200
+ - Decide which side a new file belongs on first. Two common bugs: a harness artifact in host code, a host fact baked into `.devboard/`.
201
+ - Run harness work from the repo root with the host path passed in or discovered at runtime.
202
+ - In harness mode, never "temporarily" write outside `.devboard/`; fix the layering.
203
+ - Host mode leaves `.devboard/` byte-identical. Harness mode leaves host behavior unchanged except where asked.
204
+ - Pushing is part of the task, not a follow-up. Never stop with a commit or merge that exists only
205
+ locally, even when every test passes and the worktree is clean.
206
+
207
+ ## Done
208
+
209
+ - Both modes: done in `.worktree/<name>`, merged to main, pushed to the canonical upstream, and
210
+ verified that the upstream tip is the integrated commit. Only then are the worktree and branch
211
+ removed. Local-only main is never done.
212
+ - Host mode: host build/tests run; `.devboard/` unchanged.
213
+ - Harness mode: the pushed commit's release workflow succeeded and `bun run publish` verified
214
+ the npm package and release assets; installed dashboards report the release through
215
+ `versionState` and restart only from the reload control; the changed entry point runs from the
216
+ repo root against a supplied host path; no absolute paths or host specifics under
217
+ `.devboard/`; nothing created outside `.devboard/`.
218
+
219
+ ## Map
220
+
221
+ - `.devboard/` — harness root: interface, toolchain, prompts, extensions, goals, tests,
222
+ `omp/`, `install.sh`, `scripts/publish.ts`.
223
+ - `.devboard/scripts/publish.ts` — verifies `main`, waits for the release workflow, proves the
224
+ npm package and release assets, and reports dashboards without restarting them.
225
+ - `.devboard/scripts/release.ts` — publishes the public package and the Gitea fallback release.
226
+ - `.devboard/templates/host-AGENTS.md` — host contract copied at release time; byte-identical to
227
+ this file.
228
+ - `.gitea/workflows/devboard-release.yml` — CI on the canonical Gitea instance: install, build,
229
+ test, typecheck, and release. GitHub receives a push mirror only.
230
+ - `.devboard/omp/` — canonical agent commands and extensions.
231
+ - `.omp/` — installed copy of `.devboard/omp/`; never edited by hand.
232
+ - `adrs/`, `GOAL.md` — host data at the root; not harness artifacts.
233
+ - `.devboard/AGENTS.md` — harness-internal instructions, when present.
234
+ - `.worktree/` — per-task worktrees; ephemeral.
235
+ - `AGENTS.md` — harness ⇄ host contract; at repo root by design.
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "skipLibCheck": true,
9
+ "types": ["bun"],
10
+ "allowImportingTsExtensions": true,
11
+ "noEmit": true
12
+ },
13
+ "include": [
14
+ "client/**/*.ts",
15
+ "client/**/*.d.ts",
16
+ "client/**/*.svelte",
17
+ "server/**/*.ts",
18
+ "issues/**/*.ts",
19
+ "vite.config.ts"
20
+ ]
21
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from "vite";
2
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+
5
+ export default defineConfig({
6
+ plugins: [tailwindcss(), svelte()],
7
+ build: {
8
+ outDir: "dist",
9
+ emptyOutDir: true,
10
+ chunkSizeWarningLimit: 12000,
11
+ rollupOptions: {
12
+ output: {
13
+ manualChunks(id: string) {
14
+ if (id.includes("monaco-editor")) return "monaco";
15
+ if (id.includes("@xterm")) return "xterm";
16
+ return undefined;
17
+ },
18
+ },
19
+ },
20
+ },
21
+ });
@@ -0,0 +1,98 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { runCleanup } from "./cleanup";
4
+ import { discoverInstances } from "./discovery";
5
+
6
+ /**
7
+ * `bun run cleanup -- --dry-run` prints one week's retention plan for every
8
+ * covered state directory; `--once` applies it. The watchdog cycle performs
9
+ * the same sweep; this entry point exists for operators and verification.
10
+ */
11
+
12
+ const usage = `Usage: bun run cleanup [options]
13
+
14
+ Removes entries older than seven days from every devboard state directory the
15
+ watchdog covers: the canonical checkout, its direct siblings, and live
16
+ dashboards. Journal retention stays with the journal.
17
+
18
+ --once Delete every eligible entry. The default is dry-run.
19
+ --dry-run Print the plan and change nothing.
20
+ --root <path> Canonical repository root. Defaults to this checkout.
21
+ -h, --help Show this text.
22
+ `;
23
+
24
+ const args = process.argv.slice(2);
25
+ let once = false;
26
+ let dryRun = false;
27
+ let rootOption: string | undefined;
28
+
29
+ for (let index = 0; index < args.length; index += 1) {
30
+ const argument = args[index];
31
+ if (argument === "--once") once = true;
32
+ else if (argument === "--dry-run") dryRun = true;
33
+ else if (argument === "--root") {
34
+ rootOption = args[index + 1];
35
+ index += 1;
36
+ if (rootOption === undefined) {
37
+ console.error("cleanup: --root needs a path");
38
+ process.exit(2);
39
+ }
40
+ } else if (argument === "-h" || argument === "--help") {
41
+ console.log(usage);
42
+ process.exit(0);
43
+ } else {
44
+ console.error(`cleanup: unknown option ${argument}`);
45
+ console.error(usage);
46
+ process.exit(2);
47
+ }
48
+ }
49
+
50
+ if (once && dryRun) {
51
+ console.error("cleanup: --once and --dry-run are exclusive");
52
+ process.exit(2);
53
+ }
54
+
55
+ function gitTopLevel(): string {
56
+ const result = Bun.spawnSync({
57
+ cmd: ["git", "-C", dirname(import.meta.dir), "rev-parse", "--show-toplevel"],
58
+ stdin: "ignore",
59
+ stdout: "pipe",
60
+ stderr: "pipe",
61
+ });
62
+ const top = result.stdout.toString().trim();
63
+ if (result.exitCode !== 0 || top.length === 0) {
64
+ console.error("cleanup: the harness is not inside a git checkout");
65
+ process.exit(2);
66
+ }
67
+ return top;
68
+ }
69
+
70
+ const root = resolve(rootOption ?? gitTopLevel());
71
+ if (!existsSync(join(root, ".devboard"))) {
72
+ console.error(`cleanup: missing prerequisite: ${join(root, ".devboard")} does not exist`);
73
+ process.exit(2);
74
+ }
75
+
76
+ const instances = discoverInstances({ selfPids: new Set([process.pid]) });
77
+ const report = runCleanup({ root, instances, dryRun: dryRun || !once });
78
+ const effectiveDryRun = dryRun || !once;
79
+ const verb = effectiveDryRun ? "would remove" : "removed";
80
+ let errors = 0;
81
+
82
+ for (const entry of report.roots) {
83
+ console.log(
84
+ `cleanup: ${entry.stateDir}: ${verb} ${entry.files} file(s), ` +
85
+ `${entry.directories} directory/directories, ${entry.bytes} byte(s)`,
86
+ );
87
+ if (effectiveDryRun) {
88
+ for (const candidate of entry.candidates) {
89
+ console.log(` ${candidate.kind} ${candidate.path} ${candidate.bytes}`);
90
+ }
91
+ }
92
+ for (const error of entry.errors) {
93
+ errors += 1;
94
+ console.error(`cleanup: ${entry.stateDir}: ${error}`);
95
+ }
96
+ }
97
+
98
+ process.exit(errors === 0 ? 0 : 1);
@@ -0,0 +1,301 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readdirSync,
5
+ realpathSync,
6
+ rmdirSync,
7
+ unlinkSync,
8
+ } from "node:fs";
9
+ import { dirname, join, relative, resolve, sep } from "node:path";
10
+ import { ISSUES_DIRECTORY } from "../issues/paths";
11
+ import { JOURNAL_RETENTION_MS } from "../server/journal";
12
+
13
+ /**
14
+ * Age-based retention for everything a devboard state directory accumulates.
15
+ * The journal keeps its own rule in `server/journal.ts`; this module sweeps the
16
+ * rest of `.tmp`: run directories, loop logs, legacy dashboard files, and
17
+ * verification scratch. Nothing here decides what a dashboard should keep.
18
+ */
19
+
20
+ /** The one-week threshold; the journal uses the same number. */
21
+ export const CLEANUP_RETENTION_MS = JOURNAL_RETENTION_MS;
22
+
23
+ export type CleanupSource = "canonical" | "sibling" | "instance";
24
+
25
+ export interface CoveredStateDir {
26
+ stateDir: string;
27
+ source: CleanupSource;
28
+ }
29
+
30
+ export interface CleanupCandidate {
31
+ path: string;
32
+ bytes: number;
33
+ kind: "file" | "symlink" | "directory";
34
+ }
35
+
36
+ export interface CleanupRootReport {
37
+ stateDir: string;
38
+ source: CleanupSource;
39
+ /** Everything eligible under this directory, whether or not it was removed. */
40
+ candidates: CleanupCandidate[];
41
+ /** What the run removed; empty in a dry run. */
42
+ removed: CleanupCandidate[];
43
+ /** Bytes removed, or bytes a dry run would remove. */
44
+ bytes: number;
45
+ files: number;
46
+ directories: number;
47
+ errors: string[];
48
+ }
49
+
50
+ export interface CleanupReport {
51
+ now: string;
52
+ cutoff: string;
53
+ dryRun: boolean;
54
+ roots: CleanupRootReport[];
55
+ }
56
+
57
+ export interface CleanupInstance {
58
+ stateDir?: string;
59
+ journal: string;
60
+ }
61
+
62
+ export interface CleanupRunOptions {
63
+ root: string;
64
+ now?: Date;
65
+ dryRun?: boolean;
66
+ instances?: CleanupInstance[];
67
+ /** Test seam: replace discovery outright. */
68
+ covered?: CoveredStateDir[];
69
+ /** The watchdog run directory of the cycle performing the sweep. */
70
+ watchdogRunDir?: string;
71
+ }
72
+
73
+ function stateDirectory(instance: CleanupInstance): string {
74
+ return instance.stateDir ?? dirname(dirname(instance.journal));
75
+ }
76
+
77
+ function realDirectory(path: string): string | undefined {
78
+ try {
79
+ if (!lstatSync(path).isDirectory()) return undefined;
80
+ return realpathSync(path);
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The state directories one sweep covers: the canonical harness, every direct
88
+ * sibling `<host>/.devboard/.tmp`, and every live dashboard's real state
89
+ * directory (derived from its journal, never from the serving package).
90
+ * De-duplicated by real path; a symlinked `.tmp` is never followed.
91
+ */
92
+ export function coveredStateDirs(
93
+ root: string,
94
+ instances: CleanupInstance[] = [],
95
+ ): CoveredStateDir[] {
96
+ const covered: CoveredStateDir[] = [];
97
+ const seen = new Set<string>();
98
+ const add = (path: string, source: CleanupSource): void => {
99
+ const real = realDirectory(path);
100
+ if (real === undefined || seen.has(real)) return;
101
+ seen.add(real);
102
+ covered.push({ stateDir: real, source });
103
+ };
104
+
105
+ add(join(root, ".devboard", ".tmp"), "canonical");
106
+
107
+ let names: string[] = [];
108
+ try {
109
+ names = readdirSync(dirname(root)).sort();
110
+ } catch {
111
+ names = [];
112
+ }
113
+ for (const name of names) {
114
+ add(join(dirname(root), name, ".devboard", ".tmp"), "sibling");
115
+ }
116
+ for (const instance of instances) add(stateDirectory(instance), "instance");
117
+
118
+ return covered;
119
+ }
120
+
121
+ /** True when a lock file's name records a pid that is still alive. */
122
+ function liveLegacyLock(path: string): boolean {
123
+ const match = /^devboard-(\d+)\.lock$/.exec(path.slice(path.lastIndexOf(sep) + 1));
124
+ if (match === null) return false;
125
+ return existsSync(`/proc/${match[1]}`);
126
+ }
127
+
128
+ function inside(directory: string, path: string): boolean {
129
+ const rest = relative(directory, path);
130
+ return rest.length > 0 && rest !== ".." && !rest.startsWith(`..${sep}`) && !rest.startsWith(sep);
131
+ }
132
+
133
+ interface SweepOptions {
134
+ now: Date;
135
+ dryRun: boolean;
136
+ watchdogRunDir?: string;
137
+ }
138
+
139
+ /**
140
+ * One state directory. Every file or symlink older than the cutoff is a
141
+ * candidate; a directory is one when it would be empty and its own mtime, read
142
+ * before any deletion, is older. Protected paths are never even descended into.
143
+ */
144
+ export function sweepStateDir(
145
+ stateDir: string,
146
+ options: SweepOptions,
147
+ ): CleanupRootReport {
148
+ const cutoff = options.now.getTime() - CLEANUP_RETENTION_MS;
149
+ const errors: string[] = [];
150
+ const candidates: CleanupCandidate[] = [];
151
+ const report: CleanupRootReport = {
152
+ stateDir,
153
+ source: "canonical",
154
+ candidates,
155
+ removed: [],
156
+ bytes: 0,
157
+ files: 0,
158
+ directories: 0,
159
+ errors,
160
+ };
161
+
162
+ const info = realDirectory(stateDir);
163
+ if (info === undefined) {
164
+ errors.push("not a directory; nothing swept");
165
+ return report;
166
+ }
167
+ const directory = info;
168
+ const protectedPrefixes = [
169
+ join(directory, "state"),
170
+ join(directory, "journal"),
171
+ // Every repo-scoped issues record: state, ledger, runs, activity, control,
172
+ // and the repo-anchored lock a worker holds. A running worker's run
173
+ // directory is therefore never swept.
174
+ join(directory, ISSUES_DIRECTORY),
175
+ // Flow session records: machine state, reviewable artifacts, and the
176
+ // worktree/PR bookkeeping Flow restore and History read. Nothing
177
+ // recreates a swept session, so an untouched one must survive.
178
+ join(directory, "flow"),
179
+ ...(options.watchdogRunDir === undefined ? [] : [resolve(options.watchdogRunDir)]),
180
+ ];
181
+ const protectedExact = new Set([
182
+ join(directory, "watchdog", "state.json"),
183
+ join(directory, "watchdog", "latest.json"),
184
+ join(directory, "watchdog", "lock"),
185
+ join(directory, "orchestrator", "state.json"),
186
+ join(directory, "orchestrator", "ledger.json"),
187
+ join(directory, "orchestrator", "lock"),
188
+ ]);
189
+
190
+ const isProtected = (path: string): boolean => {
191
+ if (protectedExact.has(path)) return true;
192
+ if (liveLegacyLock(path)) return true;
193
+ return protectedPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}${sep}`));
194
+ };
195
+
196
+ const visit = (path: string): { removable: boolean; bytes: number } => {
197
+ if (isProtected(path)) return { removable: false, bytes: 0 };
198
+ let entry;
199
+ try {
200
+ entry = lstatSync(path);
201
+ } catch (error) {
202
+ errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
203
+ return { removable: false, bytes: 0 };
204
+ }
205
+ if (entry.isSymbolicLink()) {
206
+ if (entry.mtimeMs < cutoff) {
207
+ candidates.push({ path, bytes: 0, kind: "symlink" });
208
+ return { removable: true, bytes: 0 };
209
+ }
210
+ return { removable: false, bytes: 0 };
211
+ }
212
+ if (entry.isFile()) {
213
+ if (entry.mtimeMs < cutoff) {
214
+ candidates.push({ path, bytes: entry.size, kind: "file" });
215
+ return { removable: true, bytes: entry.size };
216
+ }
217
+ return { removable: false, bytes: 0 };
218
+ }
219
+ if (!entry.isDirectory()) return { removable: false, bytes: 0 };
220
+
221
+ let names: string[] = [];
222
+ try {
223
+ names = readdirSync(path).sort();
224
+ } catch (error) {
225
+ errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
226
+ return { removable: false, bytes: 0 };
227
+ }
228
+ let survivors = 0;
229
+ let bytes = 0;
230
+ for (const name of names) {
231
+ const child = visit(join(path, name));
232
+ bytes += child.bytes;
233
+ if (!child.removable) survivors += 1;
234
+ }
235
+ if (survivors === 0 && entry.mtimeMs < cutoff) {
236
+ candidates.push({ path, bytes: 0, kind: "directory" });
237
+ return { removable: true, bytes };
238
+ }
239
+ return { removable: false, bytes };
240
+ };
241
+
242
+ let top: string[] = [];
243
+ try {
244
+ top = readdirSync(directory).sort();
245
+ } catch (error) {
246
+ errors.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`);
247
+ return report;
248
+ }
249
+ for (const name of top) visit(join(directory, name));
250
+
251
+ let bytes = 0;
252
+ for (const candidate of candidates) bytes += candidate.bytes;
253
+ if (options.dryRun) {
254
+ report.bytes = bytes;
255
+ report.files = candidates.filter((candidate) => candidate.kind !== "directory").length;
256
+ report.directories = candidates.filter((candidate) => candidate.kind === "directory").length;
257
+ return report;
258
+ }
259
+
260
+ for (const candidate of candidates) {
261
+ if (!inside(directory, candidate.path)) {
262
+ errors.push(`${candidate.path}: refuses to delete outside ${directory}`);
263
+ continue;
264
+ }
265
+ try {
266
+ if (candidate.kind === "directory") rmdirSync(candidate.path);
267
+ else unlinkSync(candidate.path);
268
+ report.removed.push(candidate);
269
+ report.bytes += candidate.bytes;
270
+ if (candidate.kind === "directory") report.directories += 1;
271
+ else report.files += 1;
272
+ } catch (error) {
273
+ errors.push(
274
+ `${candidate.path}: ${error instanceof Error ? error.message : String(error)}`,
275
+ );
276
+ }
277
+ }
278
+ return report;
279
+ }
280
+
281
+ /** Finds every covered state directory and sweeps each one. */
282
+ export function runCleanup(options: CleanupRunOptions): CleanupReport {
283
+ const now = options.now ?? new Date();
284
+ const dryRun = options.dryRun === true;
285
+ const covered = options.covered ?? coveredStateDirs(options.root, options.instances ?? []);
286
+ const roots = covered.map((entry) => {
287
+ const report = sweepStateDir(entry.stateDir, {
288
+ now,
289
+ dryRun,
290
+ ...(options.watchdogRunDir === undefined ? {} : { watchdogRunDir: options.watchdogRunDir }),
291
+ });
292
+ report.source = entry.source;
293
+ return report;
294
+ });
295
+ return {
296
+ now: now.toISOString(),
297
+ cutoff: new Date(now.getTime() - CLEANUP_RETENTION_MS).toISOString(),
298
+ dryRun,
299
+ roots,
300
+ };
301
+ }