@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
package/README.md CHANGED
@@ -1,34 +1,2505 @@
1
- # @dsdenes/devboard
1
+ # Devboard
2
2
 
3
- Public launcher for the private `@dsdenes/devboard-harness` package. The
4
- launcher holds bootstrap code only; the harness tarball carries the dashboard
5
- server, client bundle, prompts, and omp wrappers and is distributed from GitHub
6
- Releases, never from a registry.
3
+ Local browser interface for a repository's goal, Flow implementation, and Discover artifacts.
4
+ It renders one full-viewport, dark-first administration workspace with an activity bar, a
5
+ contextual sidebar, one workspace page per mode, resizable split panes, Monaco editors, xterm
6
+ terminals, a command palette, and a status bar. Every mode is a client-side view over the existing
7
+ WebSocket contract; the server still owns all work and durable state.
8
+
9
+ ## Interface
10
+
11
+ The interface is a desktop tool. It requires a viewport of at least 1024×720 CSS pixels. Below
12
+ either threshold the shell is not rendered: a notice carrying
13
+ `data-testid="unsupported-viewport"` states the minimum instead, so the application never
14
+ degrades into a clipped mobile layout.
15
+
16
+ ### Application shell
17
+
18
+ `data-testid="app-shell"` fills the viewport exactly. It contains, in order:
19
+
20
+ |Region|Test hook|Role|
21
+ |---|---|---|
22
+ |Mode rail|`activity-bar`|Three labeled mode tabs and their Lucide icons|
23
+ |Sidebar|`sidebar`|Contextual navigation for the active mode|
24
+ |Workspace|`workspace`|The open surface; absorbs all remaining space|
25
+ |Status bar|`status-bar`|Connection, project name, lane count, issues state, watchdog, versions, reload|
26
+
27
+ The status bar stays visible while panes resize, and the document itself never scrolls
28
+ vertically or horizontally. Each mode's panel keeps its scroll position, focus, open page, and
29
+ unsaved editor text while another mode is shown.
30
+
31
+ ### Mode rail
32
+
33
+ The 72px rail presents exactly four modes in this order as `role="tab"` controls:
34
+ `data-testid="mode-goal"`, `data-testid="mode-issues"`, `data-testid="mode-flow"`, and
35
+ `data-testid="mode-discover"`. Each is a 64×52px control with a Lucide icon above a visible 10px
36
+ uppercase label (`Goal`, `Issues`, `Flow`, `Discover`), an accessible name that also names the
37
+ shortcut, `aria-selected`, and `aria-controls` naming its workspace panel. The labels never
38
+ collapse to icon-only and never overflow the tab at the 1024×720 boundary. A fresh profile starts
39
+ on Goal. Clicking a tab or pressing Alt+1, Alt+2, Alt+3, or Alt+4 changes only which mounted view
40
+ is shown; it never sends a WebSocket frame.
41
+
42
+ The sidebar has no header: it starts with the active mode's navigation, because the rail already
43
+ names the mode and the status bar names the project.
44
+
45
+ ### Sidebar
46
+
47
+ The sidebar is always visible and has no hide control or collapse shortcut. It is initially 300
48
+ CSS px wide, constrained to 200–600 CSS px, and resized by dragging its separator or pressing the
49
+ arrow keys with that separator focused. Double-clicking the separator restores the default width.
50
+ The width survives a reload.
51
+
52
+ - **Goal** lists the goal workspace surfaces, a **Skills** entry that discloses every prepared
53
+ skill, the pending ADR records (title, status, next marker, file), and the lifecycle lanes. The
54
+ ADR records group is absent while no record is pending.
55
+ - **Flow** lists the workspace surfaces — overview, decision board, steering, and history — the
56
+ session and PR state, widgets, changed files, checkpoints, and blockers. The overview entry
57
+ carries the goal's `unsaved` marker. Blockers open the overview at the blocker they name.
58
+ - **Discover** lists the overview and glossary sections, the five skills under their own group,
59
+ and warnings only while at least one exists. The glossary entry carries the published term
60
+ count. A skill carries a `generated` state silently and shows `edited` or `missing` as trailing
61
+ meta, so an exception is visible before the page opens. The Glossary entry is a plain section
62
+ link: it has no disclosure control and no nested per-term menu.
63
+
64
+ Selecting an entry opens or focuses its workspace surface. Informational entries open the
65
+ surface that holds the same facts. The Flow overview opens with the flow-goal editor, which takes
66
+ the full page height; while a goal interview is running the editor collapses to its header and the
67
+ interview takes the next block. The overview ends with the implementation activity console, which
68
+ also takes a full page height so agent output never shrinks to a thumbnail.
69
+
70
+ ### Workspace page
71
+
72
+ Each mode shows exactly one page at a time and renders no tab strip. Opening an ADR, a Flow
73
+ widget, changed file, or checkpoint, or a Discover skill replaces the visible page; the previous
74
+ selection is reachable again from the sidebar or the palette. Close controls inside a page return
75
+ to an empty page, and a dirty page closes only after the confirmation dialog restores or discards
76
+ the draft.
77
+
78
+ A reload restores the active mode, each mode's open page, sidebar and split sizes, and theme. All
79
+ of that state lives in `localStorage`; none of it is sent to the server.
80
+
81
+ ### Split panes
82
+
83
+ PaneForge groups provide the sidebar/workspace split and the workspace/detail split used by Flow
84
+ widgets and diffs and by the Discover skill detail. Separators are focusable, carry
85
+ `role="separator"` with `aria-orientation`, `aria-valuemin`, `aria-valuemax`, and `aria-valuenow`,
86
+ resize by drag and arrow keys, reset to their defaults on double-click, keep both panes at least
87
+ 160 CSS px wide, persist their sizes, and never cover the status bar.
88
+
89
+ ### Command palette
90
+
91
+ Ctrl/Cmd+K opens `data-testid="command-palette"`. It has a search input
92
+ (`command-palette-input`) and command items (`command-item`), filters as you type, moves with
93
+ ArrowUp/ArrowDown/Home/End, runs with Enter, closes with Escape, and restores focus to the
94
+ previously focused element. Every actionable control carries a stable `data-command`, and the
95
+ palette exposes the same command ids; disabled commands show the same reason as their control.
96
+ Commands that need text focus the relevant input rather than inventing a value.
97
+
98
+ The palette covers navigation, opening every record, lane, widget, file, checkpoint, term, and
99
+ skill, lifecycle start/pause/resume/stop, Goal commands, Flow goal and decision editing, Flow
100
+ start/stop and PR actions, Discover start/stop and skill editing, the theme toggle, reset layout,
101
+ reload when stale, and the `Report UI issue`, `Remove element`, and `Redesign element` actions
102
+ (`app.report`, `app.remove`, `app.redesign`, and `app.report.cancel`).
103
+
104
+ ### Keyboard shortcuts
105
+
106
+ |Shortcut|Action|
107
+ |---|---|
108
+ |Ctrl/Cmd+K|Open the command palette|
109
+ |Alt+1 / Alt+2 / Alt+3 / Alt+4|Switch to Goal / Issues / Flow / Discover|
110
+ |Ctrl/Cmd+S|Save the focused editor when it has unsaved changes|
111
+ |Escape|Close the palette, dialogs, and overlays|
112
+
113
+ Shortcuts are inert while text is being typed, except palette, save, and Escape. Every shortcut
114
+ is listed in the palette or in the control's tooltip. Focus rings are always visible; dialogs
115
+ trap focus and restore it on close.
116
+
117
+ ### Theme
118
+
119
+ A fresh profile starts dark with `data-theme="dark"` on the root element, regardless of the OS
120
+ preference. The status-bar control and the palette toggle between dark and light; the choice
121
+ persists and an inline head script applies it before the first paint, so there is no wrong-theme
122
+ flash. Both themes define every token used by the shell, tables, editors, terminals, diffs,
123
+ status indicators, and overlays. Body text and muted text meet 4.5:1 contrast; interactive
124
+ borders and focus rings meet 3:1; no state is conveyed by color alone. `prefers-reduced-motion:
125
+ reduce` disables nonessential transitions.
126
+
127
+ ### Dense data views
128
+
129
+ TanStack Table renders the ADR record list and the Discover command, testing, architecture,
130
+ dependency, unknown-field, and glossary tables. The ADR table keeps title, status,
131
+ pending or resolved state, next marker, file, and decision summary; selecting a row opens its
132
+ detail page without navigating away. Body and table text is 12–14 CSS px, paths, code, diffs,
133
+ terminal output, and IDs are monospace, one-line data rows are at most 32 CSS px tall, and wrapped
134
+ data rows stay within 120 CSS px. The shell uses
135
+ the full viewport with no application max-width.
136
+
137
+ ### Editors and terminals
138
+
139
+ Monaco Editor provides the Goal document (`goal-text`), Flow goal (`flow-goal-text`), Flow
140
+ decision board (`flow-decisions`), and Discover skill editor (`discover-skill-text`). Each
141
+ supports multiline Markdown or plain-text editing, its Save control, Ctrl/Cmd+S, a dirty marker,
142
+ and an accessible label; a save sends the editor's exact text. The Goal document page fills the
143
+ workspace: the editor takes every remaining pixel and the Revision guidance field is a tall
144
+ multiline text area pinned to the page bottom. Flow diffs remain rendered by
145
+ `diff2html`; the client never parses unified diff hunks.
146
+
147
+ xterm.js renders read-only agent output for Goal current activity and history, lane current
148
+ activity and history, Flow goal creation, implementation activity, conversation activity, and
149
+ Discover activity. Terminals preserve ANSI output, are selectable and copyable, auto-scroll
150
+ unless the developer scrolls away, keep a bounded scrollback, and expose their full buffer to
151
+ assistive technology.
152
+
153
+ ### Notifications and status bar
154
+
155
+ Server errors and notices appear in the notification region (`data-testid="notifications"`) with
156
+ severity, text, and dismissal, and remain in a history opened from the status bar. Every error and
157
+ warning card also carries `File issue`: it files that notification's text and card context as a UI
158
+ issue through `/api/feedback` at once, then replaces itself with the issue URL. Notifications
159
+ never replace mode content or steal focus, and several arrivals are all retained. Flow blockers
160
+ and verification results also remain in their mode context.
161
+
162
+ The 26px status bar is two groups. The left group states the WebSocket connection and the
163
+ project's own name (the last path segment, with a folder mark and the full path in its tooltip),
164
+ separated by a hairline rule. The right group states the running lane count, the issues worker
165
+ state with a state dot, the watchdog's last action and age, and the running and installed
166
+ versions; each metric carries a 10px uppercase label so its meaning never rests on color. When
167
+ `versionState.stale` is true it shows the existing reload control (`reload-devboard`), whose
168
+ accessible name and title name both versions; reload behavior is unchanged. A mode states its
169
+ status in its sidebar and workspace, never a second time in the status bar, except the issues
170
+ worker, which is detached from every dashboard and reports through `issues-indicator`. The theme
171
+ toggle and the notification indicator live in the activity bar's action group, not the status
172
+ bar.
173
+
174
+ ### Verifying the interface
175
+
176
+ ```bash
177
+ cd .devboard
178
+ bun run verify:interface
179
+ ```
180
+
181
+ The script builds the client, seeds a fixture host under `.devboard/.tmp` with eight ADRs and
182
+ real Discover artifacts produced by the stub ACP peer, starts a source dashboard, launches
183
+ headless Chrome with a profile under the same tree, and asserts the shell, modes, sidebar,
184
+ pages, splits, palette, shortcuts, theme and first paint, layout persistence and reset, the
185
+ 1024×720 boundary, dense tables, Monaco editors, xterm terminals, contrast, reduced motion,
186
+ notifications, status bar, preserved test hooks, and the absence of exceptions, console errors,
187
+ failed requests, and overflow. It writes screenshots under `.devboard/.tmp/interface/`.
188
+
189
+ ## Stack
190
+
191
+ - Backend: TypeScript on Bun, serving the built client and one WebSocket at `/ws`. Each run
192
+ spawns one `omp acp` subprocess over stdio.
193
+ - Client: Svelte 5 built with Vite, with shadcn-svelte components and its Tailwind CSS 4 setup,
194
+ Bits UI dialogs, tooltips, and popovers, PaneForge split panes, TanStack Table data grids,
195
+ Monaco Editor, xterm.js, and Lucide icons. All workers and assets are bundled locally; the
196
+ page makes no network request beyond its WebSocket.
197
+ - Density: 13 px body text with 12 px tables and monospace paths, code, diffs, terminals, and IDs.
198
+ - Records: read from `adrs/` through adrkit on every `listRecords` command and after every run, so
199
+ files added, removed, or resolved while the server runs appear without a restart. Only records
200
+ adrkit accepts are listed. Each record carries the first paragraph of its `Decision` section as
201
+ a short summary.
202
+ - State: the dashboard's server-owned state is written atomically to
203
+ `<stateDir>/state/<24-hex hash of REPO_DIR>.json` (default `stateDir` is `<harness>/.tmp`), so it
204
+ survives a backend restart without leaking between repositories that share one harness install.
205
+ See [Durable state](#durable-state).
206
+
207
+ ## Install
208
+
209
+ The harness is the public npm package `@dsdenes/devboard`. One tarball carries the launcher, the
210
+ server, the client sources and built bundle, the prompts, the omp wrappers and extensions, the
211
+ templates, and the installer. `install`, `update`, and the running dashboard resolve versions and
212
+ artifacts from npm; no local checkout, running dashboard, or release tarball is required. When npm
213
+ cannot resolve or serve the artifact and `DEVBOARD_GITEA_URL` is configured, the canonical Gitea
214
+ release tarball and its `.sha256` are used for the same version. GitHub carries only a push mirror
215
+ of this repository.
216
+
217
+ ```bash
218
+ npx @dsdenes/devboard install # install or refresh this host
219
+ npx @dsdenes/devboard update # install the newest npm version; do not restart
220
+ npx @dsdenes/devboard doctor # one PASS or FAIL line per check
221
+ ```
222
+
223
+ `install` writes a real npm prefix at `<host>/.devboard`, replacing `.devboard`,
224
+ `.omp/commands`, and `.omp/extensions` symlinks with real directories that hold the installed
225
+ package's files. It keeps `.devboard/.tmp`, copies `templates/host-AGENTS.md` to
226
+ `<host>/AGENTS.md` when that file is a symlink or absent (an existing regular file is untouched
227
+ and reported as drift), adds the three ignored paths to `.gitignore`, and leaves exactly one
228
+ dashboard running from the installed package. A reinstall keeps the version and `.tmp`; `update`
229
+ never starts, stops, or restarts the dashboard.
230
+
231
+ `doctor` checks Node 20+, Bun, npm, the git root, npm's version and artifact metadata (or the
232
+ configured Gitea fallback and its digest), the installed package, the installed version against
233
+ the newest version, `omp` and its authenticated probe, wrapper byte-equality, `AGENTS.md`, the
234
+ dashboard's package and HTTP response, a non-symlinked `.devboard`, and a recorded update failure.
235
+ Drift in a regular `AGENTS.md` is reported without failing the check; every other mismatch exits
236
+ nonzero. Gitea is never required: with no address configured the checks consult npm only.
237
+
238
+ `--host <path>` targets another repository. `DEVBOARD_RELEASE_DIR` points the launcher and the
239
+ server at a local directory of tarballs for offline verification and CI fixtures. `DEVBOARD_NPM`
240
+ selects a different npm executable for those fixtures.
241
+
242
+ `install.sh` still copies the agent wrappers under `omp/` into an existing host:
243
+
244
+ ```bash
245
+ ./install.sh /path/to/host
246
+ ./install.sh --force /path/to/host
247
+ ```
248
+
249
+ The host must already contain a `.devboard/` directory. Every file under `omp/` is copied to the
250
+ same relative path under `<host>/.omp/`; matching files are left alone, unrelated host files are
251
+ never removed, and a differing file stops the run unless `--force` is given.
252
+
253
+ The global `/setup-devboard` command runs the launcher against the invoking session's working
254
+ directory. It lives here as `omp/commands/setup-devboard.md` and is linked into the user-level
255
+ command directory (`$HOME/.omp/agent/commands/setup-devboard.md`) so every session sees it.
256
+
257
+ ### Model selection
258
+
259
+ `omp/config.yml` is installed as `<host>/.omp/config.yml`, the host's omp project settings.
260
+ Every harness peer starts its ACP process with `<REPO_DIR>` as the working directory, so each
261
+ one, and the interactive agent started in that repository, uses the same policy:
262
+
263
+ |Setting|Value|Meaning|
264
+ |---|---|---|
265
+ |`modelRoles`|`opencode-go/deepseek-v4.1-flash` for `default`, `plan`, `smol`, `slow`, and `task`|Model for every chat role the harness uses.|
266
+
267
+ The model is served by OpenCode Go and reads `OPENCODE_API_KEY`. No fallback chain is
268
+ configured. Editing `omp/config.yml` here and rerunning `install.sh` changes the installed copy.
269
+
270
+ ## Build and run
271
+
272
+ Run the interface from this directory:
273
+
274
+ ```bash
275
+ cd .devboard
276
+ bun install
277
+ bun run build
278
+ bun run check
279
+ bunx tsc --noEmit
280
+ ADRS_DIR="<host>/adrs" bun run start
281
+ ```
282
+
283
+ `bun run check` type-checks every `.svelte` and `.ts` file under `client/` and `server/`;
284
+ `bunx tsc --noEmit` type-checks the TypeScript sources.
285
+
286
+ Open `http://127.0.0.1:8080/`. The server binds to `127.0.0.1` by default.
287
+
288
+ ## Decision records
289
+
290
+ `adrs/` is an adrkit corpus. Every record opens with YAML frontmatter — `schemaVersion: 0.2.0`,
291
+ `id`, `title`, `status`, `date`, `deciders`, and `affects` — and keeps its markdown body below the
292
+ closing fence. The server reads the corpus through `@adrkit/core`'s `lintCorpus`, so a `.md` file
293
+ whose frontmatter adrkit rejects never appears in the listing, in `pending`, or in `next`.
294
+
295
+ A record is pending when its frontmatter `status` is `draft` or `proposed`; every other status is
296
+ resolved. Records are ordered by file name, `next` is the first pending file in that order, and the
297
+ interface renders the status with a capital first letter.
298
+
299
+ Run the corpus tooling from `.devboard`:
300
+
301
+ ```bash
302
+ bun run adr:lint # adr lint --dir ../adrs
303
+ bun run adr:graph # adr graph --dir ../adrs
304
+ bun run adr:queue # adr queue --dir ../adrs
305
+ ```
306
+
307
+ `.github/workflows/adr.yml` installs the harness dependencies and runs `bun run adr:lint` on every
308
+ push to `main` and every pull request. On a pull request it also runs the `mbeacom/adrkit` action
309
+ with `dir: adrs`, which comments the pull request's governing decisions.
310
+
311
+ `.devboard/omp/mcp.json` declares a stdio MCP server named `adrkit` that runs
312
+ `bunx @adrkit/mcp --cwd . --dir adrs`. It answers `tools/list` with `search_decisions`,
313
+ `get_decision`, `get_decision_context`, and `list_superseded`.
314
+
315
+ ## Durable state
316
+
317
+ The dashboard keeps the state that drives the page in one file per host repository and restores
318
+ it from a new process after a graceful stop, a launcher restart, or a `SIGKILL`. The file is
319
+ `<stateDir>/state/<key>.json`, where `key` is the 24-hex SHA-256 prefix of the resolved
320
+ `REPO_DIR` (the same key the journal uses) and `stateDir` is the harness state directory,
321
+ `<harness>/.tmp` by default and overridable with `STATE_DIR`. Two repositories that share one
322
+ harness therefore never read or overwrite each other's lifecycle, lane, pass, or foreground
323
+ state, and a repository with no stored state starts at pass 1 with no lanes and no
324
+ `lifecycleState` until a run starts. The file is never committed, never copied between
325
+ repositories, and never written outside the repository root.
326
+
327
+ Every change a client can observe is written before the event that announces it is broadcast.
328
+ Writes replace the file atomically (temporary file plus rename), so the last complete state stays
329
+ readable at every instant, a kill during a write leaves the previous state, and a client never
330
+ sees an event that the replacement process cannot replay. The stored state covers the lifecycle
331
+ stage, pass, work, and paused flag; every lane with its id, kind, epic or record, attempt, status,
332
+ activity, question, blocked/failure bookkeeping, and remaining retention; epic attempts,
333
+ `mainAtFailure`, status, and the parked and retrying sets; the last reported lifecycle state
334
+ including `finished`; the manual ADR sequence; goal creation; goal revision with its guidance;
335
+ skills preparation with the pre-run rollback snapshot; an interrupted implementation reset; and an
336
+ accepted `whenIdle` restart. Browser-only state (open page per mode, drafts, layout, theme,
337
+ notifications, connection status) and full ACP transcripts, raw stderr, and secrets stay out of the file.
338
+
339
+ Restored activity is bounded per foreground run and per lane at the first of 1,000 entries or
340
+ 1 MiB of UTF-8 text. Within that bound no line is lost across a kill; beyond it the newest
341
+ entries are kept and one marker entry stating that earlier activity was trimmed precedes them.
342
+ The total serialized state for one repository stays under 32 MiB with 20 concurrent lanes at
343
+ full bounds.
344
+
345
+ A restored lifecycle that was advancing re-spawns every incomplete lane with the same id, kind,
346
+ epic or record, attempt, recorded activity (as an exact prefix), and lifecycle position:
347
+ implementation lanes receive `/implement-next-epic <epic>`, a generation lane receives
348
+ `/epic-next`, and an ADR lane receives the same `agentPrompt(adrDir, file)`. The replacement
349
+ never creates a second lane for an epic that still owns one, `laneSequence` continues above the
350
+ highest restored id, a retrying epic retries after the configured backoff, and a parked epic
351
+ starts no peer until `main` advances past its recorded revision. A queued generation pass runs
352
+ exactly once after the restored in-flight pass. A run that was paused stays paused: its lanes
353
+ replay status `paused`, no stage transition, tracker read, retry, generation pass, or new peer
354
+ happens until `resumeLifecycle`, and the resume then re-spawns each interrupted lane.
355
+
356
+ Foreground work that was live at death resumes with its original input. A manual ADR sequence
357
+ continues from its persisted record, re-spawning that record only while it is still pending
358
+ and otherwise advancing to the next pending record; goal creation re-spawns `/goal-outcome`; a
359
+ revision re-spawns the same prompt including its original guidance; skills preparation re-spawns
360
+ `/prepare-skills` after restoring the same pre-run snapshot, so a resumed preparation that ends in
361
+ failure restores the exact bytes captured before the original run. Restored activity is kept and
362
+ new lines append. A pending lane or foreground question is replayed to every connecting client
363
+ after the restored activity; answering a restored question that the replacement peer has not
364
+ re-issued is refused with `The question is no longer pending.` and the page updates without a
365
+ reload. Before re-spawning a unit, the replacement ends any recorded ACP child of the killed
366
+ process whose process table entry proves it is that peer; it never signals a process it cannot
367
+ prove.
368
+
369
+ A reset that was active at death is never re-run. The replacement performs no git commit, push,
370
+ deletion, or staging, emits `implementationResetStopped` with `completed: false` and one `error`
371
+ naming the interruption, and leaves the working tree and refs as the killed process left them.
372
+ After a successful reset the stored state holds pass 1 and the idle `goal` stage, so a restart
373
+ restores neither the old lanes nor the old pass. An accepted `restartServer` with
374
+ `whenIdle: true` survives the process death and is honored exactly once after the restored work
375
+ becomes idle; the pending flag is cleared before the launcher is invoked, so the replacement
376
+ never restarts itself again for the same request. With no launcher configured, the existing
377
+ unavailable-restart error is reported and the flag is cleared.
378
+
379
+ A closed or failed lane whose retention window expired while the server was down is not
380
+ replayed; the remaining window is honored after a restart. An unreadable, truncated, or
381
+ unsupported-version state file never prevents startup: the server renames it aside inside
382
+ `stateDir` (`.damaged-<timestamp>`), starts from the initial state, and reports exactly one
383
+ `error` to the first connecting client and on stderr, leaving the preserved bytes in place. A
384
+ read-only state directory never stops the server: it keeps serving from memory and reports one
385
+ `error` on stderr and in the journal.
386
+
387
+ ## Flow Mode
388
+
389
+ The activity bar carries four client-only modes, `Goal`, `Issues`, `Flow`, and `Discover`
390
+ (`data-testid="mode-goal"`, `data-testid="mode-flow"`, and `data-testid="mode-discover"`).
391
+ Switching is a view change only: it sends no WebSocket frame and
392
+ starts, stops, pauses, or resumes no work in any mode. Goal Mode is the goal, ADR, epic,
393
+ lifecycle, and skills interface described above. Flow Mode is one implementation session that the
394
+ developer steers while it works: the developer interviews for a goal, starts an implementation
395
+ agent in an isolated worktree, reviews the changes as color-coded widgets, discusses a widget with
396
+ a read-only agent, and writes steering notes and decisions that the agent consumes between
397
+ checkpoints.
398
+
399
+ ### One mode at a time
400
+
401
+ Both modes may be viewed at any time, but only one may hold the checkout. Starting Flow work —
402
+ creating its goal, starting its implementation — is refused with one `error` while any Goal Mode
403
+ work is live; starting a Goal Mode run, lifecycle, goal creation or revision, or skills
404
+ preparation is refused with one `error` while any Flow work is live. Merging a Flow pull request
405
+ is likewise refused while Goal Mode work is live. Flow work means a goal interview, an
406
+ implementation turn, a change analysis, a widget conversation, or pull-request work. View
407
+ switching and read-only commands stay available in both cases. Goal Mode's implementation reset is
408
+ refused while a Flow session exists whose worktree or pull request is not merged.
409
+
410
+ ### Commands and events
411
+
412
+ Flow Mode uses one WebSocket at `/ws`. The client sends `createFlowGoal`,
413
+ `startFlowImplementation`, `stopFlowImplementation`, `saveFlowGoal`, `retryFlowPr`,
414
+ `markFlowPrReady`, `mergeFlowPr`, `sendFlowSteering`, `saveFlowDecisions`, `acceptFlowWidget`,
415
+ `refuseFlowWidget`, `openFlowWidget`, `closeFlowWidget`, `sendFlowConversation`,
416
+ `sendFlowConclusion`, `dismissFlowBlocker`, and `flowAnswer` (with `target` naming the goal,
417
+ implementation, or conversation question). The server broadcasts `flowSnapshot`,
418
+ `flowGoalStarted`, `flowGoalStopped`, `flowImplementationStarted`, `flowStopped`, `flowActivity`,
419
+ `flowQuestion`, `flowFiles`, `flowWidgets`, `flowSteering`, `flowDecisions`, `flowCheckpoints`,
420
+ `flowVerification`, `flowBlockers`, `flowPr`, `flowNotice`, `flowError`,
421
+ `flowConversationStarted`, `flowConversationActivity`, `flowConversationQuestion`,
422
+ `flowConversationMessage`, and `flowConversationStopped`. A connecting client with a Flow session
423
+ receives one `flowSnapshot` holding the whole state followed by every section event, so a reload
424
+ never loses a widget, checkpoint, verification result, blocker, steering note, decision board, or
425
+ pull-request state; a server with no Flow work sends nothing and the view starts empty.
426
+
427
+ Flow's four agent commands are `/flow-goal <session-dir>`, `/flow-implement <session-dir>`,
428
+ `/flow-change <session-dir>`, and `/flow-widget <session-dir> <widget-id>`. The server spawns each
429
+ as its own ACP peer; the goal interview runs in the repository root, the implementation runs with
430
+ the isolated worktree as its working directory, and the change analyst and widget conversation are
431
+ read-only.
432
+
433
+ ### Goal and implementation
434
+
435
+ `createFlowGoal` creates a session when none is unmerged and starts `/flow-goal <session-dir>` in
436
+ one ACP peer. The prompt interviews the developer one question at a time until the desired end
437
+ result and every load-bearing boundary are settled, then writes only `<session-dir>/goal.md`; its
438
+ questions arrive as `flowQuestion` with `target: "goal"` and are answered with `flowAnswer`. The
439
+ goal text is end-result only: no implementation, technology, path, step, or internal criterion.
440
+ The interview is bounded by `goalTimeoutMs` (120 s by default). `<session-dir>/goal.md` is removed
441
+ before each interview, so only a file that interview writes can be accepted. A timeout kills the
442
+ peer, discards whatever draft it wrote, and reports `flowGoalStopped` with `completed: false`; the
443
+ session's accepted goal is left unchanged, exactly as when the interview writes no goal.
444
+ `saveFlowGoal` replaces the goal text while the implementation is stopped. `startFlowImplementation`
445
+ requires a non-empty goal and starts `/flow-implement`; `stopFlowImplementation` ends that peer and
446
+ reports the session `stopped`. A session whose pull request has merged cannot be restarted: create
447
+ a new goal.
448
+
449
+ The implementation commits each coherent unit itself and never pushes. The server reads its
450
+ commits, records the checkpoints, follows the diff, and runs the change analyst whenever the
451
+ diff changes. The developer watches activity, answers implementation questions, accepts or refuses
452
+ widgets, and writes steering notes and decisions while it works. A refused widget becomes a
453
+ steering note telling the agent to abandon that change and implement another solution.
454
+
455
+ ### Session state
456
+
457
+ A session lives at `<stateDir>/flow/<24-hex journalKey(REPO_DIR)>/<session-id>/`, keyed exactly
458
+ like the dashboard state, so repositories sharing one harness never see each other's sessions.
459
+ `stateDir` is the harness state directory (`<harness>/.tmp` by default, overridable with
460
+ `STATE_DIR`); `<session-id>` is an 8-hex id chosen when the session is created. The directory is
461
+ never committed and never written into the host.
462
+
463
+ |File|Contents|
464
+ |---|---|
465
+ |`goal.md`|The goal text `/flow-goal` wrote, or the text the developer saved.|
466
+ |`decisions.md`|The decision board: developer text plus appended timestamped sections.|
467
+ |`steering.jsonl`|Append-only developer steering notes.|
468
+ |`steering-ack.jsonl`|The ids of steering notes the implementation consumed.|
469
+ |`checkpoints.jsonl`|One JSON line per commit: id, time, summary, files, sha, verification.|
470
+ |`verification.json`|Latest test, lint, or build result: command, status, timestamp, output.|
471
+ |`changes.json`|The current changed files with status, insertion and deletion counts, and diffs.|
472
+ |`accepted.json`|The widget signatures the developer accepted.|
473
+ |`blockers.jsonl`|Every `DEVBOARD-BLOCKED:` line an implementation printed.|
474
+ |`session.json`|Machine state: ids, status, worktree, branch, base, widgets, checkpoints, PR.|
475
+ |`request.json`|Analyst input: goal, decisions, changed files and diffs, previous widgets.|
476
+ |`widgets.json`|The change analyst's complete widget list.|
477
+
478
+ `goal.md`, `decisions.md`, `verification.json`, and `checkpoints.jsonl` are re-read on restore,
479
+ so text a developer or agent changed while the server was down is the text the replacement uses.
480
+ `session.json` is the source of truth for status, worktree, branch, widgets, accepted signatures,
481
+ changed files, blockers, and the pull request.
482
+
483
+ ### Change widgets
484
+
485
+ The change analyst groups every changed file into one or more widgets and writes the complete list
486
+ to `widgets.json`. Each widget has a fixed category, a concept, a summary, a rationale, and the
487
+ files it covers. The seven categories are:
488
+
489
+ - `contract` — an interface another part of the system or an external caller depends on.
490
+ - `entity` — the identity, lifecycle, or ownership of a domain object.
491
+ - `naming` — a rename or terminology change with behavior otherwise unchanged.
492
+ - `data` — a migration, schema, persistence, or stored-data shape change.
493
+ - `dependency` — an added, removed, or changed external package, service, or tool.
494
+ - `irreversible` — a change that is costly or impossible to undo after shipping.
495
+ - `structure` — file layout, module boundaries, or code organization, and the fallback for a
496
+ file no clearer category explains.
497
+
498
+ Every category has its own color token, each with a light and a dark variant:
499
+ `--flow-contract-border` and `--flow-contract-surface` through
500
+ `--flow-structure-border` and `--flow-structure-surface`, defined for the day theme in `:root` and
501
+ overridden in the dark theme. A widget carries `data-category="<category>"` and always renders its
502
+ category label as text, so the color is never the only signal.
503
+
504
+ A widget's id is a signature: the first 16 hex characters of a SHA-256 over its category, its
505
+ sorted file paths, and those files' normalized diffs. The concept, summary, and rationale are
506
+ display metadata and never part of it. Acceptance is keyed by that signature and persisted in
507
+ `accepted.json`; a re-analysis that finds the same category, files, and diff keeps the same
508
+ signature and therefore stays accepted, while any edit to those files yields a new signature and
509
+ an unaccepted widget. A widget whose files leave the diff disappears, and its stale signature is
510
+ dropped from `accepted.json`. Every file left unnamed by the analyst is still shown, as a
511
+ `structure` widget, so no changed file is ever hidden.
512
+
513
+ ### Steering and the decision board
514
+
515
+ `sendFlowSteering` appends a timestamped note to `steering.jsonl`; the feed is append-only and
516
+ never rewritten. Notes carry `new` until the implementation appends the consumed ids to
517
+ `steering-ack.jsonl`, after which the server reports them `read`. The implementation reads only
518
+ the lines it has not consumed, appends the ids it acted on, and applies every direction before
519
+ its next checkpoint. Refusing a widget records the refusal as a steering note, and concluding a
520
+ widget conversation appends its conclusion to the decision board and adds one steering note.
521
+
522
+ The decision board is `<session-dir>/decisions.md`. The developer's `Save decisions` replaces
523
+ the document and is authoritative: it is exactly the text the agent reads next. Sections the
524
+ server appends — a widget conclusion, for example — are timestamped and source-named, and only
525
+ ever append: they never edit, reflow, or remove the developer's text or an earlier section. An
526
+ appended section is a record, not a vote: the developer's saved text still governs, and the
527
+ implementation must not rewrite either form.
528
+
529
+ ### Worktree, pull request, and merge
530
+
531
+ Starting an implementation creates the worktree `<repo>/.worktree/flow-<session-id>` on branch
532
+ `flow/<session-id>`, based on `origin/main` when it exists and on `main` otherwise, and runs
533
+ `/flow-implement <session-dir>` with that worktree as its working directory. The agent works and
534
+ commits only there; the server watches the branch and records every checkpoint it finds.
535
+
536
+ The first checkpoint beyond the base revision makes the server push the branch and open one draft
537
+ pull request or merge request against `main`, using the forge named by `origin`; later checkpoints
538
+ update its title and body with the goal and the decision board. The developer marks it ready
539
+ manually, only after the implementation is stopped and the worktree is clean. The developer then
540
+ merges it manually through the forge, and only as a merge commit — never squash, never rebase, and
541
+ only after both modes' work has stopped and the worktree is clean. Flow never merges on a
542
+ schedule, never force-pushes, and never rewrites the branch.
543
+
544
+ After a successful merge the server fast-forwards the host's `main` checkout when it is on `main`
545
+ and clean, removes the worktree, and deletes the local and remote branch once the branch tip is an
546
+ ancestor of `origin/main`. Whatever it could not remove is reported as a leftover and as one
547
+ `error`; the session still records the merge, and the leftover names each path or ref so nothing is
548
+ silently abandoned.
549
+
550
+ ### Restart and resume
551
+
552
+ A graceful stop, launcher restart, or `SIGKILL` leaves the session on disk. The replacement reads
553
+ the session directory and re-emits its complete state. A session that was running is reported
554
+ `stopped`, its pending question is cleared, and the server sends one `flowNotice` saying the Flow
555
+ implementation stopped and that starting it again resumes it. It never re-spawns a Flow agent on
556
+ its own: no goal interview, implementation turn, change analysis, or widget conversation resumes
557
+ automatically. Selecting `Start implementation` re-spawns `/flow-implement` with the same session
558
+ directory, and the agent reads the persisted goal, decisions, steering acks, checkpoints, and
559
+ verification before continuing. A session that was idle stays idle. The replacement recomputes the
560
+ diff and widgets from the worktree without spawning an analyst, so a restart can never lose review
561
+ state or overwrite a newer widget list.
562
+
563
+ ### What Flow never does
564
+
565
+ Flow never creates an issue or an epic, never queries the tracker, and never labels, closes, or
566
+ comments on one. Its forge use is limited to the session's draft pull request or merge request and
567
+ that request's branch: create, update, mark ready, view, merge, and delete the remote branch. Flow
568
+ never writes `GOAL.md` and never writes `adrs/`; the flow goal belongs only to its session
569
+ directory, and the decision board is never an ADR record.
570
+
571
+ ## Discover Mode
572
+
573
+ The Discover mode is the fourth client-only view, opened from the activity bar. It exists for an existing host project: one
574
+ read-only crawl produces an evidence-backed project sheet, a UbiLang glossary for each detected
575
+ bounded context, and five project-specific Agent Skills that every devboard implementation agent in
576
+ that host discovers. Switching to Discover, and switching its pages, sends no WebSocket frame
577
+ and starts, stops, resets, or clears nothing; all three views stay mounted, so native details,
578
+ scroll state, and focus survive. Discover events render only in this view and never touch Goal
579
+ Mode's activity, question, or error state.
580
+
581
+ The Discover sidebar opens pages for `Overview` and `Glossary` without a group heading. Once
582
+ Discover artifacts are published it also lists the five skills `Testing Strategy`,
583
+ `Naming Conventions`, `Coding Conventions`, `Architecture & Boundaries`, and `Glossary` under
584
+ **Skills** (`data-testid="discover-tab"` with `data-tab` values `overview`, `glossary`,
585
+ `testing-strategy`, `naming-conventions`, `coding-conventions`, `architecture-boundaries`, and
586
+ `glossary-skill`); with no published artifacts the **Skills** group and its header do not render.
587
+ The sidebar has no header, so its first entry is its mode's navigation. The active panel carries
588
+ `data-testid="discover-panel"` and the same `data-tab`.
589
+ Warnings appears as its own group with a count only while at least one warning exists; the sidebar
590
+ keeps no freshness group and no recent-notice list.
591
+
592
+ Every Discover page shares one frame: an optional uppercase eyebrow, one `h1`, a subtitle, an
593
+ optional meta row, and a right-aligned action group. The Overview frame holds the repository as
594
+ `owner/name` from its GitHub remote (falling back to the directory name when no remote names one;
595
+ the full path stays in the tooltip), the one-line definition, the discovery date, and the single
596
+ filled run action. Staleness is not repeated on the sheet: the activity bar and the status bar
597
+ report it. While a run exists, every Discover page carries a run tag above its notice stack: a
598
+ status dot, `Discover run in progress`, the age, the run identifier as a code chip, the latest
599
+ activity line, and Stop. Below the Overview frame, the active run region adds the full activity
600
+ terminal: a tinted header with `Run in progress`, `Started 3 minutes ago`, and Stop, followed by
601
+ the terminal. A queued question region follows while a question waits.
602
+ The sheet follows: Platform, Delivery, and Stack in a bounded rail on the left, Commands and
603
+ Testing in the main column on the right, and Bounded contexts below them. The manual-testing strip
604
+ is the last region of the page, so a source checkout never pushes it above the project identity. An
605
+ error, an interrupted-run notice, and a stop notice render above the page in one notice stack.
606
+
607
+ The Glossary row is a plain section link: it has no disclosure control and no nested submenu, so
608
+ individual terms are never repeated as sidebar menu items. A term opens from its row on the
609
+ Glossary page.
610
+
611
+ With no published artifacts the Overview page shows the header's first-run explanation and a
612
+ `Discover project` button (`data-testid="discover-start"`); with artifacts it shows `Re-discover`.
613
+ The button is disabled when the socket is not open, while a Discover run is active,
614
+ or while Goal or Flow work is active, and its title names the reason. It never starts on page
615
+ load, reconnect, mode switch, or page switch. Selecting it sends `{ "type": "discoverProject" }`;
616
+ the server broadcasts `discoverStarted` and starts exactly one ACP peer whose prompt is exactly
617
+ `/discover <staging-dir>`, whose working directory is the active target root, and whose staging
618
+ directory is `<target>/.omp/discover/staging/<run-id>/`. A second start while one runs is refused
619
+ with one `discoverError` and spawns no peer. A start requires no `GOAL.md`, no prepared skill, a
620
+ clean tree, or a forge account; it is accepted on a host with a dirty tree and no goal document.
621
+
622
+ The run is server-owned: closing the browser or switching views does not stop it. The view shows
623
+ an active marker (`data-testid="discover-active"`) and the xterm activity output, and a reconnect receives
624
+ `discoverState`, `discoverStarted`, every recorded activity line, and any pending question before
625
+ its own command replies. The crawl is not an interview: an ambiguity is recorded as `unknown` with
626
+ a reason, never a blocking question. `answerDiscover` with the pending id and a non-empty value
627
+ answers it once; a second answer, a stale id, or an empty value is refused with one
628
+ `discoverError`. `stopDiscover` closes the peer, clears the pending question, removes staging,
629
+ broadcasts `discoverStopped { completed: false }`, and leaves the last published artifacts
630
+ byte-identical; it spawns no replacement peer.
631
+
632
+ Discover uses the same WebSocket at `/ws`. The client sends `discoverProject`, `stopDiscover`,
633
+ `answerDiscover` (with `id` and `value`), `getDiscover`, `saveDiscoverSkill` (with `name` and
634
+ `text`), and `restoreDiscoverSkill` (with `name`). The protocol also accepts
635
+ `confirmGlossaryTerm` (with `context` and `id`) and `rejectGlossaryTerm` (with `context`, `id`, and
636
+ a non-empty `reason`) for tooling, but the Glossary page does not use them. `getDiscover` asks for
637
+ the current snapshot and is answered with `discoverState`. The server
638
+ broadcasts `discoverState`, `discoverStarted`, `discoverActivity`, `discoverQuestion`,
639
+ `discoverStopped`, and `discoverError`; it never sends `activity`, `question`, `error`,
640
+ `runStarted`, or `runStopped` for Discover. `discoverState` carries the active and interrupt flags,
641
+ staleness, warnings, the busy reason, the last read error, the manifest, freshness, sheet,
642
+ repository links for installed packages, glossaries, per-concept usage and examples measured from
643
+ the host's documentation and source, `glossarySignificance` keyed by bounded context and then
644
+ concept id with `{ score, reason }` values, decisions, and the five skills with baseline text, live
645
+ text, hashes, and state.
646
+ `discoverQuestion` carries `id`, `message`, and `options` of `{ label, description?, recommended }`.
647
+
648
+ When the peer exits, the server validates every staged artifact and the whole staged set before
649
+ publishing. Success publishes atomically, removes staging, broadcasts `discoverState` with the new
650
+ artifacts, and broadcasts `discoverStopped { completed: true }`. Any invalid, missing, unreadable,
651
+ or incomplete artifact, schema violation, write outside staging, or collision with a non-Discover
652
+ skill fails the run: staging is removed, one `discoverError` names the reason,
653
+ `discoverStopped { completed: false }` follows, and the previous sheet, glossary, manifest,
654
+ baselines, and live skills stay byte-identical.
655
+ Published artifacts are read from disk when a client connects, so no prior run in the process is
656
+ required; an unreadable or invalid published artifact is reported with one `discoverError` and the
657
+ files are never overwritten.
658
+
659
+ Artifacts live only under the host: `.omp/discover/manifest.json`, `.omp/discover/sheet.json`,
660
+ `.omp/discover/decisions.json`, `.omp/discover/significance.json`,
661
+ `.omp/discover/glossary/<bounded-context>.glossary.yaml`,
662
+ `.omp/discover/skills/<skill>/SKILL.md` for the generated baselines, and the live, editable
663
+ `.omp/skills/<skill>/SKILL.md`. Staging mirrors the crawl's relative layout without `manifest.json`
664
+ or `decisions.json`, and the staged sheet omits freshness. The manifest carries a schema version,
665
+ the discovery fingerprint, the publication time, the run id, the sheet's structural digest, one
666
+ structural digest per glossary, the five skill names, each live skill's baseline and current hash,
667
+ each skill's edited or missing state, and artifact paths relative to the host; it contains no
668
+ secrets and no absolute path. A manifest written by this code carries
669
+ `paths.significance: ".omp/discover/significance.json"`; a manifest without that path predates the
670
+ significance sidecar and is read as legacy, with no error.
671
+
672
+ `sheet.json` is the canonical data sheet, with top-level keys in the fixed order `schemaVersion`,
673
+ `definition`, `stack`, `deployment`, `environments`, `commands`, `branching`, `testing`,
674
+ `architecture`, `ci`, `runtime`, `dependencyMap`, and `delivery`. It has a freshness header naming
675
+ the project, the short commit, the dirty flag, the discovery time, and the stored fingerprint. The
676
+ Overview page is a project sheet, not a card grid. A masthead
677
+ (`data-testid="discover-freshness"`) carries the project name, the one-sentence definition, and the
678
+ discovery date. The commit, the dirty flag, and the staleness flag stay in the sheet and the state
679
+ payload but render no header badge. Below the masthead the sheet is one bounded two-column spine:
680
+ `Platform`, `Delivery`, and `Stack` in a left rail, and Commands, Testing, Quality gates, and
681
+ Bounded contexts in the main column on the right. Every section carries
682
+ `data-testid="discover-card"` and `data-field`; the commands, testing, and quality-gate panels are
683
+ raised surfaces and the remaining sections are flat, separated by hairlines. Below 1240px viewport
684
+ the columns collapse into one in reading order.
685
+ Tables grow with the page; neither a panel nor a card shrinks below its rows, and none scrolls
686
+ inside the page. Every non-`unknown` leaf
687
+ carries at least one sorted, unique repository-relative evidence path, symbol anchor, or safe git
688
+ fact and a confidence of `observed`, `declared`, `inferred`, or `unknown`; `unknown` carries empty
689
+ evidence and a short reason. Confidence labels, evidence paths, the `Not observed` ledger, the
690
+ repeated dependency map, and the static lead and note prose are not rendered; the stored facts and
691
+ the sheet's confidence and evidence stay unchanged. The definition is exactly one sentence of at
692
+ most 240 characters; every value and list entry is a single line of at most 160 characters. The
693
+ sheet covers the short platform stack (languages, runtimes, package managers, frameworks, and
694
+ major tools, not individual dependencies), customer-facing deployment (artifact class, artifact,
695
+ target, and pipeline sources), every environment (at least `production`, whose purpose states how
696
+ changes reach it), exact main commands (with `unknown` for an unobserved command), branching
697
+ strategy and model, test levels and pyramid, architecture contexts and their directed dependencies
698
+ plus boundaries, CI provider, triggers, jobs, and quality gates, and the three mandatory sections:
699
+ runtime inventory, entry-point and dependency map, and delivery and versioning. It never guesses,
700
+ invents an additional environment, copies README prose, or records a configuration value; a claim
701
+ it cannot ground is `unknown`.
702
+
703
+ Main commands render as a two-column `Task`/`Command` table of the observed commands, `$`-prefixed,
704
+ with no header row, under an `N of 8 commands found` count. That count is a disclosure
705
+ (`data-testid="discover-command-coverage"`): activating it lists every one of the eight command
706
+ slots, and a slot the crawl did not find renders a muted `not found` placeholder whose title
707
+ carries the unknown reason. A `tryOut` command is listed only when it is a hands-on entry point
708
+ that no other recorded command aliases. A command the host manifest declares as a script is shown
709
+ as its invocation — `npm test` for `scripts.test`, `npm run <name>` for any other script — instead
710
+ of the program the script expands to; the recorded program is the chip's tooltip. Every command,
711
+ path, package, and boundary value uses the same inline code chip (`.code-chip`); no surface has a
712
+ second inline-code presentation. Testing shows the test, level, and
713
+ happy-path totals and the whole-suite command (`data-testid="discover-suite-command"`), then a
714
+ `Level`/`Tests`/`Happy path`/`Command` table (`data-testid="discover-pyramid"`). Happy path is the
715
+ part of a level's count that exercises the primary success path, recorded as an exact count from
716
+ `0` to the level's total; the summary percentage appears only when every listed level reports one.
717
+ `Command` is the narrowest declared invocation for that level. Only a level that carries a test is
718
+ listed and counted; a recorded level with no test case is omitted, so a sheet cannot claim a
719
+ second level while its table holds none. Every listed level states an exact test count, never
720
+ `count unknown`. No pyramid graphic is drawn. Quality gates render as a `Quality gate`/`What it
721
+ checks` table (`data-testid="discover-quality-gates"`): one row per required CI gate, its name, and
722
+ one short line saying what it verifies. A gate recorded without a check states `No check was
723
+ recorded.`, and the gates are not repeated among the Delivery facts. Runtime, deployment, CI, and
724
+ branching use compact `dt`/`dd` facts whose list values are comma-separated.
725
+ Architecture renders under the heading `Bounded contexts` as context rows showing the name,
726
+ responsibility, and entry points, followed by external-boundary and shared-kernel chips and a
727
+ rendered Mermaid dependency graph. The repository path of a bounded context is not rendered. The
728
+ graph draws one node per bounded context and one arrow for every `architecture.dependencies` edge,
729
+ pointing from the context that depends to the context it reaches; it is drawn with `flowchart LR`
730
+ through Mermaid at render time, and a sheet without recorded edges draws no graph.
731
+ External boundaries name only external systems — a remote API or service, a message broker, a
732
+ database, or an identity provider the host integrates with across its own process and artifact.
733
+ Language and runtime built-ins describe internal architecture, so `node:child_process`,
734
+ `node:worker_threads`, and other concurrency primitives are never boundaries; a host that integrates
735
+ with no external system shows `None detected.` in that block instead of an invented entry or
736
+ `unknown`.
737
+ `Stack` renders one full-width chip band after the two columns; a chip whose package is installed
738
+ under `node_modules/` becomes a link to the repository its manifest declares, and every other chip
739
+ stays plain text. Environments is `unknown` here and is not listed. The delivery section, the
740
+ deployment pipeline sources, and the runtime `versions` fact stay hidden because they repeat other
741
+ facts. A sheet with no observed test level and a glossary with no matching term keep an explicit
742
+ empty state.
743
+
744
+ The Glossary page and `.omp/discover/glossary/` implement the UbiLang glossary format: one YAML
745
+ document per bounded context named `<bounded-context>.glossary.yaml`, top-level `boundedContext`,
746
+ `titles`, and `concepts`, and concepts carrying `id`, `prefLabels`, `altLabels`, `hiddenLabels`,
747
+ `definitions`, `scopeNote`, `example`, `historyNote`, `status`, `origin`, `standardRef`, `kind`,
748
+ `grounding`, `broader`, and `related` in that order. Fields are camelCase, enum values use the
749
+ standard's snake_case spellings, ids are unique kebab case, every concept has an English preferred
750
+ label and at least one `en` definition, and every label language is `en` or `de`. No undeclared
751
+ field is written. Every concept has at least one grounding of the form
752
+ `code:<repository-relative path that exists>` or `model:<name that appears in the host>`; an
753
+ ungrounded candidate is omitted. The crawl stages
754
+ `status: unconfirmed` with `origin: owner` or `standard` for a term found in code or documentation,
755
+ and `status: proposed` with `origin: llm` for a name it proposes for an existing unnamed concept;
756
+ it never writes `confirmed`, `deprecated`, or `invalidated`. On publication the server confirms
757
+ every newly crawled term, so the published glossary contains only confirmed terms unless an
758
+ earlier operator decision invalidated one. The page groups terms by context and offers search; it
759
+ shows no status filter, status badge, or per-term control, and it hides invalidated terms. Each
760
+ context card holds a table with `Kind`, `Term`, `Significance`, `Definition`, and an unnamed
761
+ examples column; the Kind, Term, Significance, and examples columns have the same fixed widths in
762
+ every context card, the term reads in bold, and the examples cell lists the identifiers that name
763
+ the concept in the host's code. The table grows with the page, neither the card nor the table
764
+ scrolls inside a panel, and long definitions wrap; each term carries `data-testid="discover-term"`,
765
+ `data-context`, `data-id`, `data-status`, `data-kind`, `data-significance`, and `data-examples`, and
766
+ each context carries `data-testid="discover-context"`. The context heading shows its title, falling
767
+ back to the context id only when no title exists. Every Kind value carries a Lucide icon colored by
768
+ kind: entity, event, field, role, actor, value, and rule each use a stable token. The search field
769
+ centers its placeholder vertically.
770
+
771
+ The crawl authors each concept's significance; the server never derives a score from occurrence
772
+ counts. The crawl includes only project-significant entities in a glossary, omits generic framework,
773
+ library, language, and UI vocabulary such as `option`, `hook`, or `command` unless the host defines
774
+ that exact term as a project concept grounded in at least two distinct host files, and stages a
775
+ score from `0` to `100` with a one-line reason for every included concept, where 50 is the
776
+ significance floor and a lower score means an incidental term. `glossary/significance.json` is the
777
+ staged sidecar; the server validates it, then publishes it atomically as
778
+ `.omp/discover/significance.json` beside the glossary artifacts. Its shape is
779
+ `{"schemaVersion": 1, "contexts": {"<boundedContext>": {"<concept-id>": {"score": 0, "reason":
780
+ "One line of evidence."}}}}`. The run fails with one `discoverError` and publishes nothing when the
781
+ sidecar is absent, is not valid JSON or not an object, carries a top-level key other than
782
+ `schemaVersion` and `contexts`, carries a `schemaVersion` other than `1`, names a context or concept
783
+ the staged glossaries do not stage, omits a staged context or concept, carries an entry key other
784
+ than `score` and `reason`, carries a score that is not an integer from `0` through `100`, or carries
785
+ a reason that is empty, multi-line, longer than 160 characters, an absolute path, or a secret. A
786
+ rejected sidecar leaves the previously published sheet, glossaries, manifest, decisions, sidecar,
787
+ baselines, and live skills byte-identical. A concept preserved from an earlier human decision but
788
+ absent from the staged sidecar is published unscored rather than rejected. Scores are excluded from
789
+ the glossary digest and the sheet digest, so changing only a score moves no digest.
790
+
791
+ The Glossary page orders each context's rows by significance descending, with unscored concepts last
792
+ and ties broken by the displayed term label, and it renders the score in the `Significance` column;
793
+ an unscored concept reads `—`. When at least one published concept is scored, a `Significant only`
794
+ control (`data-testid="discover-glossary-significant"`) is present and checked by default, and the
795
+ floor slider (`data-testid="discover-glossary-threshold"`) spans `min="0"` to `max="100"` with
796
+ `value="50"`. With the control checked, a concept is listed only when it matches the search and
797
+ either has no score or scores at or above the slider value; clearing it lists every term matching
798
+ the search and disables the slider. The subtitle reports the visible terms, the total terms, and the
799
+ active floor, the sidebar glossary count stays the published term count, and the search, filter, and
800
+ slider compose. The search field, the `Significant only` switch, and the valued floor sit in one
801
+ bounded toolbar row. A glossary published before the sidecar existed keeps its concepts and stays
802
+ unfiltered: every term is visible, each Significance cell reads `—`, and the switch and slider are
803
+ disabled. An invalidated term never appears. A glossary row is read-only: it carries no activation
804
+ command, no focus target, and no single-term page. The server still measures per-concept usage and
805
+ examples for the row's examples cell, but `glossaryUsage` never decides which term is listed or in
806
+ which order.
807
+
808
+ Confirmation is automatic, so the Glossary page offers no `Confirm` or `Reject` control.
809
+ `confirmGlossaryTerm` still succeeds only for a term that is proposed or unconfirmed, and
810
+ `rejectGlossaryTerm` requires a non-empty reason and changes a proposed, unconfirmed, or confirmed
811
+ term to `invalidated`; neither is reachable from the page. Both append a dated decision to
812
+ `decisions.json` with the context, id, action, reason when rejecting, and date, never adding a
813
+ field to the YAML, and the change survives reload, reconnect, and restart. A later crawl preserves
814
+ every confirmed or invalidated concept's labels, definitions, notes, status, origin, standard
815
+ reference, kind, and related ids; it may refresh grounding and add confirmed concepts, but it never
816
+ resurrects an invalidated id, never overwrites a human decision, and removes an undecided concept
817
+ only when the new crawl no longer grounds it. A malformed or structurally invalid glossary fails
818
+ the run atomically with one `discoverError` naming the file and rule.
819
+
820
+ A successful run publishes exactly five skills under `.omp/skills/`: `discover-testing-strategy`,
821
+ `discover-naming-conventions`, `discover-coding-conventions`,
822
+ `discover-architecture-boundaries`, and `discover-glossary`. Each is a directory containing
823
+ `SKILL.md` whose frontmatter has `name` equal to the directory, a non-empty `description` of at most
824
+ 1024 characters, `metadata.devboard-managed: "true"`, `metadata.devboard-family: "discover"`, and
825
+ `metadata.devboard-skill` naming its role. The bodies are project-specific and directive: one
826
+ imperative bullet per rule, and no bullet names a source, test, manifest, or CI path — an exact
827
+ command may still contain one. A bullet never says where a rule was observed: no evidence clause,
828
+ file name, directory name, or path outside an exact command, so the skill carries policy and the
829
+ sheet carries provenance. Testing strategy states levels, pyramid, commands, fixture roles,
830
+ coverage expectations, and how to add a test; naming conventions state canonical terms, casing, and
831
+ rejected aliases; coding conventions state patterns, error handling, typing, async, logging,
832
+ dependencies, formatting, and prohibited constructs; architecture and boundaries state contexts,
833
+ entry points, dependency direction, persistence, integrations, and prohibited cross-boundary
834
+ imports; the glossary skill names the canonical glossary directory, the confirmed and rejected
835
+ rules, and the proposal process. From the host
836
+ root, a new `omp` session lists the five (`omp skill list`) and loads their bodies as skills, so
837
+ every implementation agent in that host discovers the set with no extra configuration.
838
+
839
+ Each skill has a page that is one full-height Monaco Markdown editor for the whole `SKILL.md`
840
+ (`data-testid="discover-skill-text"`), with no rendered preview and no split pane. The page header
841
+ holds only the skill's page title. The editor panel header names the skill's relative path,
842
+ `<skill>/SKILL.md`, with the skill name emphasized and the file name muted; a second 11px line
843
+ states the draft (`No unsaved changes`, `Unsaved changes`, or `Live file missing`). The header
844
+ also holds `discover-skill-save`, disabled until the draft differs from the live text, and, while
845
+ the live text differs from the generated version or the file is missing,
846
+ `discover-skill-restore`. A `discover-skill-dirty` marker replaces the clean state line while the
847
+ draft has unsaved changes; the page renders no state badge and no purpose line, because the
848
+ sidebar already flags `edited` or `missing`.
849
+ `saveDiscoverSkill` writes the submitted bytes verbatim and updates the manifest's current hash and
850
+ edited state; it is refused with one
851
+ `discoverError` when the name is not one of the five, the skill is missing, the frontmatter name
852
+ no longer matches the directory, the description is empty or too long, or a marker was removed,
853
+ and invalid text leaves the previous file byte-identical. `restoreDiscoverSkill` copies the current
854
+ generated version over the live skill, recreating it when it was deleted; it spawns no agent. A
855
+ later run compares each live hash with its last baseline: an unedited skill is replaced,
856
+ an edited skill is preserved byte for byte with its new baseline published beside it, and a skill
857
+ the developer deleted is not recreated but shows the `Recreate generated version` action. A
858
+ generated name colliding with a prepare-family, unmanaged, or different Discover skill fails the
859
+ run and publishes nothing.
860
+
861
+ The goal lifecycle's skill prerequisite accepts a managed skill from either family: at least one
862
+ prepare-family or Discover skill is enough for `Start implementation`. `getSkills` and `skillsState`
863
+ carry `name`, `description`, `family` (`prepare` or `discover`), and `state` (`generated`, `edited`,
864
+ or `missing`), and a managed skill with no `devboard-family` field belongs to `prepare`. The Skills
865
+ panel lists both families with their family and Discover state, and every prepare-family row
866
+ carries a Delete control that confirms before removing that one skill. Prepare Skills replaces only its
867
+ own family, and its contradiction review covers its family plus hand-authored unmanaged skills, so
868
+ a project-specific Discover directive cannot fail a project-agnostic preparation. Discover's
869
+ generated baselines must agree with each other and with the glossary; a contradiction there fails
870
+ the run, while a contradiction introduced by a developer's skill edit is a non-blocking warning in
871
+ the skill editor and never discards the edit.
872
+
873
+ The sheet and every glossary are canonicalized before publication: fixed schema key order, arrays
874
+ sorted by stable identity, evidence sorted and de-duplicated, and enums in fixed spelling. The
875
+ structural digest excludes volatile fields (`discoveredAt`, fingerprint timestamps, run id, and
876
+ publication time) and bounded prose (the definition, glossary titles, definitions, scope notes,
877
+ history notes, and unknown reasons); skill bodies and descriptions are not part of it. Two runs
878
+ against the same unchanged host fingerprint produce the same sheet digest and the same digest for
879
+ every unchanged glossary, while bounded prose may differ. The fingerprint covers the host `HEAD`
880
+ and the content of tracked and untracked non-ignored files, excluding `.devboard/`, `.worktree/`,
881
+ `.omp/discover/`, `.omp/skills/`, VCS internals, and ignored files; the manifest stores it, and
882
+ `stale` is true only when the current fingerprint is readable and differs. Discover caches nothing:
883
+ every accepted start re-crawls.
884
+
885
+ The crawl is read-only with respect to the host. Its tools read files and safe read-only git facts
886
+ (`git rev-parse`, `git log`, `git branch`, `git status`, `git ls-files`) only; they never run a
887
+ project command, test, build, package manager, install, web fetch, or forge query, and the only
888
+ network traffic is the model endpoint that serves the peer. The crawl excludes `.devboard/`,
889
+ `.worktree/`, `.git/` internals, `.omp/`, dependency caches, build outputs, and ignored files, but
890
+ reads `README`, `AGENTS.md`, `GOAL.md`, `adrs/`, manifests, configuration, source, tests, and CI
891
+ files that remain. It never modifies `GOAL.md`, `adrs/`, or host source and never writes
892
+ `.omp/discover` or `.omp/skills` directly; its only write is the staging directory. Environment
893
+ and configuration names may be recorded, but their values, tokens, credentials, private URLs, and
894
+ URLs with user info are never copied into an artifact.
895
+
896
+ A Discover run is active work. While it runs, `startRun`, `startLifecycle`, `resumeLifecycle`,
897
+ `saveGoal`, `reviseGoal`, `createGoal`, `prepareSkills`, every Flow start, and a second
898
+ `discoverProject` are refused with one error naming the active run and spawn no peer; view and
899
+ tab switching, read-only commands, and `resetImplementation` stay available. While any Goal or
900
+ Flow work is active, `discoverProject` is refused with one `discoverError` naming the active work.
901
+ `restartServer` with `whenIdle: true` treats a Discover run as active and answers `upgradePending`,
902
+ and an immediate restart ends the recorded child, removes staging, publishes nothing, sends one
903
+ `discoverError` naming the interruption, sets the interrupted flag, and never auto-resumes.
904
+ `resetImplementation` cancels a run the same way and leaves `.omp/discover` and the Discover skills
905
+ untouched because reset preserves `.omp`. Only one run exists at a time; a stopped or interrupted
906
+ run leaves the previous artifacts readable, and a later start uses a fresh staging directory.
907
+
908
+ ### Remote targets for manual testing
909
+
910
+ When the dashboard serves a harness source checkout — `<REPO_DIR>/.devboard/package.json` names
911
+ `@dsdenes/devboard` and `<REPO_DIR>/.devboard/node_modules/@dsdenes/devboard` does not exist —
912
+ Discover's Overview offers one extra target: a public GitHub repository supplied as a
913
+ URL. An installed host renders no such control and both new commands are refused. The predicate
914
+ reads no repository name, remote, or other host fact.
915
+
916
+ The field accepts exactly `https://github.com/<owner>/<repo>`, with an optional trailing `/` and an
917
+ optional `.git` suffix. Owner and repository are 1–100 letters, digits, dots, underscores, or
918
+ hyphens, and the whole URL is at most 2048 bytes. `http://`, `git@github.com:`, `ssh://`, another
919
+ host, a port, user information, a query, a fragment, an empty or whitespace-only value, an empty
920
+ owner or repository, `.`, `..`, more than two path segments, and absolute or Windows path shapes are
921
+ refused with one `discoverError` naming the reason; nothing is spawned and no directory is created.
922
+ Branch or ref selection, private repositories, and credentials are out of scope; any number of
923
+ public repositories may be retained.
924
+
925
+ The retained root is `<STATE_DIR>/discover-remote/` and must resolve inside `<REPO_DIR>`. It holds
926
+ one directory per discovered repository, named `<owner>-<repo>` in lowercase, and nothing else once
927
+ a run ends. Each directory keeps the bytes the crawl published — `.omp/discover/` and
928
+ `.omp/skills/` — beside `project.json`, which records `url`, `owner`, `repo`, and the ISO
929
+ `completedAt` time. A run's checkout is a transient `.staging-<run-id>` directory beside those
930
+ projects; it never survives the run. While a run is live it owns a claim at
931
+ `.runs/<checkout-name>.json` naming its process, and the claim and the `.runs/` directory leave
932
+ with the checkout.
933
+
934
+ A submit broadcasts `discoverState` with `cloning = { url }` before spawning anything, then runs
935
+ exactly one child: `git clone --depth 1 --single-branch --no-tags -- <url> .staging-<run-id>` with
936
+ the retained root as its working directory, `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=true`,
937
+ `NO_COLOR=1`, and a 300-second timeout. On success the checkout becomes the crawl root and exactly
938
+ one crawl runs against it. Publication writes into the checkout's `.omp`; when the crawl succeeds,
939
+ the two published trees are copied into `<owner>-<repo>/`, `project.json` is rewritten with the
940
+ submitted URL and the completion time, and the checkout is removed. The active target moves to the
941
+ new project only after its bytes are retained, so a failed clone or a failed crawl leaves the
942
+ previous target, its bytes, its entry, and its completion time untouched and reports exactly one
943
+ `discoverError`. A second `discoverRemote`, `discoverProject`, `switchDiscoverRemote`,
944
+ `removeDiscoverRemote`, or `clearDiscoverRemote` while `cloning` is non-null is refused with one
945
+ `discoverError`, as it is while a Discover run or other Goal/Flow work is active. A clone is never
946
+ resumed.
947
+
948
+ With a remote target every Discover read and write uses the retained directory: the Overview,
949
+ staleness, warnings, skill saves and restores, glossary decisions, and the manifest all come from
950
+ `<owner>-<repo>/.omp`. A crawl is the only path that needs a checkout; starting Discover on a
951
+ retained target — `discoverProject`, or `discoverRemote` with its URL — first clones the project
952
+ again, runs exactly one crawl, replaces the snapshot on success, and removes the checkout. Remote
953
+ grounding evidence is stored in the glossary, so the retained snapshot renders without the source
954
+ tree. This checkout's `.omp/discover` and `.omp/skills` stay byte-identical, and a remote operation
955
+ writes nothing outside `<STATE_DIR>/discover-remote/` apart from the dashboard's own state file.
956
+
957
+ Every discovery is retained, so the list holds exactly one entry per lowercased `owner/repo`:
958
+ discovering the same repository again in any letter case, with a trailing slash, or with a `.git`
959
+ suffix updates that entry's bytes, URL, and completion time and moves it to the top. Entries are
960
+ listed newest completed discovery first. `discoverState` carries this list as `retained`
961
+ (`{url, owner, repo, completedAt}[]`) and `target.checkout`, which is true only while the active
962
+ project's transient clone exists.
963
+
964
+ Durable state stores the active target as URL, owner, and repository only. A replacement restores
965
+ it only while `<owner>-<repo>` holds a readable `project.json` and both `.omp` trees, without a
966
+ clone or a re-crawl; otherwise it starts on the host target, drops that one stored target, keeps
967
+ every other retained entry, and sends one `discoverError` naming the missing project to the next
968
+ client. Startup removes every `.staging-*` directory and any leftover directory containing `.git`,
969
+ so a restart never resumes a run and never deletes a retained project. The one exception is a
970
+ transient another live dashboard still claims: two dashboards may serve the same repository with
971
+ one `STATE_DIR`, and a starting dashboard never deletes a checkout a running one owns. A restart
972
+ during a remote crawl interrupts it as a host run does: the recorded child ends, the checkout's
973
+ staging is removed, its retained snapshot stays, and nothing is published or resumed. A stopped
974
+ dashboard removes its own checkout and claim.
975
+
976
+ The protocol adds `{ "type": "discoverRemote", "url": string }`,
977
+ `{ "type": "switchDiscoverRemote", "url": string }`,
978
+ `{ "type": "removeDiscoverRemote", "url": string }`, and `{ "type": "clearDiscoverRemote" }`. A
979
+ malformed payload, a missing or non-string `url`, and an unknown command receive the existing
980
+ unknown-command treatment and change nothing; an unknown or malformed URL in a switch or removal
981
+ receives one `discoverError` naming it. `switchDiscoverRemote` changes the active target to a
982
+ retained project and renders its retained bytes without cloning, crawling, or network access;
983
+ `removeDiscoverRemote` deletes exactly that project's directory and list entry, returning to the
984
+ host target when it was the active one; `clearDiscoverRemote` returns to the host target and keeps
985
+ every project and its bytes. All three are refused with one `discoverError`, changing nothing,
986
+ while a clone or a Discover run is active or while the existing busy rules refuse
987
+ `clearDiscoverRemote`.
988
+
989
+ `discoverState` keeps every existing field and adds `remoteAvailable`, `retained`, `target`
990
+ (`{ "kind": "host" }` or `{ "kind": "remote", "url", "owner", "repo", "checkout" }`), and
991
+ `cloning` (`{ "url" }` or `null`). `getDiscover` answers with the same snapshot, and a connecting
992
+ client still receives it before its own command replies.
993
+
994
+ While `remoteAvailable` is true the Overview renders a target strip as its first region, above the
995
+ project header and the sheet or the empty state: a retained-project dropdown
996
+ (`data-testid="discover-remote-select"`) whose options are the retained URLs, newest first, with the
997
+ active project selected and no host option; one row per retained project with a remove control
998
+ (`data-testid="discover-remote-remove"`, carrying the URL in `data-url`); an input
999
+ (`data-testid="discover-remote-url"`) labelled for a GitHub repository URL with the placeholder
1000
+ `https://github.com/owner/repo`; a submit control (`data-testid="discover-remote-start"`) labelled
1001
+ `Discover from URL`; a banner (`data-testid="discover-remote-target"`) naming the active URL with a
1002
+ `Manual testing` marker; and a clear control (`data-testid="discover-remote-clear"`) only while the
1003
+ target is remote. The strip is secondary in weight yet first in reading order, so an operator sees
1004
+ the active target before anything it affects. A source checkout with nothing published still learns
1005
+ the gate: the server replays one `discoverState` on connect when `remoteAvailable` is true, even
1006
+ with no artifacts and no run. The submit control is disabled while the socket is closed, `cloning`
1007
+ is non-null, a run is active, or `discoverState.busy` is set, and its `title` names the reason; it
1008
+ is also disabled for empty or whitespace-only input. Submitting sends exactly one frame with the
1009
+ trimmed URL and starts nothing else; selecting an option sends exactly one `switchDiscoverRemote`
1010
+ and starts nothing else; removing sends exactly one `removeDiscoverRemote`; clearing sends exactly
1011
+ one `clearDiscoverRemote`. Rendering the strip on load, reconnect, mode switch, or page switch sends
1012
+ no frame. While cloning, the strip shows the cloning URL and the activity area shows no run; errors
1013
+ reach the Discover error and notification surfaces only.
1014
+
1015
+ `server/discover-remote.test.ts` proves retention after a successful crawl with the checkout
1016
+ removed, restore and rendering without a clone, newest-first ordering and one entry per lowercased
1017
+ `owner/repo`, switching with zero clone invocations across a restart, one clone and one crawl on
1018
+ re-discovery, unchanged bytes after a failed clone or crawl, clear keeping every project, removal
1019
+ of exactly one directory with a restart, busy refusals, the missing-retained-directory fallback, the
1020
+ gate refusing every remote command without touching the retained root, a byte-identical host
1021
+ `.omp`, and startup hygiene that keeps a live run's checkout when a second dashboard starts on the
1022
+ same state directory. `verify:interface` drives the built UI through a retained render after
1023
+ restart, the dropdown's two entries and selection, removal of one entry, and the gate-off host with
1024
+ no remote control.
1025
+
1026
+ Operator smoke procedure, which needs network and a model endpoint and is therefore not a pass gate:
1027
+
1028
+ ```bash
1029
+ cd .devboard
1030
+ ADRS_DIR=../adrs REPO_DIR=.. bun run start
1031
+ ```
1032
+
1033
+ Open Discover, enter `https://github.com/octocat/Hello-World`, and confirm the cloning state, a
1034
+ completed crawl, the checkout gone from `.devboard/.tmp/discover-remote/`, the retained project at
1035
+ `.devboard/.tmp/discover-remote/octocat-hello-world/` with its `.omp/discover`, `.omp/skills`, and
1036
+ `project.json`, its sheet and glossaries, and a skill save landing under the retained `.omp/skills/`.
1037
+ Restart the server, reload, and confirm the same project is active with its artifacts and no
1038
+ missing-clone error. Discover a second repository, switch between the two from the dropdown, and
1039
+ confirm no clone appears. Remove one entry and confirm only its directory disappears. Hash `.omp`
1040
+ from the repository root before and after; the two hashes match.
1041
+
1042
+ ## Publish a change
1043
+
1044
+ Merging into `main` and pushing to the canonical Gitea remote triggers
1045
+ `.gitea/workflows/devboard-release.yml`, which runs on the repository's registered Gitea
1046
+ `act_runner` — never a hosted runner, and never a fallback — and builds the client, runs the
1047
+ harness suite and typecheck, packs the complete public package, publishes
1048
+ `@dsdenes/devboard@<version>` to npmjs, and creates the Gitea release `v<version>` with notes and
1049
+ the same tarball plus its `.sha256` as a fallback. The version is `0.1.<commit-count>` over the
1050
+ full history; a rerun on the same commit leaves the existing npm version and release assets
1051
+ untouched. Nothing is published under `@dsdenes/devboard-harness`.
1052
+
1053
+ After the push, verify from the integration checkout:
1054
+
1055
+ ```bash
1056
+ cd .devboard
1057
+ bun run publish
1058
+ ```
1059
+
1060
+ `bun run publish` refuses a worktree, a branch other than `main`, or a checkout ahead of or
1061
+ diverged from its canonical remote; fetches and fast-forwards `main` without discarding
1062
+ uncommitted work; installs dependencies and rebuilds `dist/`; waits for the release workflow of
1063
+ `HEAD`; verifies the Gitea release assets and `npm view @dsdenes/devboard@<version> version`;
1064
+ pushes the `github` mirror remote and proves its `main` names the published commit; and reports
1065
+ every running dashboard's running and installed version. It never restarts a dashboard and never
1066
+ writes into a host. `--wait-timeout N` bounds the workflow and propagation wait (default 1800
1067
+ seconds).
1068
+
1069
+ Installed dashboards update themselves: each checks the newest npm version at startup and every
1070
+ 60,000 ms (`DEVBOARD_UPDATE_INTERVAL_MS` overrides it), installs the npm artifact through a
1071
+ staging prefix, and keeps serving its loaded code. If npm cannot serve the artifact and
1072
+ `DEVBOARD_GITEA_URL` is configured, the same version's Gitea tarball is verified against its
1073
+ `.sha256` and installed instead. The npm child always runs in
1074
+ that staging directory, and the dashboard starts from the host root rather than the replaceable
1075
+ package directory, so replacing the package never leaves a child with a deleted working
1076
+ directory. `versionState` reports `running`, `installed`, `latest`, and `stale`; the reload
1077
+ control appears while `stale` is true and restarts the server from the installed package. A
1078
+ dashboard serving the canonical source checkout reports `0.1.<commit-count>` on both sides, the
1079
+ version its `main` commit releases as, rather than the checked-in `0.1.0` placeholder.
1080
+
1081
+ A check that cannot use the feed — an offline host, an unreachable Gitea API, a DNS or proxy
1082
+ failure, a refused or reset connection, a timeout, or an answer that is not a readable release
1083
+ description (a proxy page, a truncated body, an unexpected payload) — leaves the previous package
1084
+ and server intact, writes an `activity` record naming the skipped check, and retries with
1085
+ exponential backoff (`DEVBOARD_UPDATE_BACKOFF_MAX_MS` caps it); it is context for the operator,
1086
+ never an `update-error` finding. A check that reaches the feed and fails, a release without a
1087
+ tag, a checksum mismatch, an authentication failure, or a failed npm install writes `update-error`
1088
+ and is retried the same way.
1089
+
1090
+ |Variable|Default|Purpose|
1091
+ |---|---|---|
1092
+ |`HOST`|`127.0.0.1`|Listen address.|
1093
+ |`PORT`|`8080`|Listen port.|
1094
+ |`ADRS_DIR`|`/repo/adrs`|Directory holding the ADR files.|
1095
+ |`REPO_DIR`|parent of `ADRS_DIR`|Agent working directory and default goal location.|
1096
+ |`GOAL_FILE`|`<REPO_DIR>/GOAL.md`|Goal document read on load and replaced on save.|
1097
+ |`STATIC_DIR`|`dist`|Built client directory.|
1098
+ |`AGENT_COMMAND`|`omp acp`|ACP peer command, split on whitespace. `server/fixtures/stub-agent.ts` is a deterministic substitute for tests and local verification.|
1099
+ |`STATE_DIR`|`<harness>/.tmp`|State and journal directory; an installed dashboard passes `<host>/.devboard/.tmp`.|
1100
+ |`DEVBOARD_UPDATE_INTERVAL_MS`|`60000`|Automatic update check interval.|
1101
+ |`DEVBOARD_UPDATE_BACKOFF_MAX_MS`|`3600000`|Ceiling for the exponential retry delay after a failed check or install.|
1102
+ |`DEVBOARD_RELEASE_DIR`|unset|Local release directory for offline verification; unset reads the newest Gitea release over the public HTTP API.|
1103
+ |`DEVBOARD_GITEA_URL` / `GITEA_URL`|unset|Gitea HTTP origin; an HTTP(S) `origin` remote supplies it when unset.|
1104
+ |`GITEA_TOKEN`|unset|Gitea credential for issue, PR, scout, and watchdog writes; public reads need none.|
1105
+
1106
+ ## Self-hosted runner
1107
+
1108
+ The release workflow's only job runs on an `act_runner` registered to one machine and selected by
1109
+ the `host` label the runner advertises. A machine that lacks the label never picks up the job, and
1110
+ the job never falls back to a hosted runner. The registration is a one-time operator procedure
1111
+ against the canonical Gitea instance at `http://localhost:3001`; substitute the repository
1112
+ (`<owner>/<repo>`), the dedicated account (`<user>`), its home directory (`<home>`), and the
1113
+ runner name (`<runner-name>`) everywhere they appear.
1114
+
1115
+ The account is separate from every human account and holds no group that grants privilege — not
1116
+ `sudo`, `docker`, or `adm` — because the runner executes repository code and every secret the
1117
+ workflow is given.
1118
+
1119
+ ```bash
1120
+ sudo useradd --create-home --shell /bin/bash --user-group <user>
1121
+ sudo passwd --lock <user>
1122
+ sudo install -d -m 700 -o <user> -g <user> <home>
1123
+ sudo install -d -m 755 -o <user> -g <user> <home>/actions-runner
1124
+ ```
1125
+
1126
+ Download the official runner release and unpack it into that directory as `<user>`. The machine
1127
+ needs `git`, `curl`, and `tar`; everything else the workflow uses is installed by its `uses:`
1128
+ steps into the runner's own tool cache.
1129
+
1130
+ ```bash
1131
+ VERSION=$(gh api repos/actions/runner/releases/latest --jq '.tag_name | ltrimstr("v")')
1132
+ sudo -u <user> curl -fsSL -o <home>/actions-runner/runner.tar.gz \
1133
+ "https://github.com/actions/runner/releases/download/v$VERSION/actions-runner-linux-x64-$VERSION.tar.gz"
1134
+ sudo -u <user> tar -xzf <home>/actions-runner/runner.tar.gz -C <home>/actions-runner
1135
+ sudo -u <user> rm <home>/actions-runner/runner.tar.gz
1136
+ ```
1137
+
1138
+ Register with a registration token minted for the repository. The token is valid for one hour;
1139
+ never write it into a file, a commit, or a log.
1140
+
1141
+ ```bash
1142
+ TOKEN=$(gh api -X POST repos/<owner>/<repo>/actions/runners/registration-token --jq .token)
1143
+ sudo -u <user> -- bash -c "cd <home>/actions-runner && ./config.sh \
1144
+ --url https://github.com/<owner>/<repo> --token $TOKEN \
1145
+ --name <runner-name> --labels devboard --work _work --unattended"
1146
+ ```
1147
+
1148
+ `--labels devboard` adds to the automatic labels rather than replacing them, `--work _work` keeps
1149
+ checkouts under `<home>/actions-runner/_work`, and `--unattended` keeps the setup non-interactive.
1150
+ Install the runner as a system service owned by `<user>`, so it starts at boot and restarts after
1151
+ a failure without an operator session:
1152
+
1153
+ ```bash
1154
+ sudo bash -c "cd <home>/actions-runner && ./svc.sh install <user>"
1155
+ sudo systemctl enable --now actions.runner.<owner>-<repo>.<runner-name>.service
1156
+ systemctl status actions.runner.<owner>-<repo>.<runner-name>.service
1157
+ ```
1158
+
1159
+ Verify the registration from any machine whose `gh` is authenticated against the repository; the
1160
+ runner must be `online`, idle, and carry exactly the four labels the job asks for:
1161
+
1162
+ ```bash
1163
+ gh api repos/<owner>/<repo>/actions/runners \
1164
+ --jq '.runners[] | {name, status, busy, labels: [.labels[].name]}'
1165
+ ```
1166
+
1167
+ Because the service restarts on failure and at boot, `systemctl is-active` and `systemctl
1168
+ is-enabled` are the ongoing health check; a restart of the unit must return `status` to `online`
1169
+ within two minutes. The job's own checkouts stay in `<home>/actions-runner/_work/<repo>/<repo>`
1170
+ and are readable evidence that a run executed here.
1171
+
1172
+ To remove the runner, stop the service before deregistering, then delete the account:
1173
+
1174
+ ```bash
1175
+ sudo bash -c "cd <home>/actions-runner && ./svc.sh uninstall"
1176
+ TOKEN=$(gh api -X POST repos/<owner>/<repo>/actions/runners/remove-token --jq .token)
1177
+ sudo -u <user> -- bash -c "cd <home>/actions-runner && ./config.sh remove --token $TOKEN"
1178
+ sudo userdel --remove <user>
1179
+ ```
1180
+
1181
+ ## Harness watchdog
1182
+
1183
+ A machine-level systemd user timer runs the watchdog independently of every dashboard: at
1184
+ minutes 0, 10, 20, 30, 40, and 50 of every hour (`OnCalendar=*:0/10`, `Persistent=true`), the
1185
+ `devboard-watchdog.service` oneshot runs `bun run watchdog -- --once` from the canonical harness.
1186
+ It survives logout (`Linger=yes`) and never starts, stops, or restarts a dashboard itself.
1187
+
1188
+ Each cycle detects and reports; it never fixes. It reads the ten minutes before cycle start, both
1189
+ ends inclusive, from:
1190
+
1191
+ - every live dashboard's JSONL journal, `<STATE_DIR>/journal/<24-hex>.jsonl`, whose key is the
1192
+ first 24 hex characters of `sha256(REPO_DIR)`. `STATE_DIR` is the dashboard's environment
1193
+ value, `<host>/.devboard/.tmp` for an installed host. Each record carries `ts` (UTC ISO
1194
+ 8601), `repo`, `port`, `pid`, `kind`, and `text`; kinds are `activity`, `question`,
1195
+ `run-start`, `run-stop`, `lane-start`, `lane-stop`, `lifecycle`, `acp-stderr`,
1196
+ `server-error`, `update`, and `update-error`. A `question` record also carries `scope`, the
1197
+ run, lane, or peer that asked, so questions from separate work streams never accumulate into
1198
+ one finding. A live journal rotates at 8 MiB instead of truncating.
1199
+ - omp's own warnings and errors under `~/.omp/logs/omp.*.log`, attributed to an instance only
1200
+ when that pid descends from the instance's server process. A shared working directory never
1201
+ attributes a line, so an operator's own omp session in the project directory is not dashboard
1202
+ evidence.
1203
+
1204
+ Retention is bounded at cycle start. The journal keeps its own rule: journal files older than
1205
+ seven days are deleted, and the total under `<STATE_DIR>/journal` is capped at 1 GiB by deleting
1206
+ the oldest complete files first. A file still holding records inside the live window always wins
1207
+ over that bound, so the current cycle never loses evidence. The target is each install's real
1208
+ state directory — `<host>/.devboard/.tmp/journal` for an installed host, never the serving package
1209
+ directory under `node_modules`.
1210
+
1211
+ ### State-directory cleanup
1212
+
1213
+ Every cycle also sweeps the rest of each covered state directory. Covered directories are the
1214
+ canonical checkout's `.devboard/.tmp`, every `*/.devboard/.tmp` directly under the canonical
1215
+ root's parent, and the `STATE_DIR` of every live dashboard the cycle discovers, de-duplicated by
1216
+ real path. A symlinked `.tmp` is never followed, and a missing directory is skipped.
1217
+
1218
+ An entry is deleted when its last-modified time is older than seven days: files and symlinks
1219
+ individually, and a directory only when it is empty after that deletion and its own mtime, read
1220
+ before the sweep, is older than the cutoff. A fresh file inside an old directory survives and
1221
+ keeps its ancestors. Old watchdog run directories, legacy orchestrator run files and
1222
+ `orchestrator.log`, legacy `devboard-*.{log,state,lock}` files, verification scratch, and stray
1223
+ stale files are therefore removed after a week. These entries are never deleted at any age:
1224
+
1225
+ - `.tmp/state/` and everything below it.
1226
+ - `.tmp/journal/`, which the journal rule above governs.
1227
+ - `.tmp/watchdog/state.json`, `.tmp/watchdog/latest.json`, and `.tmp/watchdog/lock`.
1228
+ - `.tmp/orchestrator/state.json`, `.tmp/orchestrator/ledger.json`, and `.tmp/orchestrator/lock`,
1229
+ which the first Issues Start still reads to migrate handled issue numbers.
1230
+ - `.tmp/issues/` in full: every repo-scoped state, ledger, run, activity journal, control file,
1231
+ and the repo-anchored worker lock, including the run directory a running worker names.
1232
+ - `.tmp/flow/` in full: every repository key and session record, including the machine state,
1233
+ reviewable artifacts, and worktree/PR bookkeeping Flow restore and History read.
1234
+ - the run directory of the cycle performing the sweep.
1235
+ - a `devboard-<pid>.lock` whose pid is still alive.
1236
+
1237
+ Run it by hand from `.devboard`:
1238
+
1239
+ ```bash
1240
+ bun run cleanup -- --dry-run --root <root> # list every candidate path and byte size
1241
+ bun run cleanup -- --once --root <root> # delete and print one summary per directory
1242
+ ```
1243
+
1244
+ `--dry-run` is also the default when neither flag is given, and it writes nothing. The watchdog
1245
+ cycle performs the same sweep and appends one `cleanup <state-dir>: removed N file(s), ...` line
1246
+ per covered directory to `coordinator.log`, including directories with zero removals. The sweep
1247
+ deletes outside the canonical checkout only inside the `.devboard/.tmp` directories it covers;
1248
+ it creates nothing outside the checkout.
1249
+
1250
+ Discovery scans `/proc` for every process running `server/index.ts` that supplies `REPO_DIR` and
1251
+ `PORT`, de-duplicates by `(REPO_DIR, PORT)`, resolves the serving harness from `STATIC_DIR` or
1252
+ the process working directory, and reads the journal from the process's `STATE_DIR`. Instances
1253
+ from installed hosts and the canonical source checkout are covered alike; an instance whose
1254
+ state directory has no journal is reported `uninstrumented`.
1255
+
1256
+ Findings derive only from machine-checkable evidence: a `server-error`, `update-error`, or
1257
+ `acp-stderr` journal record; a `lane-stop` whose status token is `failed`; an omp log record at
1258
+ `error`, `warn`, or `fatal`; a `run-start` or `lane-start` at least five minutes before window end
1259
+ with no `run-stop` or `lane-stop` at or after it for the same instance (a stall); and three or
1260
+ more `question` records for one instance's run, lane, or peer in one window (a question loop,
1261
+ quoting the last).
1262
+ `activity`, `lifecycle`, `update`, ordinary run and lane text, and questions below the loop
1263
+ threshold never produce a finding, whatever words they contain.
1264
+
1265
+ A fingerprint is the first 32 hex characters of `sha256(category + "\n" + normalized line)`. The
1266
+ normalized line drops a leading ISO timestamp and collapses whitespace, and the source path is
1267
+ excluded, so the same error on two dashboards is one fingerprint and one issue.
1268
+
1269
+ Each cycle files one issue per new fingerprint through the operator's authenticated `gh`:
1270
+ `gh issue create --repo dsdenes/devboard --title "[watchdog] <category>: <line>" --body-file -`.
1271
+ The title is one line of at most 80 characters. The body carries the category, fingerprint,
1272
+ quote, first and last sighting, count, window, every contributing instance (repository, port,
1273
+ pid, journal path), and the cycle report path. There is no per-cycle cap. A failing `gh` call
1274
+ reports `failed` with its first stderr line and leaves the fingerprint in `pendingFiling`; the
1275
+ next cycle retries pending fingerprints before new ones, so a transient failure never loses
1276
+ evidence. A fingerprint already `filed:<url>` is never filed again: when it recurs at least 24
1277
+ hours after the last comment (or after the filing when no comment exists), the cycle comments
1278
+ once on the existing issue with the new window, sighting, and count. No cycle closes an issue.
1279
+
1280
+ Each cycle writes one run directory, `<harness>/.tmp/watchdog/runs/<UTC timestamp>/`, containing
1281
+ `report.json`, `report.md`, and `coordinator.log`; the newest report is also
1282
+ `.tmp/watchdog/latest.json`. A version 2 ledger at `.tmp/watchdog/state.json` records each
1283
+ finding's first and last sighting, count, contributing instances, and `filed:<url>`; an unchanged
1284
+ window reports `action: "duplicate"` and makes no `gh` call. A version 1 ledger's prose
1285
+ `blocked:*` and `published:<sha>` dispositions are discarded on load, never migrated into issues.
1286
+
1287
+ A singleton lock at `.tmp/watchdog/lock` serializes every invocation: a second manual run or a
1288
+ `systemctl --user start devboard-watchdog.service` while a cycle is live prints `already running`
1289
+ and exits without filing. A stale lock whose pid is gone is reclaimed.
1290
+
1291
+ The cycle never spawns an agent, creates a worktree, commits, merges, pushes, or publishes; it
1292
+ leaves `HEAD`, `origin/main`, the worktree list, and the working tree exactly as it found them.
1293
+
1294
+ ### The Watchdog page
1295
+
1296
+ The Issues sidebar lists `Watchdog` (`open-issues-watchdog`) directly after Auto Scout. Activating
1297
+ it opens the full workspace page `watchdog-page`, titled `Watchdog`, in place of the Overview tab;
1298
+ it stays the Issues mode's active tab until another Issues page is opened. The page reads only
1299
+ `GET /api/watchdog` and sends no command that files, comments, closes, starts, stops, or fixes
1300
+ anything.
1301
+
1302
+ The page is one framed header, one full-width `watchdog-last-cycle` summary band, and two panels
1303
+ under it. The summary leads with the action badge and the reason, then states the exact start, the
1304
+ findings, and the pending filings as one compact facts row. The filed-issue table takes the wider
1305
+ column on a viewport at least 1501px wide; below that the two panels stack as flexible rows. Below
1306
+ 1100px the issue `Category` column is hidden before any value is clipped (the category repeats
1307
+ inside the generated title); it stays in the DOM. Both panels own their scrolling, so the 100-row
1308
+ history never pushes the filed issues below the page.
1309
+
1310
+ `watchdog-last-cycle` is the newest cycle from `latest.json`: `watchdog-last-cycle-at` (a `<time>`,
1311
+ or `never` before the first cycle), `watchdog-last-action`, `watchdog-last-findings`,
1312
+ `watchdog-last-pending`, and `watchdog-last-reason` only when the report carries a reason. The
1313
+ panel's `watchdog-last-age` shows that cycle's age.
1314
+
1315
+ `watchdog-cycles` lists the retained run directories under `<STATE_DIR>/watchdog/runs`, newest
1316
+ first, capped at the newest 100 directories. Its table labels the three columns `Started`, `Action`,
1317
+ and `Issues`, and its header sticks while the panel scrolls. Every `watchdog-cycle` row carries
1318
+ `data-action` and renders `watchdog-cycle-at`, `watchdog-cycle-action`, and, only when the cycle's
1319
+ findings map to a filed issue, a `watchdog-cycle-issues` group of `watchdog-cycle-issue` anchors to
1320
+ those GitHub issues. Each anchor reads `#<number>` and opens in a new tab; the row repeats no issue
1321
+ and keeps the finding order. A cycle row renders no window interval, finding count, or reason. A
1322
+ run directory whose `report.json` is missing or unparsable is skipped without hiding the other
1323
+ rows. With no run directory at all, the `latest.json` cycle is the single row; with no cycle
1324
+ anywhere, `watchdog-cycles-empty` renders `No cycles yet` and the ten-minute cadence.
1325
+
1326
+ `watchdog-issues` lists every ledger entry whose disposition is `filed:<url>`, newest `filedAt`
1327
+ first and ties ordered by URL. Each `watchdog-issue` row carries `data-url`, `data-state`, and
1328
+ `data-version` and renders `watchdog-issue-title` (an anchor to the issue reading
1329
+ `[watchdog] <category>: <line>`), `watchdog-issue-category`, `watchdog-issue-filed` (a `<time>`),
1330
+ `watchdog-issue-state`, and `watchdog-issue-version` (the tag, or `—`). A pending-only fingerprint
1331
+ is counted in `watchdog-last-pending` and never becomes a row; `watchdog-issues-empty` renders
1332
+ when nothing is filed. `watchdog-issues-count` summarizes the rows and pending filings.
1333
+
1334
+ The server prints `watchdog-issue-state` as `pending` for an open issue and `implemented` for a
1335
+ closed one, whatever its `stateReason`; a reopened issue returns to `pending` within one refresh
1336
+ interval. State is read from the repository in each ledger URL. For an implemented issue the
1337
+ server resolves the closing commit from the issue timeline, or the last 7–40 character hex SHA in
1338
+ the newest comment carrying one, resolved to one full commit. The version is the earliest
1339
+ published release of that issue's repository whose history contains the commit, ordered by
1340
+ `publishedAt` and breaking a tie by tag, or `—` when no commit or release resolves. A blank
1341
+ version is recomputed on every read, so a release published later fills it.
1342
+
1343
+ The server refreshes its GitHub projection at most once per 60 seconds. Any `GET /api/watchdog`
1344
+ request triggers a due refresh in the background and is answered from the last projection without
1345
+ waiting for `gh`; the refresh result appears on a later request. A failed read keeps every last
1346
+ known state and version and the whole table, and sets `watchdog-sync` to `data-state="error"` with
1347
+ the reason in `watchdog-sync-error`. A success sets `data-state="ok"`, clears the reason, and
1348
+ updates `watchdog-sync-at`; a success older than 120 seconds with no later failure reports
1349
+ `stale`. `watchdog-sync-at` reads `never` before the first success. The page polls every 15
1350
+ seconds, so a newly written run directory or filed ledger entry appears without a reload.
1351
+
1352
+ `GET /api/watchdog` is GET-only and leaves `<STATE_DIR>/watchdog/` byte-identical: no run
1353
+ directory, ledger write, or lock. Its existing fields keep their meaning: `cycle` (ISO 8601 or
1354
+ null), `action`, the optional `reason`, the `findings` count, `issues`
1355
+ (`{url, category, line, filedAt, state, version}`) newest first, and the `pending` count; it adds
1356
+ `cycles` (each `{at, window, action, findings, issues, reason?}`, where `issues` lists the
1357
+ connected GitHub issue URLs) and `sync`. A dashboard-endpoint failure renders `watchdog-error`.
1358
+
1359
+ The status bar's watchdog indicator is unchanged: `status-watchdog` shows the last action and its
1360
+ age; activating it opens `watchdog-popover` listing up to ten filed issues, each link opening in a
1361
+ new tab, and Escape closes it.
1362
+
1363
+ Operate it from the canonical `.devboard`:
1364
+
1365
+ ```bash
1366
+ bun run watchdog:install # write the two units, enable and start the timer
1367
+ bun run watchdog -- --once # run one cycle now
1368
+ bun run watchdog -- --collect --dry-run # print this cycle's report; change nothing
1369
+ bun run watchdog:uninstall # stop, disable, and remove the units
1370
+ ```
1371
+
1372
+ `watchdog:install` refuses a worktree, verifies `Linger=yes` before writing anything, discovers
1373
+ the absolute Bun executable plus the `omp` and `git` directories for the unit's `PATH`, and is
1374
+ idempotent. `watchdog:uninstall` changes no repository file. To pause the watchdog without
1375
+ uninstalling, `systemctl --user stop devboard-watchdog.timer`; `systemctl --user start
1376
+ devboard-watchdog.timer` resumes it. `bun run verify:watchdog` builds the client, seeds run
1377
+ directories, a ledger, and a stub `gh`, and proves the Watchdog page's empty states, cycle history,
1378
+ state and version columns, sync badge and failure text, 60-second refresh, freshness, and the
1379
+ unchanged footer indicator, popover, issue links, and Escape. `bun run verify:watchdog-live`
1380
+ starts a seeded dashboard, waits for the installed timer to file a real issue, and closes it as not
1381
+ planned.
1382
+
1383
+ ## Issues mode
1384
+
1385
+ Issues is the fourth mode in the activity rail, below Goal. Each dashboard controls exactly one
1386
+ repository: its own `REPO_DIR`. Starting Issues in one checkout's dashboard polls that checkout's
1387
+ origin open issues and starts one ACP agent for every open, unhandled issue in that same poll. Each
1388
+ issue without the exact `goal` label receives exactly `/implement-issue <number>`; each
1389
+ `goal`-labeled issue receives exactly `/implement-goal-issue <number>`. There is no concurrency
1390
+ cap: ten ready issues start ten peers, and no peer waits for another to finish. A detached worker
1391
+ owns the poll loop, the peers, the ledger, the lock, and the activity for one repository, so it
1392
+ survives a dashboard restart and two repositories never share a worker. No systemd unit, timer, or
1393
+ machine-level scheduler is involved, and neither starting nor restarting a dashboard starts a
1394
+ worker.
1395
+
1396
+ Start first proves the facts it needs: a canonical checkout root that is not a linked worktree,
1397
+ a GitHub `origin`, `omp` and `gh` on `PATH`, the `/implement-issue` command installed for the
1398
+ operator's omp, and the `/implement-goal-issue` extension installed beside it. Commands are
1399
+ discovered in `<REPO_DIR>/.omp/commands/`, `<REPO_DIR>/.omp/agent/commands/`,
1400
+ `$HOME/.omp/commands/`, and `$HOME/.omp/agent/commands/`; the extension in
1401
+ `<REPO_DIR>/.omp/extensions/` or `$HOME/.omp/extensions/`. A refusal names the missing file, every
1402
+ path searched, and `.devboard/install.sh` as the remedy, spawns no peer, and writes no run. Start
1403
+ also requires `gh api repos/<owner>/<repo> --jq .permissions.push` to return `true`. A live legacy
1404
+ `devboard-orchestrator` lock is refused too; the harness never stops that process. Issues mode no
1405
+ longer requires `/implement-all-issues`; that command stays usable by hand outside the mode.
1406
+
1407
+ The worker is spawned detached in its own session from the harness package the server loaded,
1408
+ with `REPO_DIR` as its working directory. It holds `<REPO_DIR>/.devboard/.tmp/issues/lock`, which
1409
+ records its pid, argv, state directory, and harness version. A lock whose owner is gone is
1410
+ reclaimed before a new worker starts; a lock with a live owner is never taken over. A second
1411
+ dashboard with the same `REPO_DIR` and `STATE_DIR` attaches to that worker. A second dashboard
1412
+ with the same `REPO_DIR` and another `STATE_DIR` cannot attach and is refused, because the
1413
+ repo-anchored lock names the live worker. Different repositories get independent workers.
1414
+
1415
+ The poll is the GitHub issues endpoint through the operator's `gh` credential every 10 seconds,
1416
+ conditional on the previous `ETag` (`If-None-Match`). A `304` is no change; `403`/`429` honor
1417
+ `Retry-After` or `X-RateLimit-Reset`. Pull requests returned by the endpoint are excluded, and
1418
+ every issue's labels are part of the poll result. Every open issue in the queue carries `handled`,
1419
+ the run id that last handled it, and `agentRunId` while an agent works it. An issue that already
1420
+ has a running agent never receives a second peer, so a poll while four peers run starts none of
1421
+ them again. An issue first seen while three peers run gets its own peer on the next poll without
1422
+ pausing, restarting, or shortening the others. A handled issue is never retriggered while it stays
1423
+ open; a closed issue leaves the ledger, so a reopened one is eligible again. A poll with no
1424
+ unhandled open issue starts no peer and publishes `idle`.
1425
+
1426
+ Session updates stream into the mode, permission requests are auto-approved by the ACP client,
1427
+ elicitation questions are answered per agent, and every peer closes when its run ends or Stop is
1428
+ clicked. The agents own every worktree, commit, push, issue closure, and repository mutation; the
1429
+ worker passes no scope beyond the issue number and never edits code itself.
1430
+
1431
+ ### The `/implement-issue` contract
1432
+
1433
+ `.devboard/prompts/implement-issue.md` holds the whole contract and
1434
+ `.devboard/omp/commands/implement-issue.md` is its command wrapper: it carries a `description` and
1435
+ an `argument-hint`, reads the prompt, and passes `$ARGUMENTS` as the issue number. No other
1436
+ harness file duplicates the contract.
1437
+
1438
+ The prompt requires the argument to be exactly one positive integer issue number and refuses a
1439
+ missing, non-numeric, or multi-token argument before changing anything. It fetches exactly that
1440
+ one issue and never fetches, surveys, or builds a dependency graph for the rest of the backlog.
1441
+ It creates and works in one worktree under `<REPO_DIR>/.worktree/` named for the issue, never in
1442
+ the canonical checkout and never in another agent's worktree, and writes nothing outside
1443
+ `<REPO_DIR>`. It changes only that issue's implementation, tests, and documentation; it may read
1444
+ other issues and code but never edits, comments on, closes, or reopens another issue. When the
1445
+ issue declares an open prerequisite (`depends on`, `blocked by`, or a task-list issue reference),
1446
+ it writes `blocked: #<prerequisite>` as the first stderr line, exits nonzero, leaves the issue open
1447
+ and unhandled, and never implements the prerequisite in another agent's place. It is
1448
+ non-interactive: no `question` tool, no confirmation, no interactive editor, no login prompt. It
1449
+ fetches the canonical integration line, integrates concurrent changes, resolves every conflict
1450
+ itself, reruns every check the integration invalidates, and publishes directly to that line with
1451
+ bounded retry when a push is rejected; it never creates a pull request or merge request and never
1452
+ force-pushes. It closes the issue only after verifying the pushed revision is on the canonical
1453
+ line and the required checks pass, and comments on that issue with the revision and the
1454
+ verification evidence. A proven duplicate, invalid, or already-fixed issue is closed with evidence
1455
+ and without committing unrelated work. A successful agent removes only the worktree and branch it
1456
+ created; a failed or blocked agent leaves its worktree in place and reports its path.
1457
+
1458
+ The `goal` label keeps its goal-mode implementation contract: every goal-labeled issue runs
1459
+ `/implement-goal-issue <number>` concurrently with the rest.
1460
+
1461
+ ### Per-agent state, lifecycle, and history
1462
+
1463
+ Each agent has its own run directory, `run.json`, `activity.json`, first-stderr line, question,
1464
+ ACP child record, and retry gate keyed by issue number. No agent shares a run record or question
1465
+ slot with another. The published state carries `agents`, one entry per running issue with `runId`,
1466
+ `issueNumber`, `startedAt`, `question`, and `acp`; the queue carries each issue's `handled`, last
1467
+ handling `runId`, and `agentRunId`; and `cooldowns` maps an issue number to its ISO 8601 retry
1468
+ time. A failed agent finishes its run as `failed` with its first stderr line, leaves its issue
1469
+ open and unhandled, and writes only its own cooldown. A successful agent finishes its run as
1470
+ `succeeded`, records only its issue as handled, and does not touch another issue. The five-minute
1471
+ cooldown remains: an issue in cooldown is retried when it expires, and other issues keep running
1472
+ and start normally.
1473
+
1474
+ `Check now` clears every cooldown and polls immediately; while paused it writes a notice and polls
1475
+ nothing. Lifecycle is `running` while any peer runs, `pausing` while paused with any peer,
1476
+ `paused` when paused with none, `failed` when enabled and unpaused with no peer and an unhandled
1477
+ issue still cooling down, `idle` when enabled and unpaused with no peer and no unhandled issue,
1478
+ and `stopped` after Stop. Pause, Resume, Check now, and Stop apply to the whole fleet. Pause starts
1479
+ no peer and no poll and lets every peer finish. Stop closes every peer, records every active run as
1480
+ `stopped`, leaves every captured issue unhandled, writes no cooldown, releases the lock, and stops
1481
+ the ticker; no peer survives Stop. A fresh worker start terminates every ACP child the previous
1482
+ state recorded when `/proc` proves the process is that child, closes every interrupted run as
1483
+ `stopped`, clears `agents`, and starts no peer from stale state.
1484
+
1485
+ State, ledger, runs, and activity live under `<STATE_DIR>/issues/<journalKey(REPO_DIR)>/`:
1486
+ `state.json` is atomic, `ledger.json` records handled numbers with the run id,
1487
+ `runs/<runId>/run.json` and `runs/<runId>/activity.json` keep the newest 50 finished runs plus
1488
+ every currently running run, and `control/` carries dashboard commands as atomically renamed
1489
+ files. Each run's activity is persisted before the dashboard can read it, bounded at the first of
1490
+ 1,000 entries or 1 MiB with the trimmed marker, and is never merged with another run's activity.
1491
+ On connect, the bridge replays `issuesState` and then one `issuesActivity` with `replay: true` for
1492
+ every running agent, so a dashboard connecting mid-flight sees every agent and all its output. The
1493
+ state document version is 2; the parser still accepts version 1, loading it with empty `agents`,
1494
+ `cooldowns` populated from its `goalCooldowns`, and its queue, labels, ledger, and runs intact. A
1495
+ damaged state file is renamed aside and never prevents the mode from opening.
1496
+
1497
+ The worker records the harness version it loaded, its pid, and its argv; a mismatched dashboard
1498
+ shows a notice and never kills the worker, and the state records each ACP child's pid, argv, and
1499
+ cwd so Stop or a fresh Start can terminate a proven orphan. The first worker to find no repo-keyed
1500
+ ledger imports handled numbers from `<STATE_DIR>/orchestrator/state.json` and `ledger.json` when
1501
+ the legacy state parses and its `root` resolves to this `REPO_DIR`. A missing, damaged, or
1502
+ mismatched legacy file is ignored. The legacy files are otherwise left untouched.
1503
+
1504
+ An already-installed legacy unit is removed by hand, once:
7
1505
 
8
1506
  ```bash
9
- npx @dsdenes/devboard install # install or refresh the harness for this host
10
- npx @dsdenes/devboard update # install the newest release without restarting
11
- npx @dsdenes/devboard doctor # print one PASS or FAIL line per check
1507
+ systemctl --user disable --now devboard-orchestrator.service
1508
+ rm -f ~/.config/systemd/user/devboard-orchestrator.service
1509
+ rm -f ~/.config/systemd/user/default.target.wants/devboard-orchestrator.service
1510
+ systemctl --user daemon-reload
12
1511
  ```
13
1512
 
14
- `install` writes a real npm prefix at `<host>/.devboard`, replaces harness
15
- symlinks, copies the omp wrappers and `AGENTS.md` template, adds the ignored
16
- paths to `<host>/.gitignore`, and leaves exactly one dashboard running from the
17
- installed package. A reinstall keeps `.devboard/.tmp` and reuses a dashboard
18
- that is already current.
1513
+ ### The Agents surface
1514
+
1515
+ The Issues sidebar has no `Agents` entry and the Overview has no Open Agents control; the command
1516
+ palette lists `open.issues.agents` in the `Workspace` group with the title `Open Agents` and opens
1517
+ the tab from any mode.
1518
+
1519
+ The tab is `{ id: "agents", kind: "agents", resourceId: "agents", title: "Agents" }` and is
1520
+ restored after a reload. The page renders `data-testid="issues-agents-page"` with a page header
1521
+ `data-testid="issues-agents-header"` that carries the repository, the running count
1522
+ `data-testid="issues-agents-count"`, and, when any agent is blocked, a warning badge
1523
+ `data-testid="issues-agents-asking"` naming how many need an answer. With no active agent it
1524
+ renders `data-testid="issues-agents-empty"` and an `Open Overview` control
1525
+ `data-testid="issues-agents-overview"` instead of the grid.
1526
+
1527
+ The grid `data-testid="issues-agents"` holds one card per running agent, sorted by issue number
1528
+ ascending. Each card has `data-testid="issues-agent"`, `data-issue`, `data-run`, `data-status`, and
1529
+ `data-asked` while its agent waits for an answer; its head shows the issue number linked to its
1530
+ tracker URL and the `Focus` control, and it does not repeat the fleet's status or waiting state as
1531
+ a badge. Below it the issue title reserves two lines, then one compact elapsed and started-time
1532
+ line whose tooltip carries the exact timestamp. Cards in one grid row share a height. The run id
1533
+ appears in the Focus tab, not on the card. Every card renders its agent's full streamed output in
1534
+ `data-testid="issues-agent-activity"` with `data-run`, under the `Latest activity` label; the log
1535
+ scrolls, follows its newest line while the operator is at the bottom, and stays put when scrolled
1536
+ back. It is 64px on a waiting card and fills the remaining card body otherwise. A pending question
1537
+ renders inside only that agent's card as `data-testid="issues-agent-question"` with `data-run`, its
1538
+ options with a recommended marker, and a free-text answer. The controls are
1539
+ `data-testid="issues-agent-choice"`,
1540
+ `data-testid="issues-agent-answer-input"`, and `data-testid="issues-agent-answer-submit"`, each
1541
+ carrying `data-run`. Answering sends `{ type: "answerIssues", runId, id, value }`; only the agent
1542
+ with that run id receives the value, only its question clears, and a missing or unknown run id is
1543
+ refused with an error that reaches no peer. Two agents asking at once keep two independent
1544
+ questions.
1545
+
1546
+ Activating a card (its `Focus` control) opens
1547
+ `{ id: "agent:<runId>", kind: "agent", resourceId: <runId>, title: "#<issue> <title>" }`, rendering
1548
+ `data-testid="issues-agent-focus"` with `data-testid="issues-agent-focus-status"`, the same full
1549
+ activity in `data-testid="issues-agent-focus-activity"`, its question in
1550
+ `data-testid="issues-agent-focus-question"`, and `data-testid="issues-agent-focus-close"`. The
1551
+ output panel header names its run once: a `Run` label, the identifier as a `.code-chip` in
1552
+ `data-testid="issues-agent-focus-run-id"` (capped at 220px; the full id is in its `title`), and
1553
+ `data-testid="issues-agent-focus-run-started"` reading `Started <relative time>` with the ISO
1554
+ start in its `title`. The start time comes from the live agent and falls back to run history; a
1555
+ missing timestamp renders no age. The header carries no bare `panel__subtitle` run id and no
1556
+ second status badge. The focus tab survives a reload while the run is active. After the run ends,
1557
+ a restored focus tab shows that run's final status and activity from run history, or a clear
1558
+ no-longer-available state linking to Runs when the run has been pruned.
1559
+
1560
+ The Overview keeps the Start, Pause, Resume, Stop, and Check now controls, the poll and worker
1561
+ facts, and `data-testid="issues-overview"`. It shows the running-agent count as
1562
+ `data-testid="issues-running-agents"`, the pending count, the number of issues in cooldown and the
1563
+ earliest cooldown time as `data-testid="issues-cooldowns"`. The left column holds the Automation
1564
+ panel and, directly after it, the running agents in
1565
+ `data-testid="issues-agent-summary"`: one row per agent carrying `data-run` and `data-issue`,
1566
+ sorted by issue number, with `aria-current="true"` on
1567
+ the selected agent, the issue title, a `needs answer` badge while it asks, and its elapsed time.
1568
+ The row's issue number is a tracker link (`data-testid="issues-agent-row-issue"`, opening the
1569
+ issue URL from the queue in a new tab) next to a control that selects the agent's output.
1570
+ The running-agent panel header carries an aggregate `data-testid="issues-overview-asking"` warning
1571
+ badge (`<n> needs answer` or `<n> need answers`) only while at least one agent waits; the fleet
1572
+ count is not repeated in that header because the Automation panel states it.
1573
+ Each row also carries `data-testid="issues-close-issue"` with `data-issue`; it closes that issue on
1574
+ the tracker as `not planned`, so an issue the operator does not want implemented leaves the next
1575
+ poll without stopping the agent already working it. A refused or failed closure leaves the issue
1576
+ open and reports the reason as an error notification; a successful one reports
1577
+ `Closed issue #<number>.` and disables the control until the queue drops the issue.
1578
+ The right column renders only the selected agent's output in
1579
+ `data-testid="issues-overview-output"` with `data-run`, its stream in
1580
+ `data-testid="issues-overview-activity"`, and its question in
1581
+ `data-testid="issues-overview-question"`. The first agent is selected by default, the row's select
1582
+ control switches the output, and the pane falls back to the first live agent when the selected run
1583
+ ends; the overview list selects instead of opening a tab. The queue keeps every open issue, its
1584
+ `goal` badge, and its handled state, and shows `data-run` and a running marker for an issue with an
1585
+ active agent. The Runs page lists every run with its status and issue,
1586
+ and a run's detail shows only that run's activity. The status bar's issues indicator reports the
1587
+ lifecycle and the running-agent count in its title and still opens Issues mode.
1588
+
1589
+ ## Draft goals and proposed goals
1590
+
1591
+ The Issues sidebar carries a fourth entry after Runs: `Draft goals`, with `data-command="open.issues.drafts"`
1592
+ and `data-testid="open-issues-drafts"`. It trails the number of listed drafts, its accessible
1593
+ name names the Issues mode and the `Alt+2` shortcut, and the command palette lists it under the
1594
+ same command id. Opening it renders `data-testid="issues-drafts"`: every regular `.md` file
1595
+ directly under `<REPO_DIR>/.devboard/goal-proposals/`, sorted by filename, each with
1596
+ `data-testid="issues-draft"` and `data-path` holding the repository-relative path, its filename,
1597
+ and its first Markdown heading. An empty directory shows `No drafts exist.`.
1598
+
1599
+ Selecting one opens `data-testid="issues-draft-detail"`: the header carries the draft's file name,
1600
+ the state badge for this draft's run only (absent while the run is `idle`), `issues-draft-start`
1601
+ (`issues.draft.start`) and, while this draft runs, `issues-draft-stop` (`issues.draft.stop`). The
1602
+ body reads the proposal before the `## Captured UI context` marker, then the activity stream in
1603
+ `issues-draft-activity` once a run exists; the captured UI context itself is not shown. A pending
1604
+ elaboration question (`issues-draft-question`) renders above the proposal, with
1605
+ `issues-draft-choice`, `issues-draft-answer-input`, and `issues-draft-answer-submit`. The detail
1606
+ refreshes when the page opens, after the report panel writes a draft through
1607
+ `POST /api/goal-proposals`, after an elaboration finishes or stops, and when a listed draft
1608
+ disappears. The page polls the list every two seconds while it is mounted, so an external
1609
+ deletion is visible without a reload, and the report panel's write broadcast reaches an open page
1610
+ immediately. A browser reload restores the Draft goals page and the selected path; when the file
1611
+ no longer exists, the page opens the list.
1612
+
1613
+ The read path accepts only `.devboard/goal-proposals/<name>.md`: an absolute path, `..`, a nested
1614
+ path, a directory, a missing file, or a symlink whose real path leaves the drafts directory is
1615
+ refused with a visible error, and nothing outside the directory is read. The list never follows
1616
+ symlinks.
1617
+
1618
+ Only one elaboration runs at a time. Starting one spawns exactly one ACP peer with `AGENT_COMMAND`
1619
+ in `REPO_DIR`, opens one session, and sends exactly `/goal-draft <repository-relative path>`. Its
1620
+ activity streams into `issues-draft-activity`, its `ask` question renders as options plus a
1621
+ free-text answer, and an answer goes only to that peer. While it runs, every other draft's start
1622
+ control is disabled with the stated reason; Stop closes the peer, records `stopped`, keeps the
1623
+ draft, and re-enables the controls; a successful or failed run closes its peer before recording
1624
+ `succeeded` or `failed`, so no elaboration leaves an agent process behind; a failed peer keeps
1625
+ the draft, shows the error, and accepts a retry as a new peer. The running peer's pid, argv, and
1626
+ `cwd` are recorded in the server state, so a replacement server ends one its predecessor left
1627
+ running.
1628
+
1629
+ `.devboard/prompts/goal-draft.md` is the elaboration contract and
1630
+ `.devboard/omp/commands/goal-draft.md` its command wrapper, which reads the prompt and passes
1631
+ `$ARGUMENTS` as the draft path; `install.sh` installs the wrapper into the host's `.omp/commands/`.
1632
+ The prompt requires repository research before questions, then an `ask` interview with exactly one
1633
+ question per call, three mutually exclusive options with concrete descriptions and exactly one
1634
+ recommended, and forbids questions about effort, cost, time, token budget, or attempt caps. The
1635
+ finalized spec contains exactly `## Objective`, `## Success criteria`, `## Verification`,
1636
+ `## Boundaries`, and `## Stop conditions`, in that order, with binary success criteria and exact
1637
+ verification commands or actions.
1638
+
1639
+ The spec is written to `<REPO_DIR>/.devboard/goals/<slug>.md`: lowercase kebab-case, at most six
1640
+ words, and an existing filename is never overwritten (`-2`, `-3`, …). The elaboration agent
1641
+ creates no other file except that spec and the draft removal, starts no implementation, and does
1642
+ not enter goal mode. It files the spec's bytes verbatim, as a body file, as an open issue in the
1643
+ checkout's `origin` (never the harness repository unless that is the origin), with a one-line title
1644
+ bounded to 200 characters and the exact `goal` label, creating the label first when it is missing.
1645
+ Every tracker command goes through `.devboard/scripts/forge-auth.ts exec --required write`. After
1646
+ filing it reads the issue back with `gh issue view <number> --json number,url,labels,body`; only
1647
+ when the `goal` label is present and the body equals the spec file may it delete the draft. A
1648
+ failed label creation, issue creation, or verification leaves the draft in place and reports the
1649
+ failure and any created issue URL. A successful completion removes the draft from the list, keeps
1650
+ the spec under `.devboard/goals/`, and the next Issues poll lists the new open issue with its
1651
+ `goal` badge.
1652
+
1653
+ The Open issues list shows every open issue with its `goal` badge and its handled state. Every
1654
+ open, unhandled issue gets its own concurrent peer: non-goal issues are prompted with exactly
1655
+ `/implement-issue <number>`, goal issues with exactly `/implement-goal-issue <number>`. Each has its
1656
+ own run record, activity, and question slot. The snapshot publishes
1657
+ `agents: [{ runId, issueNumber, startedAt, question, acp }]`, so the Agents page, the Overview, and
1658
+ the Runs page address each agent by issue number and run id while several are active, and an
1659
+ answer carries that `runId` so it routes only to its peer.
1660
+
1661
+ `.devboard/extensions/implement-goal-issue.ts` implements the command and
1662
+ `.devboard/omp/extensions/implement-goal-issue.ts` is its host-side re-export copied by
1663
+ `install.sh`. It accepts one issue number and refuses a missing number, a missing issue, an issue
1664
+ without the exact `goal` label, or an existing goal in the session. It creates one worktree and
1665
+ branch under `.worktree/`, activates the `goal` tool, and calls the `goal` create operation with
1666
+ the fetched issue body verbatim as the objective: no distillation, no section filtering, and no
1667
+ appended requirements. It then implements the body, verifies every criterion through the product
1668
+ surface, merges into `main` with conflict integration and retry, pushes `main` to origin, removes
1669
+ the worktree and branch, and closes the issue only after the push, through the same forge-auth
1670
+ wrapper.
1671
+
1672
+ Pause lets every active peer finish, then reports `paused` and starts no new peer or poll.
1673
+ Resume polls immediately. Stop closes every peer, records every active run as `stopped`, leaves
1674
+ every issue unhandled, releases the lock, and writes no cooldown. A successful run records only
1675
+ its issue as handled in the ledger; a failed one records `failed` with its first stderr line and
1676
+ gates only that issue for five minutes, leaving it open and unhandled. An issue closed by its
1677
+ agent leaves the queue on the next poll.
1678
+
1679
+ ## Auto Scout
1680
+
1681
+ The Issues sidebar lists `Overview`, `Auto Scout`, `Issues`, `Runs`, and then `Draft goals`
1682
+ in that order. Auto Scout (`data-command="open.issues.scout"`, `data-testid="open-issues-scout"`,
1683
+ palette `Open Auto Scout`) renders `data-testid="issues-scout"`. Opening the page, selecting its
1684
+ tab, or reloading the dashboard never starts a worker and never files an issue: only
1685
+ `data-testid="scout-start"` starts the worker, and that control becomes
1686
+ `data-testid="scout-stop"` while a worker is attached. The page header
1687
+ (`data-testid="issues-scout-header"`) carries the repository origin,
1688
+ `data-testid="scout-lifecycle"` (`stopped`, `idle`, `scanning`, or `failed`), the last-read
1689
+ time, and the single Start/Stop action. A 360px side rail holds the worker
1690
+ facts and the filing limits; the results column holds the filed-issues table and
1691
+ `data-testid="scout-activity"` for the current or newest scan. The page also shows
1692
+ `data-testid="scout-error"` for the last error.
1693
+
1694
+ There is no review path. While a worker runs, a scan whose items score at least 4 files each
1695
+ accepted item directly as a GitHub issue in the host `origin`, in the same scan cycle, with no
1696
+ dashboard command, click, approval, or draft. The page exposes no staged recommendation, no
1697
+ `scout-widget`, `scout-approve`, `scout-draft-goal`, `scout-reject`, or `scout-shortfall` element,
1698
+ and `approveScout`, `draftScoutGoal`, and `rejectScout` are not accepted commands. Stop keeps the
1699
+ filed rows and the limits; the next Start reads GitHub and scans again.
1700
+
1701
+ ### Limits
1702
+
1703
+ `data-testid="scout-max-per-hour"` (default 10) and `data-testid="scout-max-pending"` (default 5)
1704
+ accept integers from 1 through 100. `data-testid="scout-save-limits"` persists both; an invalid
1705
+ value renders `data-testid="scout-limits-error"` and sends nothing. A valid save is written
1706
+ atomically to `<STATE_DIR>/scout/<journalKey(REPO_DIR)>/settings.json`, so it survives a page
1707
+ reload and a worker restart, and a running worker receives it as a limits command without
1708
+ restarting. A worker start reads that file first, then the last state's limits, then the defaults.
1709
+
1710
+ `maxPerHour` counts every Auto Scout issue whose creation time is inside a rolling 60-minute
1711
+ window, in any state, including issues adopted from GitHub and issues filed before a restart.
1712
+ `maxPending` counts every open `scout` issue. One scan requests and files at most
1713
+ `min(maxPerHour - windowCount, maxPending - openCount)` items. A zero batch starts no scan; the
1714
+ next attempt is the earlier of the next 60-second read (a close can free a pending slot) and the
1715
+ moment the oldest hourly entry expires, so the worker never retries in a tight loop. When an issue
1716
+ closes, the 60-second read frees the slot and the next scan starts without any manual action. A
1717
+ scan that files fewer items than requested is a normal shortfall, recorded in scan activity, never
1718
+ a failure. Immediately before filing, the worker reads every `scout` issue again; a failed read
1719
+ files nothing, keeps the last known rows, and blocks filing until a read succeeds.
1720
+
1721
+ ### Significance
1722
+
1723
+ Every recommendation carries an integer `significance` from 1 to 5 and a non-empty
1724
+ `significanceReason`. The scan prompt defines the rubric: 4 or 5 requires a named, observable
1725
+ impact — a reproducible defect, a real failure mode, or a measured structural cost; polishing
1726
+ code that is already correct is at most 2. Items scoring below 4 are dropped before filing and
1727
+ recorded in scan activity. A missing or out-of-range score, or a missing reason, fails the whole
1728
+ scan and files nothing unless the correction turn fixes it. Each filed issue body carries
1729
+ `## Significance` with `<n>/5` and the reason.
1730
+
1731
+ A filed issue carries the exact `scout` label (created first when absent), the recommendation's
1732
+ title, and a body with category, summary, impact, significance, evidence, proposed change, files,
1733
+ and acceptance criteria; the worker resolves forge write access through `lib/forge-auth.ts`. The
1734
+ Issues worker picks it up on its next poll exactly like any other open issue, and its pickup and
1735
+ implementation behavior is unchanged.
1736
+
1737
+ A scan never files a title whose normalized form (lowercased, whitespace-collapsed, trimmed)
1738
+ matches any existing Auto Scout issue in any state, any open issue title, or any draft goal
1739
+ heading. A duplicate is dropped, recorded in activity, and consumes no cap slot. The scan request
1740
+ names the normalized `knownTitles` and the draft headings, so the agent avoids them too.
1741
+
1742
+ ### Issue table
1743
+
1744
+ `data-testid="scout-issues"` renders one `data-testid="scout-issue"` row per Auto Scout issue,
1745
+ newest `createdAt` first, carrying `data-number`, `data-state`, and `data-version`. Each row
1746
+ renders `data-testid="scout-issue-title"` as an anchor to the issue URL,
1747
+ `data-testid="scout-issue-created"` with a local creation time (the raw ISO value stays in the
1748
+ cell's `title`), `data-testid="scout-issue-type"`,
1749
+ `data-testid="scout-issue-state"`, and `data-testid="scout-issue-version"`. The type is the
1750
+ recommendation's category, or the issue body's `## Category` value for an adopted issue, or `—`.
1751
+ With no rows, `data-testid="scout-issues-empty"` renders a centered stack: a decorative inbox
1752
+ mark, `No issues filed yet`, and one line stating that Auto Scout files significant findings after
1753
+ each scan. The empty state adds no control of its own; Start/Stop stays in the page header and the
1754
+ row count stays in the panel header. The sidebar `data-testid="scout-count"` shows the number of
1755
+ rows whose state is `pending`.
1756
+
1757
+ An open issue is `pending`. A closed issue is `implemented` regardless of `stateReason`; a
1758
+ reopened issue returns to `pending` on the next read. For an implemented issue the worker resolves
1759
+ the commit that closed it — the timeline's closing commit when present, otherwise the last 7–40
1760
+ character hex SHA in the newest comment that contains one — to one unambiguous full commit through
1761
+ the checkout or GitHub. It then selects the earliest published GitHub Release tag whose history
1762
+ contains that commit, ordered by `publishedAt` and breaking ties by tag name. The row shows `—`
1763
+ when no commit resolves or no published release contains it, and a release published later fills a
1764
+ blank version on the next read.
1765
+
1766
+ ### Periodic check
1767
+
1768
+ While started, the worker reads every `scout` issue and every published release every 60 seconds,
1769
+ and immediately on Start; the table, states, and versions reflect a change within one interval. One
1770
+ periodic read, including every `gh` child it starts, is bounded to 30 seconds; a read that does not
1771
+ finish records a sync error and releases the worker, so the next interval reads again. A failed
1772
+ read keeps the last known rows, shows the reason in `data-testid="scout-sync-error"`, and keeps the
1773
+ last success time in `data-testid="scout-sync-at"`; the reason clears on the next successful read.
1774
+ Filing stays blocked until a read succeeds. An injected GitHub client owns its own command bounds;
1775
+ the worker still races the whole read against the deadline, but it cannot cancel work that client
1776
+ never settles.
1777
+
1778
+ ### Worker lifecycle
1779
+
1780
+ Start proves one set of facts before spawning anything: a canonical checkout root that is not a
1781
+ linked worktree, a GitHub `origin`, `STATE_DIR` inside `REPO_DIR`, `omp` and `gh` on `PATH` with
1782
+ push permission, and the `/scout-recommendations` command installed at
1783
+ `<REPO_DIR>/.omp/commands/`, `~/.omp/commands/`, or `~/.omp/agent/commands/`. A refusal names the
1784
+ missing fact and writes no scan. The worker is spawned detached from the loaded harness package
1785
+ with `REPO_DIR` as its working directory and holds `<REPO_DIR>/.devboard/.tmp/scout/lock`; a live
1786
+ owner is never replaced, a different `STATE_DIR` is refused, a dead owner is reclaimed, and a
1787
+ second dashboard with the same repository and state directory attaches. Stop ends the peer,
1788
+ records `enabled: false` and `lifecycle: "stopped"`, keeps every row and the limits, releases the
1789
+ lock, and exits; a worker that outlives the command is signalled only after the process table
1790
+ proves it. A worker on another harness version is left running with a notice, and Stop then Start
1791
+ is the only upgrade path.
1792
+
1793
+ State lives under `<STATE_DIR>/scout/<journalKey(REPO_DIR)>/state.json`, written atomically and
1794
+ carrying the worker record, lifecycle, limits, every Auto Scout issue row, the last read, the
1795
+ current scan, next attempt, last error, and notice; a damaged state file is renamed aside. A
1796
+ state produced by the previous Auto Scout version loads with its worker and scan facts intact,
1797
+ discards every unapproved staged recommendation without filing anything, applies the default
1798
+ limits, and publishes a notice naming how many recommendations were discarded; no pre-upgrade
1799
+ recommendation is ever filed. The dashboard migrates that file in memory too, so an upgraded
1800
+ dashboard reads the lifecycle of a worker it did not start, and Stop acts on the live lock owner
1801
+ even when no state file is readable.
1802
+
1803
+ Scan directories live under `<REPO_DIR>/.devboard/.tmp/scout/scans/<scanId>/` and hold
1804
+ `request.json`, `recommendations.json`, `scan.json`, and `activity.json`, which is bounded at the
1805
+ first of 1,000 entries or 1 MiB with the trimmed marker. One scan runs at a time and sends exactly
1806
+ one ACP prompt from `AGENT_COMMAND` (default `omp acp`) with `cwd = REPO_DIR`:
1807
+ `/scout-recommendations <absolute scan directory>`. The prompt is
1808
+ `prompts/scout-recommendations.md`, wrapped by `omp/commands/scout-recommendations.md`; the
1809
+ version-2 request carries the repository path, the requested batch, categories, excluded roots,
1810
+ normalized known titles, draft-goal headings, and the significance threshold, and the agent writes
1811
+ only `recommendations.json`. A permission request is auto-approved, a question is answered with
1812
+ its recommended option (or its first), and both sides reach the activity. A scan that exceeds 15
1813
+ minutes is closed as failed.
1814
+
1815
+ Every result is validated before filing: at most the requested count; a known category; a one-line
1816
+ title of at most 200 characters; a summary, proposed change, significance from 1 through 5,
1817
+ non-empty significance reason, and at least one acceptance criterion; at least one evidence entry
1818
+ whose repository-relative path resolves to an existing regular file inside `REPO_DIR` and outside
1819
+ `.git/`, `node_modules/`, `dist/`, `.worktree/`, and `.tmp/`, with an in-range `N` or `N-M` span;
1820
+ and a non-empty file list whose entries all appear in the evidence. A rejected answer is sent back
1821
+ to the same agent session once, with the rejection text and the root-relative path rule, while the
1822
+ scan's own deadline still bounds the turn; a repaired file is validated again and filed normally.
1823
+ A malformed item, duplicate id, path escape, over-count, missing score, or out-of-range score that
1824
+ survives the correction turn fails the whole answer atomically, naming the offending item, so
1825
+ nothing is filed. An item's id is the first 16 hex characters of SHA-256 over its category, sorted
1826
+ evidence paths, and normalized title, so the same finding keeps its id. Items below significance
1827
+ 4 and duplicate titles are dropped before filing and recorded in the activity. The worker never
1828
+ edits source, tests, configuration, a goal, a draft, a branch, or a pull request.
1829
+
1830
+ ## Report, remove, or redesign a UI element
1831
+
1832
+ When the server has a Gitea address for harness-owned reporting, every page carries three UI issue
1833
+ controls in the activity bar's application actions, beside commands, notifications, and theme:
1834
+ `Report UI issue`, `Remove element`, and `Redesign element`. Without `DEVBOARD_GITEA_URL` they are
1835
+ not rendered and their palette commands are disabled, so no page offers a filing control that
1836
+ cannot work.
1837
+ Each is a 28px icon control with an accessible name and `aria-pressed="true"` while its action is
1838
+ armed; the outcome toast and its error stay out of element picking. `Esc` cancels a mode that is
1839
+ not filing, and `app.report.cancel` cancels any active mode from the palette. While one request is
1840
+ in flight every control is disabled and the active one is labelled `Filing the UI issue`, so one
1841
+ click or one double-click files at most one issue.
1842
+
1843
+ `Report UI issue` enters element-selection mode: the element under the pointer is outlined, and a
1844
+ click opens a report panel anchored beside it, without filing anything yet. The panel shows the
1845
+ selected element's selector path; the report text is free-form. `File issue` posts the report and
1846
+ the captured context to `POST /api/feedback`, the server files the issue through the configured
1847
+ Gitea API, and the panel closes back into report selection mode so another
1848
+ element can be picked without leaving and re-entering the mode. A successful filing shows a
1849
+ `Report filed.` toast with an `Open issue` link to the created issue. `Esc`, the control again, or
1850
+ `Cancel` leaves the mode, a failed report stays in the panel with its error, and a click in
1851
+ selection mode never activates the element. The toast survives leaving the mode: a success
1852
+ dismisses itself after eight seconds, a failure stays until dismissed or replaced.
1853
+
1854
+ The panel also answers questions. `Ask`, or `Enter` in the composer (`Shift+Enter` inserts a
1855
+ newline), sends the typed question and the captured element context — route, URL, selector,
1856
+ viewport, visible text, DOM excerpt, and capture time — over the existing WebSocket to a
1857
+ background ACP agent started from `AGENT_COMMAND`. The agent runs in the host repository root,
1858
+ and its answer streams into a compact transcript in the same panel as plain text, with preserved
1859
+ line breaks and no Markdown interpretation. Every prompt tells it the conversation is `read-only`:
1860
+ never edit files, run mutating commands, file issues, commit, or push. It also tells it to answer
1861
+ in at most `three short sentences` and about 400 characters in plain text. That is a prompt-level
1862
+ rule, not a sandbox.
1863
+
1864
+ One conversation exists `per selected element`, and only while its panel is open. Escape, `×`,
1865
+ `Cancel`, a new pick, a successful filing, or a lost connection closes it: the server kills the
1866
+ agent and the transcript is discarded. Reopening a panel starts a new conversation, selecting a
1867
+ different element never resumes the previous one, nothing is written to `localStorage` or the
1868
+ state directory, and a page reload loses it. While a turn is pending the composer and both action
1869
+ buttons are disabled, `Ask` reads `Asking…`, and `Cancel` stays enabled. A failed start, an exited
1870
+ agent, or a rejected prompt shows the error in the panel, keeps the question and earlier answers,
1871
+ and accepts the next question. `Ask` never calls `POST /api/feedback` and creates no issue.
1872
+
1873
+ Filing or proposing from a panel that has answered questions carries the visible Ask conversation
1874
+ into the issue or draft as a `## Ask conversation` block between the request and
1875
+ `## Captured UI context`, labelled `You:` and `Agent:` per turn. The conversation is bounded to 40
1876
+ turns, 4000 characters per turn, and 12000 characters in total; when it exceeds that, the newest
1877
+ whole turns are kept and the oldest dropped. A panel without a conversation produces no block, and
1878
+ a conversation is never resumed from storage.
1879
+
1880
+ `Goal` turns the composer text into a raw proposed goal instead of an issue. With non-whitespace
1881
+ text it posts `report`, the Ask conversation when the panel has one, and the same captured element
1882
+ context to `POST /api/goal-proposals`; the server writes one Markdown draft under
1883
+ `<REPO_DIR>/.devboard/goal-proposals/` and answers
1884
+ `201 { "path": ".devboard/goal-proposals/<file>.md" }`. The directory is deliberately separate
1885
+ from the final goal specs in `.devboard/goals/`: a draft is unprocessed input, and this action
1886
+ never writes a goal document, `GOAL.md`, or an ADR, never files an issue, and never runs `gh`.
1887
+ The file name is chosen by one bounded naming turn: an ACP peer starts with `AGENT_COMMAND` in
1888
+ `REPO_DIR`, receives the operator report marked as data with a read-only instruction, and must
1889
+ answer with only a lowercase kebab-case slug, at most six hyphen-separated words, `[a-z0-9-]`, and
1890
+ at most 64 characters. A start failure, an exited peer, a question, a 15-second timeout, or an
1891
+ unusable answer falls back to the report's first non-empty line, lowercased, with every run of
1892
+ non-alphanumeric characters replaced by `-`, leading and trailing `-` removed, and at most six
1893
+ hyphen-separated words, falling back to `ui-goal`; an existing name earns `-2`, `-3`, … through an
1894
+ exclusive create, so the first draft is never overwritten. The body opens with
1895
+ `# Proposed goal: <first line, whitespace collapsed, at most 200 characters>`, then the operator's
1896
+ text verbatim, the `## Ask conversation` block when the panel has one, and the
1897
+ `## Captured UI context` block an issue report carries. It carries no notice line or other prose
1898
+ the operator did not write. A successful click closes the panel,
1899
+ stays in report selection mode with an empty composer, and shows the
1900
+ `Goal proposed: <path>` toast as plain text with no link; a failed write keeps the panel open
1901
+ with the server's error and leaves no draft. The drafts are ignored by git.
1902
+
1903
+ `Remove element` and `Redesign element` are one-click shortcuts. Selecting one arms it and shows a
1904
+ shortcut-specific hint; the next click on any element files the issue immediately, with no panel
1905
+ and no text field. Filing does not use client-supplied report text. A successful filing shows a
1906
+ `Removal issue filed.` or `Redesign issue filed.` toast with an `Open issue` link to the created
1907
+ issue and keeps the mode armed, so the next element is one click away; a failure shows a
1908
+ `Removal issue failed: <message>` or `Redesign issue failed: <message>` alert toast, creates no
1909
+ issue, and keeps the mode armed with the controls re-enabled. A success toast dismisses itself
1910
+ after eight seconds; a failure stays until dismissed or replaced. Either survives `Esc`, the
1911
+ active control, or `app.report.cancel`, because leaving the mode is not a reason to hide whether
1912
+ the issue was created. The active control, `Esc`, or `app.report.cancel` returns to idle.
1913
+
1914
+ Both shortcuts use a fixed title and body, so the implementation agent starts from the captured
1915
+ context rather than operator prose. The title is `[ui] Remove element: <selector>` or
1916
+ `[ui] Redesign element: <selector>`, with the selector's whitespace collapsed and the whole title
1917
+ clamped to 200 characters; an empty selector leaves `[ui] Remove element`. A removal body starts
1918
+ with `## Removal request` and the instruction `Remove the selected UI element from the
1919
+ interface.`. A redesign body starts with `## Redesign request` and the instruction `Check the
1920
+ selected UI element and redesign it because it does not look good. Use the global OMP UI/UX
1921
+ standard (the ui-ux skill) and this repository's DESIGN.md and design/tokens.json as the normative
1922
+ design system.` Both bodies end with the same `## Captured UI context` block: route, URL, selected
1923
+ element, viewport, capture time, visible text, and DOM excerpt.
1924
+
1925
+ The report title is `[ui] ` plus the first line of the report, at most 200 characters; its body is
1926
+ the verbatim report followed by the same captured context, so the ticket stays authoritative
1927
+ without a model in the loop.
1928
+
1929
+ Every request carries an `action` of `report`, `remove`, or `redesign`; an absent `action` means
1930
+ `report`, the only action that requires report text, and any other value is refused with HTTP 400
1931
+ before the Gitea request. Harness-owned reporting uses only the Gitea API: `DEVBOARD_GITEA_URL`
1932
+ names the instance, `GITEA_TOKEN` (or `DEVBOARD_GITEA_TOKEN`) authenticates it, and
1933
+ `DEVBOARD_RELEASE_REPO` selects the repository (default `dsdenes/devboard`), whatever host project
1934
+ the interface is serving. Nothing is created in the host's own tracker, and no `github.com`
1935
+ repository is used for filing. Without `DEVBOARD_GITEA_URL` the endpoint answers HTTP 503 naming
1936
+ that variable and performs no request. A refused or failing request is reported in the filing
1937
+ toast and creates nothing. The endpoint accepts only same-origin `POST` requests, bounds the body
1938
+ to 64 KB, and gives the request 60 seconds.
1939
+
1940
+ ## Container
1941
+
1942
+ Build the container from the `.devboard` context at the repository root:
1943
+
1944
+ ```bash
1945
+ docker build -t devboard-adr-ui .devboard
1946
+ docker run --rm -d --name devboard-adr-ui -p 127.0.0.1:8080:8080 \
1947
+ -v "$PWD:/repo" devboard-adr-ui
1948
+ ```
1949
+
1950
+ The container reads `/repo/adrs` and listens on port `8080`. The repository mount is writable because `Save goal` replaces `/repo/GOAL.md`; mount it read-only only if goal saving is not needed. Docker publishes the port on the host's loopback interface only. The container process binds `0.0.0.0` because Docker forwards published traffic to the container interface; the local-only access boundary is enforced by the `127.0.0.1` host publish.
1951
+
1952
+ ## WebSocket contract
1953
+
1954
+ The client sends:
1955
+
1956
+ ```json
1957
+ { "type": "listRecords" }
1958
+ { "type": "getGoal" }
1959
+ { "type": "getProject" }
1960
+ { "type": "getVersion" }
1961
+ { "type": "getIssues" }
1962
+ { "type": "getIssuesRun", "runId": "2026-10-27T10-00-00-000Z" }
1963
+ { "type": "startIssues" }
1964
+ { "type": "pauseIssues" }
1965
+ { "type": "resumeIssues" }
1966
+ { "type": "stopIssues" }
1967
+ { "type": "checkIssues" }
1968
+ { "type": "answerIssues", "id": 7, "value": "all", "runId": "2026-10-27T10-00-02-000Z" }
1969
+ { "type": "closeIssue", "number": 240 }
1970
+ { "type": "getGoalDrafts" }
1971
+ { "type": "getGoalDraft", "path": ".devboard/goal-proposals/example.md" }
1972
+ { "type": "startGoalDraft", "path": ".devboard/goal-proposals/example.md" }
1973
+ { "type": "stopGoalDraft" }
1974
+ { "type": "answerGoalDraft", "id": 4, "value": "The whole goal" }
1975
+
1976
+ { "type": "getScout" }
1977
+ { "type": "startScout" }
1978
+ { "type": "stopScout" }
1979
+ { "type": "setScoutLimits", "maxPerHour": 10, "maxPending": 5 }
1980
+ { "type": "restartServer" }
1981
+ { "type": "restartServer", "whenIdle": true }
1982
+ { "type": "saveGoal", "text": "# Goal\n\nThe complete goal text." }
1983
+ { "type": "startRun" }
1984
+ { "type": "startRun", "file": "0002-example.md" }
1985
+ { "type": "startLifecycle" }
1986
+ { "type": "pauseLifecycle" }
1987
+ { "type": "resumeLifecycle" }
1988
+ { "type": "reviseGoal", "request": "Tighten the goal" }
1989
+ { "type": "reviseGoal" }
1990
+ { "type": "createGoal" }
1991
+ { "type": "prepareSkills" }
1992
+ { "type": "getSkills" }
1993
+ { "type": "deleteSkill", "name": "test-strategy" }
1994
+ { "type": "resetImplementation" }
1995
+ { "type": "answer", "id": 1, "value": "Gamma" }
1996
+ { "type": "ask", "conversationId": "…", "question": "What does this label mean?", "context": { … } }
1997
+ { "type": "closeAsk", "conversationId": "…" }
1998
+ ```
1999
+
2000
+ - `getGoal` reads the goal document from disk and answers with its complete contents; an
2001
+ absent document answers with empty text, which shows `Create goal` and keeps the lifecycle
2002
+ start disabled.
2003
+ - `getProject` answers with the page heading label. `path` is `REPO_DIR` relative to the user's
2004
+ home directory when the repository lies inside it, and the absolute path otherwise.
2005
+ - `getVersion` answers with `versionState`: `running` is the harness package version the
2006
+ backend loaded, `installed` is the version its prefix holds now, `latest` is the newest
2007
+ release an update check has seen, and `stale` is true only when `running` and `installed` are
2008
+ readable and differ. A dashboard that serves the canonical source checkout reads neither side
2009
+ from the checked-in manifest: it reports `0.1.<commit-count>` over the full history, the same
2010
+ version a release packs, so an unpacked checkout never claims `0.1.0`. A client that connects,
2011
+ and the page every few seconds, asks for it; a stale answer makes the reload control appear
2012
+ with both versions in its title.
2013
+ - `getIssues` answers with `issuesState`, the worker snapshot plus `present` and `attached`:
2014
+ lifecycle, enabled, paused, repository, poll times, queue entries with `labels`, `handled`,
2015
+ `runId`, and `agentRunId`, run summaries, the running `agents` (run id, issue number, startedAt,
2016
+ question, ACP child), the per-issue `cooldowns` map, errors, and the harness version the worker
2017
+ loaded. A missing state and no live lock answer `present: false`, `attached: false`, and
2018
+ `lifecycle: "stopped"`. A live worker from another harness version adds a non-blocking `notice`
2019
+ naming both versions. A connect also replays one `issuesActivity` with `replay: true` for every
2020
+ running agent; `getIssuesRun` answers `issuesRun` with one run's metadata and activity. The
2021
+ client never polls: state and activity arrive as events, so switching modes and reconnecting
2022
+ send no frame.
2023
+ - `answerIssues` carries `{ runId, id, value }`; the answer reaches only the agent with that run
2024
+ id, and a missing or unknown run id is refused with an `error` that reaches no peer.
2025
+ - `closeIssue` carries `{ number }` and closes that issue on the checkout's GitHub origin as
2026
+ `not planned` with a write-capable account. It answers `issuesClosed` with `{ number, ok }` and,
2027
+ on failure, the reason in `message`; a missing number answers `error`. A running agent is not
2028
+ interrupted, and the queue drops the issue on the worker's next poll.
2029
+ - `getGoalDrafts` answers `issuesDrafts` with the sorted list of `{ path, name, heading }` and
2030
+ `issuesDraftState` with the current elaboration. `getGoalDraft` answers `issuesDraftDetail` with
2031
+ the draft's exact bytes, or `issuesDraftError` when the path is refused or missing.
2032
+ `startGoalDraft` starts the one elaboration peer and broadcasts `issuesDraftState` and
2033
+ `issuesDrafts`; `stopGoalDraft` closes it, records `stopped`, and keeps the draft.
2034
+ `answerGoalDraft` routes one answer to the elaboration question. Activity is sent as
2035
+ `issuesDraftState` snapshots while the peer runs.
2036
+
2037
+ lifecycle, enabled, paused, repository, poll times, queue, run summaries, active run, question,
2038
+ next attempt, errors, and the harness version the worker loaded. A missing state and no live
2039
+ lock answer `present: false`, `attached: false`, and `lifecycle: "stopped"`. A live worker from
2040
+ another harness version adds a non-blocking `notice` naming both versions. A connect also
2041
+ replays the active run's whole activity as one `issuesActivity` with `replay: true`;
2042
+ `getIssuesRun` answers `issuesRun` with one run's metadata and activity. The client never
2043
+ polls: state and activity arrive as events, so switching modes and reconnecting send no frame.
2044
+ - `getScout` answers with `scoutState` and the newest scan's `scoutActivity`, on connect and on
2045
+ demand: the worker record, lifecycle, enabled and attached flags, the persisted limits, every
2046
+ issue row, the last GitHub read, the current scan, next attempt, errors, and notice. A connect
2047
+ replays a present state and the newest scan's whole activity before any reply to the client's
2048
+ own commands. `startScout` runs the preflight and spawns or attaches; `stopScout` writes one
2049
+ stop command; `setScoutLimits` carries `{ maxPerHour, maxPending }`, refuses a value outside
2050
+ 1–100, writes `settings.json`, and writes one limits command when a worker is attached. There is
2051
+ no `approveScout`, `draftScoutGoal`, or `rejectScout`. The rows are pushed to every connected
2052
+ dashboard, so a filing or a close in one updates another without a reload.
2053
+ - `restartServer` broadcasts `serverRestarting`, then hands the process to its launcher, which
2054
+ stops the listener, starts the installed harness package, and exits. It is refused with an
2055
+ `error` when no launcher is configured or while a restart is already in progress. A client
2056
+ that clicked the reload control waits for the replacement to answer and reloads the page even
2057
+ if the acknowledgement was lost when the old process exited. With `"whenIdle": true`, a
2058
+ dashboard with active work answers `upgradePending` instead and restarts itself as soon as
2059
+ that work ends; an idle dashboard restarts immediately.
2060
+ - `saveGoal` replaces the goal document with `text` exactly as submitted and answers with the same text. A write failure leaves the file unchanged and answers with an `error`. It is accepted while the lifecycle is paused and refused while the lifecycle is advancing.
2061
+ - `startRun` without `file` starts the sequence with the lowest-numbered pending record.
2062
+ - `startRun` with `file` starts that record first only when it is pending; the sequence then
2063
+ continues with the lowest-numbered remaining pending records.
2064
+ - Every accepted start resolves one record at a time until no pending record remains or a run
2065
+ ends without resolving its record. A still-pending or removed active record ends the
2066
+ sequence.
2067
+ - A manual start is accepted while a lifecycle run is active. When the lifecycle is advancing,
2068
+ the server first pauses it at its interrupted stage and then starts the sequence; a start that
2069
+ cannot run leaves the lifecycle advancing. While the lifecycle is paused, the manual sequence
2070
+ runs beside the suspended lifecycle lanes, which keep their tabs, activity, and questions.
2071
+ Resolving the record the lifecycle itself has in progress is refused. When the sequence ends
2072
+ the lanes stay paused, with their tabs intact, until the lifecycle is resumed. `runStarted` events for a manual record carry `"manual": true`, and a client that
2073
+ connects during the sequence receives that conversation.
2074
+ - A start is refused with an `error` event while another manual sequence is running (including
2075
+ between records), while a goal revision, goal creation, or skills preparation is active, when
2076
+ no record is pending, or when the named record is not pending.
2077
+ - `answer` supplies one value for the pending elicitation; the question is cleared after the first answer.
2078
+ - `ask` sends one question and the captured element context for a conversation id. The first turn
2079
+ starts one ACP peer from `AGENT_COMMAND` in `REPO_DIR`; a follow-up reuses that peer and session,
2080
+ and a replacement peer receives the earlier turns. Only the asking socket receives the
2081
+ conversation's `askChunk`, `askDone`, and `askError` events, each carrying its conversation id.
2082
+ A question must be 1–4000 trimmed characters with a present, bounded context, one turn runs at a
2083
+ time per conversation, and events for a closed conversation are dropped. `closeAsk` closes the
2084
+ conversation and kills its peer; closing the socket closes every conversation it owned. See
2085
+ Report, remove, or redesign a UI element.
2086
+ - `reviseGoal` starts a guided goal revision with one ACP peer. `request` is optional free-form guidance passed to the agent; without it, the agent establishes the direction through its interview questions. The agent writes the goal document itself. Activity and questions use the existing `activity` and `question` events, and `answer` replies to a revision question exactly as it does to a run question. It is accepted while the lifecycle is paused, where the suspended lanes keep their tabs.
2087
+ - A revision is refused with an `error` event while the lifecycle is advancing, a run or run
2088
+ sequence is active, a manual ADR sequence is active, or another revision is active. While a revision is active, `startRun`, `startLifecycle`, `resumeLifecycle`, and another `reviseGoal` are each refused with an `error`, and no agent is spawned.
2089
+ - `createGoal` starts the guided goal creation with one ACP peer whose prompt is exactly
2090
+ `/goal-outcome`, the installed skill command that reads the harness prompt and interviews the
2091
+ developer. The server accepts it in every state: it replaces a goal document that already has
2092
+ content, and it preempts an in-flight creation or revision and stops an advancing ADR run,
2093
+ manual ADR sequence, or lifecycle before the new peer starts. It reads
2094
+ `<REPO_DIR>/GOAL.md` before and after the peer, while `getGoal` and `saveGoal` keep using
2095
+ `GOAL_FILE`. It is accepted while a lifecycle run is paused, where the suspended lanes keep
2096
+ their tabs, and it stops a manual sequence running beside those lanes. Activity and
2097
+ questions use the existing `activity` and `question` events, and `answer` replies to a creation question
2098
+ exactly as it does to a revision question; a second answer for the same question id is refused
2099
+ with an `error`. When the peer ends, the server reads `<REPO_DIR>/GOAL.md` again: text that
2100
+ contains at least one non-whitespace character completes the creation, while an absent, empty,
2101
+ or whitespace-only document is a failure that sends an `error` followed by a failed
2102
+ `goalCreationStopped`.
2103
+ - A creation is refused with an `error` event, without spawning a peer, only when
2104
+ `<REPO_DIR>/GOAL.md` exists but cannot be read. Otherwise a `createGoal` request always wins:
2105
+ the server cancels the active creation or revision, reports it stopped with `completed: false`,
2106
+ stops an advancing run or lifecycle and reports the idle `goal` stage, then starts the new peer.
2107
+ While a creation is active, `startRun`, `startLifecycle`, `resumeLifecycle`, and `reviseGoal`
2108
+ are each refused with an `error`, and no agent is spawned; a second `createGoal` restarts the
2109
+ creation instead. The creation runs in the server, so closing the browser does not stop it, and
2110
+ a client that connects during it receives `goalCreationStarted`, the recorded activity, and the
2111
+ pending question before its own command replies.
2112
+ - `prepareSkills` starts one ACP peer whose prompt is exactly `/prepare-skills`, the installed
2113
+ command that reads `.devboard/prompts/prepare-skills.md`. The server accepts it only while
2114
+ `<REPO_DIR>/GOAL.md` contains at least one non-whitespace character and no run, run sequence,
2115
+ lifecycle run (paused or advancing), goal creation, goal revision, or other preparation is
2116
+ active; otherwise it answers with an `error` and spawns no peer. The server snapshots
2117
+ `<REPO_DIR>/.omp/skills/`, deletes every skill carrying `metadata.devboard-managed: "true"`,
2118
+ and broadcasts `skillsPreparationStarted`. Activity and questions use the existing `activity`
2119
+ and `question` events, and `answer` replies to a preparation question like any other. When the
2120
+ peer exits, the server validates the managed set, compares every installed skill for
2121
+ incompatible directives, and checks that no unmanaged skill changed; success broadcasts
2122
+ `skillsPreparationStopped` with `completed: true`, the skill list, and an empty contradiction
2123
+ list, while any failure broadcasts an `error`, restores the snapshot, and broadcasts the same
2124
+ event with `completed: false`. `getSkills` answers with the current managed set read from disk
2125
+ and sends `skillsState`; the client issues it on every connection, so the panel is rebuilt after
2126
+ a reload or restart instead of starting a run. While preparation is active, `startRun`,
2127
+ `startLifecycle`, `resumeLifecycle`, `reviseGoal`, `saveGoal`, `createGoal`, and a second
2128
+ `prepareSkills` are each refused with an `error` and spawn no peer. `deleteSkill` (with `name`)
2129
+ removes exactly one installed managed skill of the prepare family from `<REPO_DIR>/.omp/skills/`,
2130
+ is accepted only while that directory is installed and no run, lifecycle, goal creation,
2131
+ revision, or preparation is active, and answers by broadcasting a fresh `skillsState`; it
2132
+ refuses an unmanaged skill, a Discover-family skill, and a name that is not in the managed set.
2133
+ - `resetImplementation` deletes and commits the implementation as described above. The server
2134
+ broadcasts `implementationResetStarted` when it accepts the command, performs the reset
2135
+ itself, and broadcasts `implementationResetStopped` with `completed: true` and the 40-hex
2136
+ `commit` only after `git push origin main` succeeds; a failed push includes the local commit
2137
+ with `completed: false`, and a failure before or during the commit omits `commit`. Every
2138
+ failure is preceded by one `error` carrying git's stderr where git failed. While a reset
2139
+ runs, `saveGoal`, `startRun`, `startLifecycle`, `pauseLifecycle`, `resumeLifecycle`,
2140
+ `reviseGoal`, `createGoal`, `prepareSkills`, `answer`, and a second `resetImplementation`
2141
+ are each refused with an `error`; `listRecords`, `getGoal`, `getProject`, `getIssues`, `getIssuesRun`,
2142
+ and `getSkills` still answer. A client that connects during a reset receives `implementationResetStarted` before its own
2143
+ command replies. On success the server follows the stopped event, in order, with
2144
+ `lifecycleState` at `goal` with pass `1`, `work: null`, and `paused: false`, `records` with
2145
+ an empty list and `next: null`, and `goal` with the unchanged text (empty when the document
2146
+ is absent).
2147
+ - `startLifecycle` starts the backend-owned parallel pipeline. It is refused with an `error`
2148
+ while a run, a run sequence, a manual ADR sequence, a goal revision, a goal creation, or
2149
+ another lifecycle run is active; when the goal document is absent, unreadable, or contains only
2150
+ whitespace; when `<REPO_DIR>/.omp/skills/` cannot be read; and when no managed skill is
2151
+ installed there. A refused start spawns no agent and reports no `lifecycleState`. An accepted
2152
+ start reports `lifecycleState` at `epic` with `work: null`, the pass already held, and
2153
+ `paused: false` before its first tracker query and before any agent starts. While the lifecycle
2154
+ run is active, `startLifecycle` is also refused. `startRun` is accepted and pauses every lane
2155
+ as described above, and the lifecycle resumes when the manual sequence ends.
2156
+ `reviseGoal` and `saveGoal` are refused only while the lifecycle is
2157
+ advancing.
2158
+ - `stopLifecycle` ends an active run for real. It closes every live lane peer, reports the idle
2159
+ `lifecycleState` at `goal` with `work: null`, `paused: false`, and the pass already reached,
2160
+ and publishes a lane snapshot with every stopped lane `failed` and `runningLanes: 0`. It
2161
+ leaves nothing suspended. It is refused with an `error` when no lifecycle run is active. A
2162
+ stopped run's loop finishes unwinding before a later `startLifecycle` is accepted, so the two
2163
+ runs never publish concurrently.
2164
+ - The accepted run reads every open epic and starts one implementation lane per epic at once,
2165
+ with no fixed cap. Each lane sends `/implement-next-epic <ref>` to its own ACP peer; a lane
2166
+ never selects a different epic. If no epic is open, the run sends one `/epic-next` peer.
2167
+ Generation is zero-or-more: it may file several pairwise-independent epics in one pass, and
2168
+ every epic it files starts its own lane immediately. Closing an epic increments `pass`, marks
2169
+ that lane `closed`, and requests a generation pass immediately, even while other lanes keep
2170
+ implementing; a request during an active pass coalesces into at most one follow-up, and two
2171
+ generation peers never run at once.
2172
+ - A lane that ends with its epic still open is marked `retrying` and its lane sends an `error`;
2173
+ it is retried after a five-second backoff and immediately when `main` advances. A lane whose
2174
+ peer prints a line beginning `DEVBOARD-BLOCKED:` is `parked`; it is retried only when `main`
2175
+ advances. Neither state ever creates a second concurrent lane for the same epic, and neither
2176
+ stops the other lanes. With no lane active, a generation pass that files
2177
+ nothing, and only parked epics remaining, the run sends an `error` naming every parked epic,
2178
+ reports the idle `goal` stage, and leaves those epics open; a new run clears the parked set.
2179
+ - When no epic remains, a generation pass files nothing, and no ADR is still pending, the
2180
+ lifecycle resolves each pending record in ascending file-name order and then reports
2181
+ `finished`. A pass that leaves the same pending record set as the previous one without an epic
2182
+ sends an `error` and stops. Every stop before `finished` reports the idle `goal` stage with
2183
+ `work: null`, `paused: false`, and the pass reached. A tracker or record read failure also
2184
+ stops the run. A forge can expose a peer's tracker write a moment after the peer exits, so the
2185
+ server re-reads the tracker for a bounded settle window per lane before trusting an answer that
2186
+ still shows the pre-peer state; pause holds the loop between reads, and an exhausted window
2187
+ accepts the last answer.
2188
+ - `pauseLifecycle` suspends every running lane with `SIGSTOP`, marks each lane `paused`, and holds
2189
+ the scheduler before the next peer start or stage transition. It reports `lifecycleState` with
2190
+ the interrupted `stage`, `pass`, and `work` and `paused: true`, then a lane snapshot with
2191
+ `runningLanes: 0`; a pause during the first lookup reports `epic` with `work: null`. No tracker
2192
+ result use, stage transition, retry, or new ACP peer happens until a resume. Pending lane
2193
+ questions stay in their tabs, and `answer` is accepted and buffered for the suspended child.
2194
+ Selecting a manual ADR start while the lifecycle advances pauses every lane the same way and
2195
+ resumes it once the sequence ends. An explicit pause command stays suspended until
2196
+ `resumeLifecycle`; the interface's single lifecycle button offers only start and stop, not
2197
+ pause.
2198
+ - `resumeLifecycle` reports the same `stage`, `pass`, and `work` with `paused: false`, continues
2199
+ every suspended lane with `SIGCONT`, and releases the held loop. It is refused while a goal
2200
+ revision or a manual ADR sequence is active, leaving the lifecycle paused until that peer ends.
2201
+ Pause and resume are each refused with an `error` when no lifecycle run is active, when pausing
2202
+ an already-paused run, and when resuming a run that is not paused. The paused flag is part of
2203
+ the durable state, so a run paused before a restart stays paused and spawns no peer until
2204
+ `resumeLifecycle`; it is discarded when the run ends.
2205
+ - Each lifecycle lane is one `omp acp` process. `runStarted`, `activity`, `question`, and
2206
+ `runStopped` for a lane carry its `lane` id; lanes also report their epic or record identity,
2207
+ attempt number, status, recorded activity, and pending question in `laneState`. Untagged
2208
+ `runStarted`, `activity`, `question`, and `runStopped` events remain the foreground run,
2209
+ revision, creation, and skills preparation. Lifecycle state is durable: the lifecycle, its
2210
+ pass, every lane tab with its activity and question, and the epic bookkeeping are written to
2211
+ the harness state directory keyed by `REPO_DIR` and restored when the backend starts. A client
2212
+ that connects while no run is active receives the idle `goal` state when the retained pass is
2213
+ above 1.
2214
+
2215
+ The server answers with events:
2216
+
2217
+ ```json
2218
+ { "type": "lifecycleState", "stage": "epic", "pass": 1, "work": null, "paused": false }
2219
+ { "type": "lifecycleState", "stage": "adr", "pass": 1, "work": { "kind": "adr", "id": "0002-example.md" }, "paused": false }
2220
+ { "type": "lifecycleState", "stage": "implementation", "pass": 1, "work": { "kind": "epic", "id": "42", "url": "https://github.com/owner/repo/issues/42" }, "paused": false }
2221
+ { "type": "lifecycleState", "stage": "implementation", "pass": 1, "work": { "kind": "epic", "id": "42", "url": "https://github.com/owner/repo/issues/42" }, "paused": true }
2222
+ { "type": "lifecycleState", "stage": "epic", "pass": 2, "work": null, "paused": false }
2223
+ { "type": "lifecycleState", "stage": "goal", "pass": 2, "work": null, "paused": false }
2224
+ { "type": "laneState", "openEpics": 3, "runningLanes": 3, "lanes": [ { "id": "lane-2", "kind": "implementation", "epic": { "number": 42, "title": "Add export", "url": "https://github.com/owner/repo/issues/42" }, "record": null, "attempt": 1, "status": "running", "activity": [ "Working on epic 42" ], "question": null }, { "id": "lane-3", "kind": "adr", "epic": null, "record": { "file": "0002-example.md", "title": "Choose a runtime" }, "attempt": 1, "status": "paused", "activity": [ "Inspecting 0002-example.md" ], "question": { "id": 1, "message": "Choose an answer", "options": [] } } ] }
2225
+ { "type": "runStarted", "lane": "lane-2", "kind": "implementation", "attempt": 1, "file": "", "title": "Add export", "action": "implementing" }
2226
+ { "type": "activity", "lane": "lane-2", "text": "Working on epic 42" }
2227
+ { "type": "question", "lane": "lane-2", "id": 1, "message": "Choose an answer", "options": [] }
2228
+ { "type": "runStopped", "lane": "lane-2", "file": "", "title": "Add export", "resolved": true, "status": "closed" }
2229
+ { "type": "records", "records": [ { "file": "0001-example.md", "title": "Example", "status": "Accepted", "pending": false, "summary": "Use the simplest runtime." } ], "next": null }
2230
+ { "type": "project", "path": "projects/devboard" }
2231
+ { "type": "versionState", "running": "0.1.200", "installed": "0.1.201", "latest": "0.1.201", "stale": true }
2232
+ { "type": "serverRestarting" }
2233
+ { "type": "upgradePending" }
2234
+ { "type": "goal", "text": "# Goal\n\nThe complete goal text." }
2235
+ { "type": "goalRevisionStarted" }
2236
+ { "type": "goalRevisionStopped", "completed": true, "text": "# Revised goal\n\nThe agent rewrote this." }
2237
+ { "type": "goalRevisionStopped", "completed": false }
2238
+ { "type": "goalCreationStarted" }
2239
+ { "type": "goalCreationStopped", "completed": true, "text": "# Created goal\n\nThe agent wrote this." }
2240
+ { "type": "goalCreationStopped", "completed": false }
2241
+ { "type": "skillsPreparationStarted" }
2242
+ { "type": "skillsPreparationStopped", "completed": true, "skills": [ { "name": "dependency-direction", "description": "Choose dependency direction." } ], "contradictions": [] }
2243
+ { "type": "skillsPreparationStopped", "completed": false }
2244
+ { "type": "skillsState", "skills": [], "contradictions": [] }
2245
+ { "type": "implementationResetStarted" }
2246
+ { "type": "implementationResetStopped", "completed": true, "commit": "0123456789abcdef0123456789abcdef01234567" }
2247
+ { "type": "implementationResetStopped", "completed": false, "commit": "0123456789abcdef0123456789abcdef01234567" }
2248
+ { "type": "implementationResetStopped", "completed": false }
2249
+ { "type": "runStarted", "file": "0001-example.md", "title": "Example", "action": "resolving" }
2250
+ { "type": "runStarted", "file": "", "title": "Improve watch mode", "action": "implementing" }
2251
+ { "type": "runStarted", "file": "", "title": "Epic generation", "action": "generating" }
2252
+ { "type": "runStarted", "file": "0002-example.md", "title": "Manual example", "action": "resolving", "manual": true }
2253
+ { "type": "activity", "text": "Inspecting 0001-example.md" }
2254
+ { "type": "activity", "text": " and its status line", "append": true }
2255
+ {
2256
+ "type": "question",
2257
+ "id": 1,
2258
+ "message": "Choose an answer",
2259
+ "options": [
2260
+ { "label": "Alpha", "description": "Alpha leads to the first outcome.", "recommended": true },
2261
+ { "label": "Beta", "description": "Beta leads to the second outcome.", "recommended": false }
2262
+ ]
2263
+ }
2264
+ { "type": "runStopped", "file": "0001-example.md", "title": "Example", "resolved": true, "status": "Accepted" }
2265
+ { "type": "error", "message": "No pending ADRs to start." }
2266
+ ```
2267
+
2268
+ - A `records` event carries every record in file-name order. `summary` is the first paragraph
2269
+ of the record's `Decision` section, shortened at a word boundary; it is empty when the record
2270
+ has no such section. `next` names the lowest-numbered pending record, or null.
2271
+ - `runStarted` carries `action`: `resolving` for an ADR peer, `implementing` when the lifecycle
2272
+ peer implements an epic, and `generating` when it creates one. The client labels the run from
2273
+ it, so an implementation is never described as a resolution.
2274
+ - `activity` events carry one line of the agent's output. An event with `"append": true`
2275
+ continues the previous line: the client appends `text` to it instead of starting a new one.
2276
+ Streamed ACP text updates are deltas of one assistant message (identified by its
2277
+ `messageId`) and are joined this way, so the developer reads whole messages rather than
2278
+ chunks; a different message id, a change from thought to reply, a tool call, or a plan
2279
+ starts a new line. The server stores the joined lines, so a client that connects during a
2280
+ run receives each line once, without the `append` flag. Text updates that carry no message
2281
+ id are complete lines and always start a new one. The interface renders the last line as the
2282
+ current action and lists earlier lines under a collapsed history.
2283
+ - A manual record `runStarted` carries `"manual": true`; a lifecycle stage's event omits the
2284
+ field. The flag lets the client keep its start controls disabled for the manual sequence while
2285
+ a paused lifecycle remains resumable. A client that connects during a manual sequence receives
2286
+ that `runStarted`, the recorded activity, and the pending question, ahead of the suspended
2287
+ lifecycle conversation.
2288
+ - A `question` event carries one option per choice. Each option has a `label`, an optional
2289
+ `description` stating the consequence of choosing it, and `recommended`, true for the answer
2290
+ the agent suggests. When the peer's schema marks none, the server marks the first option as
2291
+ recommended, so a choice list never lacks a suggestion. `answer` names the selected label as
2292
+ `value`.
2293
+ - A record's title and status come from its YAML frontmatter `title` and `status` fields.
2294
+ - A record is pending exactly when its status is `draft` or `proposed`; every other status is
2295
+ resolved.
2296
+ - Records are ordered by file name, and `next` is the first pending record in that order (the lowest-numbered pending file).
2297
+ - `lifecycleState` reports `stage` (`goal`, `epic`, `adr`, `implementation`, or `finished`),
2298
+ the `pass` number, `work` (`null`, an ADR filename, or an epic issue number), and `paused`.
2299
+ An epic `work` also carries the issue's `url` from the tracker when the origin reported one,
2300
+ which the page renders as a link. A `lifecycleState` is sent when a lifecycle run is accepted,
2301
+ on every transition and pause change, and to
2302
+ each client that connects or reconnects before any other event, ahead of that client's own
2303
+ command replies: while a lifecycle run is active, the current state; when no run is active
2304
+ and a state has been reported, the last reported state, including `finished` with its final
2305
+ pass or the idle `goal` state of an aborted run with the pass it reached. An accepted run
2306
+ is reported at `epic` with `work: null` before the origin-tracker query, and an open epic
2307
+ follows as a transition to `implementation`. A client that connects while paused receives
2308
+ `paused: true`, followed by the lane snapshot with every lane's recorded activity and pending
2309
+ question. Stage `goal` is the idle state before a lifecycle run starts and after one
2310
+ stops before `finished`; a backend on which no lifecycle run has ever started sends no
2311
+ `lifecycleState` on connection. The retained state is durable, so a replacement process
2312
+ replays the same `lifecycleState`, lanes, activity, questions, and pass.
2313
+ - `goalRevisionStarted` marks the beginning of a revision. `goalRevisionStopped` marks its end: `completed` is `true` only when the peer ended without error and the goal file could be read, in which case `text` carries the complete file contents. A failed revision has `completed: false`, omits `text`, and is accompanied by an `error` event.
2314
+ - A revision emits no `runStarted` or `runStopped` events of its own and does not read or write `adrs/`. When it ends while a lifecycle run is paused, the suspended lane tabs stay as they were; the server does not replay them because they were never removed.
2315
+ - `goalCreationStarted` marks the beginning of a guided goal creation. `goalCreationStopped`
2316
+ marks its end: `completed` is `true` only when the peer ended without error and
2317
+ `<REPO_DIR>/GOAL.md` could be read with at least one non-whitespace character, in which case
2318
+ `text` carries the complete file contents. A failed creation has `completed: false`, omits
2319
+ `text`, and is accompanied by an `error` event; that includes a peer that ends with an absent,
2320
+ empty, or whitespace-only goal document. A creation emits no `runStarted` or `runStopped`
2321
+ events and does not read or write `adrs/`. When it ends while a lifecycle run is paused, the
2322
+ suspended lane tabs stay as they were. A client that connects or reconnects during a creation
2323
+ receives `goalCreationStarted`, every recorded `activity` line, and the pending `question` if
2324
+ one exists, before any other event beyond the lifecycle state and lane snapshot.
2325
+ - A client that connects or reconnects during a revision receives `goalRevisionStarted`, every `activity` line recorded so far, and the pending `question` if one exists, before any other event beyond the lifecycle state and lane snapshot.
2326
+ - `skillsPreparationStarted` marks the beginning of a skills preparation, and
2327
+ `skillsPreparationStopped` marks its end. Success is `completed: true` with `skills` (each
2328
+ entry carries `name` and `description`, and nothing else) and `contradictions: []`; failure is
2329
+ `completed: false` without either field, and follows an `error`. A run that ends with no
2330
+ managed skill is a failure, like every other validation failure. A failed run restores
2331
+ `<REPO_DIR>/.omp/skills/` to its pre-run bytes, so the panel and the disk still show the
2332
+ previous managed set. Preparation emits no `runStarted`, `runStopped`, or `records` events and
2333
+ does not read or write `adrs/`. `skillsState` reports the managed skills read from disk and
2334
+ the current contradiction list; `getSkills` requests it, and a client that connects or
2335
+ reconnects during preparation first receives `skillsPreparationStarted`, every recorded
2336
+ `activity` line, and the pending `question` if one exists. The panel lists managed skills only
2337
+ and shows a contradiction list only when the review found one; the review itself covers every
2338
+ skill directory, managed or not.
2339
+ - The server reads ADR files and never writes them; the agent changes the status line. The server reads the tracker and never writes issues. The goal document is written verbatim by `saveGoal` and by the revision and creation agents, and the server reads it to answer `getGoal` and to report `goalRevisionStopped` or `goalCreationStopped`; a creation always writes `<REPO_DIR>/GOAL.md` even when `GOAL_FILE` names another file.
2340
+ - Skills preparation writes only under `<REPO_DIR>/.omp/skills/` and only through its ACP peer;
2341
+ the server deletes managed skill directories before the run and restores the snapshot after a
2342
+ failure. `runStopped` reports whether the active record is now resolved; a `records` event
2343
+ follows each `runStopped` before the next `runStarted`. One foreground agent — a run, manual
2344
+ sequence, goal revision, goal creation, or skills preparation — works at a time, in server
2345
+ memory, beside any number of lifecycle lanes. A paused lifecycle's suspended lanes coexist
2346
+ with a manual sequence or revision, and resume waits until that peer ends. Stopping the server terminates its child
2347
+ process and the loop.
2348
+
2349
+ ## Tests
2350
+
2351
+ ```bash
2352
+ cd .devboard
2353
+ bun test
2354
+ bun run verify:interface
2355
+ bun run verify:install
2356
+ bun run verify:update
2357
+ bun run verify:ui
2358
+ bun run verify:issues
2359
+ bun run verify:scout
2360
+ ```
19
2361
 
20
- `update` and every running dashboard read the newest private release through
21
- the authenticated `gh` CLI, verify its `.sha256`, install it into the existing
22
- prefix, and never expose a token. A dashboard installs without restarting and
23
- shows its reload control until someone clicks it; `update` never touches the
24
- dashboard.
2362
+ `verify:interface` drives the IDE shell against a seeded fixture host in headless Chrome and
2363
+ writes its screenshots under `.tmp/interface`. The other `verify:` scripts build fixture releases
2364
+ under `.devboard/.tmp`, install them into throwaway hosts, and assert the launcher, update, and
2365
+ reload contracts end to end. They never touch a real host.
25
2366
 
26
- `doctor` needs Node 20 or newer, Bun, npm, git, an authenticated `gh` account
27
- that can read the release repository, `omp`, and a working install. It reports
28
- wrapper drift, a stale or missing install, a symlinked `.devboard`, a missing
29
- dashboard, and a recorded update failure, and exits nonzero when any check
30
- fails.
2367
+ `bun run verify:issues` is the deterministic end-to-end gate. It starts a source dashboard
2368
+ against a throwaway checkout with stub `gh` and ACP commands, starts the worker, and proves four
2369
+ simultaneous peers started in one poll with exact distinct prompts, per-agent run directories and
2370
+ activity files, per-agent questions answered by run id, an issue opened mid-run, one issue's
2371
+ cooldown leaving the others running, Check now retrying the gated issue, pause draining every peer,
2372
+ Stop closing every peer without a cooldown, connect-time replay of every agent, server-restart
2373
+ reattachment, per-repository isolation, same-repository attach, refusal with another `STATE_DIR`,
2374
+ legacy-lock refusal, that no `/implement-all-issues` prompt is ever sent, and that no systemd unit
2375
+ or timer is written and no `systemctl`/`loginctl` stub is invoked. It writes only under
2376
+ `.devboard/.tmp`.
2377
+ `bun run verify:scout` drives the built dashboard in headless Chrome against a fixture checkout,
2378
+ a stub ACP peer, and a stub `gh`: Start files significant items with no click, a duplicate and a
2379
+ score below 4 are dropped and named in the activity, the issue table shows the title anchor, the
2380
+ creation time, the category, the state, and the shipped version, both limits save and survive a
2381
+ reload, an invalid save shows its error and sends nothing, a close flips the row to implemented,
2382
+ a release published later fills its blank version, a failed read shows `error` with its reason
2383
+ and the last success time while keeping the rows, no review widget or command remains, and Stop
2384
+ leaves the table and a released lock. It writes only under `.devboard/.tmp`.
31
2385
 
32
- `--host <path>` targets a host other than the git root of the current
33
- directory. `DEVBOARD_RELEASE_DIR` points the launcher at a local directory of
34
- release tarballs for offline verification; production runs leave it unset.
2386
+ `server/scout.test.ts` covers recommendation identity and the body's `## Category`, result
2387
+ validation (over-count, malformed item, multi-line title, path escape, excluded tree, duplicate
2388
+ id, out-of-range span, uncited file, shortfall, missing score, out-of-range score, missing
2389
+ reason), the version-2 request, the shipped-version rules (earliest release, tie by tag,
2390
+ timeline commit, newest comment SHA, open issue, no release), settings and state migration,
2391
+ the bridge's limits and absent decision path, the Stop/limits command surface, the Start preflight
2392
+ refusals, and the activity delta.
2393
+ `tests/scout.test.ts` covers filing without a command, the significance score and issue body,
2394
+ duplicates against closed issues, open issues, and drafts, both caps across a restart, the
2395
+ effective batch, zero capacity, a close freeing a slot, limits applying live and surviving a
2396
+ restart, state mapping and a reopen, a later release filling a blank version, a failed periodic
2397
+ read, a periodic read that never settles failing at its deadline and releasing the worker, the
2398
+ default client's bounded `gh` calls, a failed pre-filing read, migration from a version-1 board, a
2399
+ missing score failing the scan atomically, a failed filing, Stop keeping the rows, the live lock,
2400
+ a replacement closing an interrupted scan, bridge replay without a spawn, another `STATE_DIR`
2401
+ refused, a harness-version notice, the issue body and label creation, and the scan prompt's rubric.
2402
+ `tests/issues.test.ts` covers the fleet (four mixed issues, twelve peers with no cap, duplicate
2403
+ prevention, an issue opened mid-run, per-issue success and ledger entries, one failure cooling only
2404
+ its issue), Check now clearing every cooldown, pause draining every peer, Stop closing every peer
2405
+ without a cooldown, per-agent questions and answer routing, two orphan children on a fresh start,
2406
+ version 1 state loading, reopened-issue eligibility, history pruning that keeps every running
2407
+ directory, preflight for both wrappers, repo-scoped state and lock isolation, atomic and damaged
2408
+ state, ledger migration, the activity bounds, and the conditional tracker. `tests/cleanup.test.ts`
2409
+ proves the watchdog preserves repo-scoped issues state, the repo-anchored lock, and Flow session
2410
+ records that Flow restore still reads, while sweeping legacy orchestrator run files. Every fixture
2411
+ stays under `.devboard/.tmp`.
2412
+ `tests/launcher.test.ts` covers the single public package: the manifest, the help output, the
2413
+ tarball's required and forbidden paths, the fallback asset name, and template identity. `lib/inventory.test.ts` covers the publish report. `lib/release.test.ts`
2414
+ covers installing a release when the process working directory has been deleted.
2415
+ `server/updates.test.ts` covers the startup check, the interval, backoff, single-flight, and
2416
+ failure journaling.
2417
+ `tests/install.test.ts` covers the wrapper script: a fresh install, an idempotent rerun that
2418
+ keeps unrelated files, a host without `.devboard/`, a differing file refused without `--force`,
2419
+ `--force` replacing it, and the default host path. `server/run.test.ts` drives the interface
2420
+ against the stub ACP agent, covering sequential
2421
+ resolution in file-name order, selected first records, refusal of a second start during a run
2422
+ and between runs, activity and question/answer relay, the unresolved-record stop, the
2423
+ removed-record stop, disconnect continuation, records refresh, and an idle restart deferred
2424
+ until the active run ends. `server/app.test.ts`
2425
+ covers `getGoal`/`saveGoal`, including verbatim writes, failure responses, and the absence of
2426
+ agent startup. `server/goal-revision.test.ts` covers guided goal revision: request and
2427
+ no-request starts, activity and question relay, the rewritten goal reported verbatim, refusal
2428
+ during lifecycle and record runs (including between records), refusals during a revision,
2429
+ reconnect replay, the unreadable-goal failure, and the absence of run events or ADR writes.
2430
+ `server/goal-creation.test.ts` covers guided goal creation: the `/goal-outcome` prompt, no
2431
+ spawn on connect, activity and question relay, the created goal reported verbatim from
2432
+ `<REPO_DIR>/GOAL.md`, the unreadable goal before and after the peer, an absent goal answered as
2433
+ empty, an empty or whitespace-only result reported as a failed creation that leaves the
2434
+ document unchanged and creation available, every refusal, the
2435
+ single-agent refusals while creating, reconnect replay before the late client's own replies,
2436
+ acceptance while the lifecycle is paused with the paused lane tabs retained, completion without
2437
+ reading or writing `adrs/`, and the custom `GOAL_FILE` split.
2438
+ `server/lifecycle.test.ts` covers the `lifecycleState` sequence, the running `epic` state
2439
+ reported before the first tracker lookup and to every client, stopping a running lifecycle and
2440
+ its in-flight lanes with the idle state that follows, the single `/epic-next`
2441
+ peer, ADR-order resolution, implementation of an already-open epic, return to epic generation
2442
+ on the next pass, the settle window that waits out a lagging tracker after epic generation and
2443
+ after implementation and that finishes when the window is exhausted, a lane that stays open
2444
+ marked `retrying` without stopping the run, retry after the backoff, the parked-only stop, the
2445
+ finished transition when a pass yields no epic and no pending ADR, restart after that
2446
+ transition, the lifecycle and goal-save refusals during implementation plus an empty ADR start,
2447
+ replay of the implementation state and activity to a reconnecting client and
2448
+ of the finished state to a client that connects after the run ends, the absence of a
2449
+ `lifecycleState` before any run, the new run's stage after restarting from `finished`, and the
2450
+ goal precondition: an absent, whitespace-only, or just-cleared goal refuses the start with one
2451
+ `error` and no agent, while a non-blank goal starts the loop. It also covers each abort: a
2452
+ same-open-epic stop, epic generation leaving the same pending records, and a throwing tracker
2453
+ query each end with the idle `goal` state, `work: null`, `paused: false`, and the aborting
2454
+ pass; a later client receives that state first, goal saving answers with the saved text, and
2455
+ a new lifecycle start is accepted and runs without a stale refusal.
2456
+ `server/skills-preparation.test.ts` covers skills preparation: the `prepareSkills` command and
2457
+ its `/prepare-skills` prompt, the project-agnostic prompt rules, one peer, activity and question
2458
+ relay, the non-whitespace goal precondition, every refusal while preparation runs and while each
2459
+ other activity runs, reconnect replay and disk replay after a restart, skill layout/name and
2460
+ description validation, the failure when no managed skill is prepared, managed-only replacement
2461
+ with an unmanaged skill preserved byte for byte, the all-skill contradiction pass, the
2462
+ unmanaged-conflict and name-collision errors, invalid output and peer failure restoring the
2463
+ previous managed set, and the absence of `GOAL.md` or `adrs/` writes.
2464
+ `server/lifecycle-pause.test.ts` covers pausing during an implementation and resuming the same
2465
+ peer, pausing with a pending question and carrying its answer across the pause, pausing between
2466
+ stages while a tracker query is outstanding, pausing the initial tracker lookup and resuming
2467
+ into its result, paused lane replay with recorded activity and question, goal saving while
2468
+ paused, manual ADR resolution while paused and while advancing (which pauses first), automatic
2469
+ resume when the manual sequence ends, stopping a paused run for good, refusal of
2470
+ the lifecycle's own in-progress record and of resume during the manual sequence, retention of
2471
+ the paused lane tabs when the sequence ends, guided goal revision while paused including replay
2472
+ of the paused lanes and every refusal path with unchanged state.
2473
+ `server/parallel-lifecycle.test.ts` covers the parallel pipeline: one overlapping implementation
2474
+ peer per open epic, five lanes with no cap, stopping every running lane at once and returning to
2475
+ the idle stage, generation filing several epics that start their own
2476
+ lanes immediately, refill on close while other lanes keep implementing, coalesced refill
2477
+ triggers, per-epic assignment, retry after backoff, immediate retry when `main` advances, a
2478
+ parked lane isolated from the others and retried on `main`, the parked-only error and idle
2479
+ `goal` stage, reconnect replay of counts, tabs, activity, and a lane question, pause and resume
2480
+ across lanes, reset across lanes, finish when nothing remains, and five-epic tracker reads
2481
+ through the existing GitHub and GitLab query shapes.
2482
+ `server/reset-implementation.test.ts` drives the reset against temporary hosts with local bare
2483
+ remotes: the command and started/stopped events, tracked/untracked/ignored deletion, keep-set
2484
+ bytes, the `.worktree` sentinel, symlink removal without following, the exact commit subject and
2485
+ remote tree, staging exclusions without a `.gitignore`, empty-change idempotence, an absent,
2486
+ empty, or whitespace-only goal, a differently named `GOAL_FILE`, a bypassed failing pre-commit
2487
+ hook, refusal off `main`, without `origin`, outside the top level, with an unborn `HEAD`, and
2488
+ during an active run, a rejecting pre-receive hook retaining the local commit, a commit failure
2489
+ retaining the staged deletion, mutating-command refusal during a reset, replay to a late client,
2490
+ and the post-reset empty records, goal text, `getGoal`, and `saveGoal` behavior.
2491
+ `server/journal.test.ts` covers the dashboard journal: a real stub-agent run writes `activity`,
2492
+ `question`, `run-start`, and `run-stop` records with `ts`, `repo`, `port`, and `pid`; a write
2493
+ failure is reported without interrupting the run; rotation keeps every record; and pruning
2494
+ applies the seven-day limit and the size bound without deleting the live window.
2495
+ `tests/watchdog.test.ts` covers discovery (de-duplication, harness resolution, self-exclusion,
2496
+ legacy logs), the inclusive ten-minute window, omp-log attribution and self-exclusion, the exact
2497
+ evidence mapping (prose never produces a finding), host-independent fingerprints, filing and
2498
+ commenting against a stub `gh`, pending retries after a failed call, duplicate windows, the
2499
+ version 1 ledger migration, and the absence of any commit, merge, push, or publish.
2500
+ `tests/watchdog-api.test.ts` covers the read-only endpoint, the newest-cycle projection, the
2501
+ cycle history's newest-first order, 100-directory cap, `latest.json` fallback, and malformed-report
2502
+ skip, issue row order and fields, open/closed state mapping, timeline and comment shipped-version
2503
+ resolution with the earliest-release rule, the `ok`/`error`/`stale` transitions against a stubbed
2504
+ GitHub reader and clock, the request-never-waits contract, and byte-identical state. All new
2505
+ fixtures live under `.devboard/.tmp`; no test uses the network or a real model.