@dsdenes/devboard 0.1.560 → 0.1.562

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +709 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,1073 @@
1
+ import { fixtureRecord } from "./fixtures/adr-source";
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import type { Server } from "bun";
7
+ import { createApp } from "./app";
8
+ import type { EpicLookup } from "./tracker";
9
+
10
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
11
+ const QUESTION_OPTIONS = [
12
+ { label: "Alpha", description: "Alpha leads to the first outcome.", recommended: true },
13
+ { label: "Beta", description: "Beta leads to the second outcome.", recommended: false },
14
+ ];
15
+ const TEST_ROOT = join(import.meta.dir, "..", ".test-tmp");
16
+ const temporaryDirectories: string[] = [];
17
+
18
+ async function temporaryDirectory() {
19
+ await mkdir(TEST_ROOT, { recursive: true });
20
+ const directory = await mkdtemp(join(TEST_ROOT, "skills-"));
21
+ temporaryDirectories.push(directory);
22
+ return directory;
23
+ }
24
+
25
+ afterAll(async () => {
26
+ await Promise.all(
27
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
28
+ );
29
+ await rm(TEST_ROOT, { recursive: true, force: true });
30
+ });
31
+
32
+ interface TestServer {
33
+ server: Server<unknown>;
34
+ root: string;
35
+ adrDir: string;
36
+ goalFile: string;
37
+ skillsDir: string;
38
+ logPath: string;
39
+ url: string;
40
+ }
41
+
42
+ interface ServerOptions {
43
+ root?: string;
44
+ goalText?: string | null;
45
+ pendingAdr?: boolean;
46
+ files?: Record<string, string>;
47
+ agentArguments?: string;
48
+ tracker?: EpicLookup;
49
+ goalFile?: string;
50
+ }
51
+
52
+ const GOAL_TEXT = "# Goal\n\nShip a documented HTTP API.\n";
53
+
54
+ async function startServer(options: ServerOptions = {}): Promise<TestServer> {
55
+ const root = options.root ?? (await temporaryDirectory());
56
+ const adrDir = join(root, "adrs");
57
+ const staticDir = join(root, "static");
58
+ const goalFile = options.goalFile ?? join(root, "GOAL.md");
59
+ const skillsDir = join(root, ".omp", "skills");
60
+ await mkdir(adrDir, { recursive: true });
61
+ await mkdir(staticDir, { recursive: true });
62
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
63
+
64
+ const goalText = options.goalText === undefined ? GOAL_TEXT : options.goalText;
65
+ if (goalText !== null) await writeFile(goalFile, goalText);
66
+ if (options.pendingAdr === true) {
67
+ await writeFile(join(adrDir, "0001-decision.md"), fixtureRecord("0001-decision.md", "A decision", "proposed"));
68
+ }
69
+ for (const [relative, source] of Object.entries(options.files ?? {})) {
70
+ const path = join(root, relative);
71
+ await mkdir(dirname(path), { recursive: true });
72
+ await writeFile(path, source);
73
+ }
74
+
75
+ const logPath = join(root, "agent.log");
76
+ const command = [`bun ${STUB_AGENT}`, `--log=${logPath}`, options.agentArguments ?? ""]
77
+ .filter((part) => part.length > 0)
78
+ .join(" ");
79
+
80
+ const server = createApp({
81
+ adrDir,
82
+ staticDir,
83
+ hostname: "127.0.0.1",
84
+ port: 0,
85
+ repoDir: root,
86
+ stateDir: join(root, "state"),
87
+ goalFile,
88
+ agentCommand: command,
89
+ tracker: options.tracker ?? (async () => undefined),
90
+ trackerSettle: { attempts: 1 },
91
+ });
92
+
93
+ return { server, root, adrDir, goalFile, skillsDir, logPath, url: `127.0.0.1:${server.port}` };
94
+ }
95
+
96
+ interface TestClient {
97
+ socket: WebSocket;
98
+ events: Record<string, unknown>[];
99
+ send(command: unknown): void;
100
+ waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
101
+ close(): void;
102
+ }
103
+
104
+ function connect(url: string): Promise<TestClient> {
105
+ const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
106
+ const socket = new WebSocket(`ws://${url}/ws`);
107
+ const events: Record<string, unknown>[] = [];
108
+ const buffered: Record<string, unknown>[] = [];
109
+ const waiters: {
110
+ predicate: (message: Record<string, unknown>) => boolean;
111
+ resolve: (message: Record<string, unknown>) => void;
112
+ reject: (error: Error) => void;
113
+ }[] = [];
114
+
115
+ socket.onopen = () => {
116
+ resolve({
117
+ socket,
118
+ events,
119
+ send(command) {
120
+ socket.send(JSON.stringify(command));
121
+ },
122
+ waitFor(predicate) {
123
+ const index = buffered.findIndex(predicate);
124
+ if (index >= 0) {
125
+ const message = buffered.splice(index, 1)[0];
126
+ if (message !== undefined) return Promise.resolve(message);
127
+ }
128
+ const { promise: waiting, resolve: settle, reject: fail } =
129
+ Promise.withResolvers<Record<string, unknown>>();
130
+ waiters.push({ predicate, resolve: settle, reject: fail });
131
+ return waiting;
132
+ },
133
+ close() {
134
+ socket.close();
135
+ },
136
+ });
137
+ };
138
+
139
+ socket.onmessage = (event) => {
140
+ let message: Record<string, unknown>;
141
+ try {
142
+ message = JSON.parse(String(event.data)) as Record<string, unknown>;
143
+ } catch {
144
+ return;
145
+ }
146
+ events.push(message);
147
+ const index = waiters.findIndex((waiter) => waiter.predicate(message));
148
+ if (index >= 0) {
149
+ const waiter = waiters.splice(index, 1)[0];
150
+ if (waiter !== undefined) waiter.resolve(message);
151
+ } else {
152
+ buffered.push(message);
153
+ }
154
+ };
155
+
156
+ socket.onerror = () => reject(new Error("websocket failed"));
157
+ socket.onclose = () => {
158
+ for (const waiter of waiters.splice(0)) {
159
+ waiter.reject(new Error("websocket closed"));
160
+ }
161
+ };
162
+
163
+ return promise;
164
+ }
165
+
166
+ function logLines(logPath: string): string[] {
167
+ if (!existsSync(logPath)) return [];
168
+ return readFileSync(logPath, "utf8").split("\n").filter((line) => line.length > 0);
169
+ }
170
+
171
+ function spawnCount(logPath: string): number {
172
+ return logLines(logPath).filter((line) => line.startsWith("spawn ")).length;
173
+ }
174
+
175
+ function prompts(logPath: string): string[] {
176
+ return logLines(logPath)
177
+ .filter((line) => line.startsWith("prompt "))
178
+ .map((line) => line.slice("prompt ".length));
179
+ }
180
+
181
+ function errors(client: TestClient): Record<string, unknown>[] {
182
+ return client.events.filter((message) => message.type === "error");
183
+ }
184
+
185
+ async function waitUntil(predicate: () => boolean, label: string, timeout = 8000) {
186
+ const deadline = Date.now() + timeout;
187
+ while (!predicate() && Date.now() < deadline) await Bun.sleep(20);
188
+ if (!predicate()) throw new Error(label);
189
+ }
190
+
191
+ function managedSkill(name: string, directive: string): string {
192
+ return [
193
+ "---",
194
+ `name: ${name}`,
195
+ `description: Guidance for ${name}.`,
196
+ "metadata:",
197
+ ' devboard-managed: "true"',
198
+ "---",
199
+ "",
200
+ `# ${name}`,
201
+ "",
202
+ `- ${directive}`,
203
+ "",
204
+ ].join("\n");
205
+ }
206
+
207
+ function unmanagedSkill(name: string, directive: string): string {
208
+ return [
209
+ "---",
210
+ `name: ${name}`,
211
+ `description: Hand-authored guidance for ${name}.`,
212
+ "---",
213
+ "",
214
+ `# ${name}`,
215
+ "",
216
+ `- ${directive}`,
217
+ "",
218
+ ].join("\n");
219
+ }
220
+
221
+ function skillDirectories(root: string): string[] {
222
+ if (!existsSync(root)) return [];
223
+ return readdirSync(root, { withFileTypes: true })
224
+ .filter((entry) => entry.isDirectory())
225
+ .map((entry) => entry.name)
226
+ .sort();
227
+ }
228
+
229
+ function directoryBytes(root: string): Map<string, string> {
230
+ const files = new Map<string, string>();
231
+ if (!existsSync(root)) return files;
232
+ const walk = (relative: string) => {
233
+ const directory = relative.length === 0 ? root : join(root, relative);
234
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
235
+ const next = relative.length === 0 ? entry.name : `${relative}/${entry.name}`;
236
+ if (entry.isDirectory()) {
237
+ walk(next);
238
+ } else if (entry.isFile()) {
239
+ files.set(next, readFileSync(join(root, next), "utf8"));
240
+ }
241
+ }
242
+ };
243
+ walk("");
244
+ return files;
245
+ }
246
+
247
+ function frontmatter(source: string): Record<string, string> {
248
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
249
+ if (match === null) return {};
250
+ const fields: Record<string, string> = {};
251
+ let section: string | undefined;
252
+ for (const line of (match[1] ?? "").split(/\r?\n/)) {
253
+ const trimmed = line.trim();
254
+ const pair = /^([^:]+):\s*(.*)$/.exec(trimmed);
255
+ if (pair === null) continue;
256
+ const key = pair[1]?.trim() ?? "";
257
+ const value = (pair[2] ?? "").trim().replace(/^"(.*)"$/, "$1");
258
+ if (/^\s/.test(line) && section !== undefined) {
259
+ fields[`${section}.${key}`] = value;
260
+ } else if (value.length === 0) {
261
+ section = key;
262
+ } else {
263
+ section = undefined;
264
+ fields[key] = value;
265
+ }
266
+ }
267
+ return fields;
268
+ }
269
+
270
+ function skillNames(message: Record<string, unknown>): string[] {
271
+ const skills = Array.isArray(message.skills) ? message.skills : [];
272
+ return skills
273
+ .map((skill) =>
274
+ typeof skill === "object" && skill !== null && "name" in skill
275
+ ? String(skill.name)
276
+ : "",
277
+ )
278
+ .sort();
279
+ }
280
+
281
+ function skillSummaries(message: Record<string, unknown>): Record<string, unknown>[] {
282
+ return Array.isArray(message.skills) ? (message.skills as Record<string, unknown>[]) : [];
283
+ }
284
+
285
+ describe("skills preparation", () => {
286
+ test(
287
+ "starts one peer with /prepare-skills, relays activity and questions, and reports the prepared set",
288
+ async () => {
289
+ const { server, root, adrDir, goalFile, skillsDir, logPath, url } = await startServer({
290
+ agentArguments: "--prepare-skills --questions=1",
291
+ });
292
+ const client = await connect(url);
293
+ const goalBefore = await readFile(goalFile, "utf8");
294
+ const adrBefore = directoryBytes(adrDir);
295
+
296
+ try {
297
+ client.send({ type: "prepareSkills" });
298
+
299
+ expect(await client.waitFor((message) => message.type === "skillsPreparationStarted")).toMatchObject(
300
+ { type: "skillsPreparationStarted" },
301
+ );
302
+ expect(
303
+ await client.waitFor(
304
+ (message) =>
305
+ message.type === "activity" &&
306
+ String(message.text).includes("Researching public standards"),
307
+ ),
308
+ ).toBeDefined();
309
+ const question = await client.waitFor((message) => message.type === "question");
310
+ expect(question).toMatchObject({ message: "Choose an answer", options: QUESTION_OPTIONS });
311
+
312
+ client.send({ type: "answer", id: question.id, value: "Alpha" });
313
+ expect(
314
+ await client.waitFor(
315
+ (message) =>
316
+ message.type === "activity" &&
317
+ String(message.text).includes("Answer received: Alpha"),
318
+ ),
319
+ ).toBeDefined();
320
+
321
+ const stopped = await client.waitFor(
322
+ (message) => message.type === "skillsPreparationStopped",
323
+ );
324
+ expect(stopped).toMatchObject({ completed: true, contradictions: [] });
325
+ expect(skillSummaries(stopped).length).toBeGreaterThanOrEqual(1);
326
+ for (const summary of skillSummaries(stopped)) {
327
+ expect(Object.keys(summary).sort()).toEqual([
328
+ "content",
329
+ "description",
330
+ "family",
331
+ "name",
332
+ "state",
333
+ ]);
334
+ expect(summary.family).toBe("prepare");
335
+ expect(summary.state).toBe("generated");
336
+ expect(String(summary.content)).toContain(`name: ${String(summary.name)}`);
337
+ }
338
+
339
+ expect(spawnCount(logPath)).toBe(1);
340
+ expect(prompts(logPath)).toEqual(["/prepare-skills"]);
341
+ expect(client.events.some((message) => message.type === "runStarted")).toBe(false);
342
+ expect(client.events.some((message) => message.type === "runStopped")).toBe(false);
343
+
344
+ const directories = skillDirectories(skillsDir);
345
+ expect(directories.length).toBeGreaterThanOrEqual(1);
346
+ for (const name of directories) {
347
+ const source = await readFile(join(skillsDir, name, "SKILL.md"), "utf8");
348
+ const fields = frontmatter(source);
349
+ expect(fields.name).toBe(name);
350
+ expect(name.length).toBeGreaterThan(0);
351
+ expect(name.length).toBeLessThanOrEqual(64);
352
+ expect(/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)).toBe(true);
353
+ expect(fields.description?.trim().length ?? 0).toBeGreaterThan(0);
354
+ expect((fields.description ?? "").length).toBeLessThanOrEqual(1024);
355
+ expect(fields["metadata.devboard-managed"]).toBe("true");
356
+ expect(Object.keys(fields).filter((key) => key.startsWith("metadata."))).toEqual([
357
+ "metadata.devboard-managed",
358
+ ]);
359
+ }
360
+ expect(skillNames(stopped).sort()).toEqual(directories);
361
+
362
+ expect(await readFile(goalFile, "utf8")).toBe(goalBefore);
363
+ expect(directoryBytes(adrDir)).toEqual(adrBefore);
364
+ } finally {
365
+ client.close();
366
+ server.stop(true);
367
+ }
368
+ },
369
+ 15000,
370
+ );
371
+
372
+ test(
373
+ "refuses without a non-whitespace goal and never starts on load",
374
+ async () => {
375
+ for (const goalText of [null, "", " \n\t"]) {
376
+ const { server, logPath, url } = await startServer({
377
+ goalText,
378
+ agentArguments: "--prepare-skills",
379
+ });
380
+ const client = await connect(url);
381
+ try {
382
+ client.send({ type: "getSkills" });
383
+ expect(await client.waitFor((message) => message.type === "skillsState")).toBeDefined();
384
+ client.send({ type: "getGoal" });
385
+ await client.waitFor(
386
+ (message) => message.type === "goal" && String(message.text).trim().length === 0,
387
+ );
388
+ expect(spawnCount(logPath)).toBe(0);
389
+
390
+ client.send({ type: "prepareSkills" });
391
+ const failure = await client.waitFor((message) => message.type === "error");
392
+ expect(String(failure.message)).toMatch(/goal is not set/i);
393
+ expect(
394
+ client.events.some((message) => message.type === "skillsPreparationStarted"),
395
+ ).toBe(false);
396
+ expect(spawnCount(logPath)).toBe(0);
397
+ } finally {
398
+ client.close();
399
+ server.stop(true);
400
+ }
401
+ }
402
+ },
403
+ 15000,
404
+ );
405
+
406
+ test(
407
+ "refuses every other command while skills preparation is active",
408
+ async () => {
409
+ const { server, logPath, url } = await startServer({
410
+ agentArguments: "--prepare-skills --questions=1",
411
+ });
412
+ const client = await connect(url);
413
+
414
+ try {
415
+ client.send({ type: "prepareSkills" });
416
+ await client.waitFor((message) => message.type === "skillsPreparationStarted");
417
+
418
+ const commands = [
419
+ { type: "startRun" },
420
+ { type: "startLifecycle" },
421
+ { type: "resumeLifecycle" },
422
+ { type: "reviseGoal" },
423
+ { type: "saveGoal", text: "A different goal" },
424
+ { type: "createGoal" },
425
+ { type: "prepareSkills" },
426
+ ];
427
+ for (const command of commands) client.send(command);
428
+ await waitUntil(() => errors(client).length === commands.length, "refusal errors");
429
+
430
+ for (const failure of errors(client)) {
431
+ expect(String(failure.message)).toMatch(/skills preparation/i);
432
+ }
433
+ await waitUntil(() => spawnCount(logPath) === 1, "one preparation peer");
434
+ expect(spawnCount(logPath)).toBe(1);
435
+ expect(
436
+ client.events.some((message) => message.type === "lifecycleState"),
437
+ ).toBe(false);
438
+
439
+ await client.waitFor((message) => message.type === "question");
440
+ client.send({ type: "answer", id: 1, value: "Alpha" });
441
+ const stopped = await client.waitFor(
442
+ (message) => message.type === "skillsPreparationStopped",
443
+ );
444
+ expect(stopped.completed).toBe(true);
445
+ } finally {
446
+ client.close();
447
+ server.stop(true);
448
+ }
449
+ },
450
+ 15000,
451
+ );
452
+
453
+ test(
454
+ "accepts only one of two commands sent back to back",
455
+ async () => {
456
+ const { server, logPath, url } = await startServer({
457
+ agentArguments: "--prepare-skills",
458
+ });
459
+ const client = await connect(url);
460
+
461
+ try {
462
+ client.send({ type: "prepareSkills" });
463
+ client.send({ type: "prepareSkills" });
464
+
465
+ const stopped = await client.waitFor(
466
+ (message) => message.type === "skillsPreparationStopped",
467
+ );
468
+ expect(stopped.completed).toBe(true);
469
+ expect(
470
+ client.events.filter((message) => message.type === "skillsPreparationStarted").length,
471
+ ).toBe(1);
472
+ expect(errors(client)).toHaveLength(1);
473
+ expect(String(errors(client)[0]?.message)).toMatch(/skills preparation/i);
474
+ await waitUntil(() => spawnCount(logPath) === 1, "one preparation peer");
475
+ expect(spawnCount(logPath)).toBe(1);
476
+ } finally {
477
+ client.close();
478
+ server.stop(true);
479
+ }
480
+ },
481
+ 15000,
482
+ );
483
+
484
+ test(
485
+ "refuses preparation during a run",
486
+ async () => {
487
+ const { server, logPath, url } = await startServer({
488
+ pendingAdr: true,
489
+ agentArguments: "--prepare-skills --delay-ms=600 --questions=0",
490
+ });
491
+ const client = await connect(url);
492
+ try {
493
+ client.send({ type: "startRun" });
494
+ await client.waitFor((message) => message.type === "runStarted");
495
+ client.send({ type: "prepareSkills" });
496
+ const failure = await client.waitFor((message) => message.type === "error");
497
+ expect(String(failure.message)).toMatch(/run is already active/i);
498
+ await waitUntil(() => spawnCount(logPath) === 1, "run peer spawned");
499
+ expect(spawnCount(logPath)).toBe(1);
500
+ await client.waitFor((message) => message.type === "runStopped");
501
+ } finally {
502
+ client.close();
503
+ server.stop(true);
504
+ }
505
+ },
506
+ 15000,
507
+ );
508
+
509
+ test(
510
+ "refuses preparation during a paused lifecycle",
511
+ async () => {
512
+ const { server, logPath, url } = await startServer({
513
+ files: {
514
+ ".omp/skills/behavior-first-testing/SKILL.md": managedSkill(
515
+ "behavior-first-testing",
516
+ "Prefer observable behavior.",
517
+ ),
518
+ },
519
+ agentArguments: "--prepare-skills --delay-ms=500",
520
+ });
521
+ const client = await connect(url);
522
+ try {
523
+ client.send({ type: "startLifecycle" });
524
+ await client.waitFor(
525
+ (message) => message.type === "lifecycleState" && message.stage === "epic",
526
+ );
527
+ client.send({ type: "pauseLifecycle" });
528
+ await client.waitFor(
529
+ (message) => message.type === "lifecycleState" && message.paused === true,
530
+ );
531
+ const spawned = spawnCount(logPath);
532
+ client.send({ type: "prepareSkills" });
533
+ const failure = await client.waitFor((message) => message.type === "error");
534
+ expect(String(failure.message)).toMatch(/lifecycle run is active/i);
535
+ expect(spawnCount(logPath)).toBe(spawned);
536
+ client.send({ type: "resumeLifecycle" });
537
+ await client.waitFor(
538
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
539
+ );
540
+ } finally {
541
+ client.close();
542
+ server.stop(true);
543
+ }
544
+ },
545
+ 15000,
546
+ );
547
+
548
+ test(
549
+ "refuses preparation during a goal creation",
550
+ async () => {
551
+ const root = await temporaryDirectory();
552
+ const goalFile = join(root, "GOAL.md");
553
+ const { server, logPath, url } = await startServer({
554
+ root,
555
+ goalText: null,
556
+ agentArguments: `--prepare-skills --create-goal --goal=${goalFile}`,
557
+ });
558
+ const client = await connect(url);
559
+ try {
560
+ client.send({ type: "createGoal" });
561
+ await client.waitFor((message) => message.type === "goalCreationStarted");
562
+ client.send({ type: "prepareSkills" });
563
+ const failure = await client.waitFor((message) => message.type === "error");
564
+ expect(String(failure.message)).toMatch(/goal creation is active/i);
565
+ await waitUntil(() => spawnCount(logPath) === 1, "creation peer spawned");
566
+ expect(spawnCount(logPath)).toBe(1);
567
+ } finally {
568
+ client.close();
569
+ server.stop(true);
570
+ }
571
+ },
572
+ 15000,
573
+ );
574
+
575
+ test(
576
+ "refuses preparation during a goal revision",
577
+ async () => {
578
+ const root = await temporaryDirectory();
579
+ const goalFile = join(root, "GOAL.md");
580
+ const { server, logPath, url } = await startServer({
581
+ root,
582
+ goalFile,
583
+ agentArguments: `--prepare-skills --revise-goal --goal=${goalFile}`,
584
+ });
585
+ const client = await connect(url);
586
+ try {
587
+ client.send({ type: "reviseGoal" });
588
+ await client.waitFor((message) => message.type === "goalRevisionStarted");
589
+ client.send({ type: "prepareSkills" });
590
+ const failure = await client.waitFor((message) => message.type === "error");
591
+ expect(String(failure.message)).toMatch(/goal revision is active/i);
592
+ await waitUntil(() => spawnCount(logPath) === 1, "revision peer spawned");
593
+ expect(spawnCount(logPath)).toBe(1);
594
+ } finally {
595
+ client.close();
596
+ server.stop(true);
597
+ }
598
+ },
599
+ 15000,
600
+ );
601
+
602
+ test(
603
+ "replays preparation on reconnect and the prepared set across restarts",
604
+ async () => {
605
+ const first = await startServer({
606
+ agentArguments: "--prepare-skills --questions=1",
607
+ });
608
+ const leader = await connect(first.url);
609
+ const follower = await connect(first.url);
610
+
611
+ try {
612
+ leader.send({ type: "prepareSkills" });
613
+ await leader.waitFor((message) => message.type === "skillsPreparationStarted");
614
+ await leader.waitFor(
615
+ (message) =>
616
+ message.type === "activity" && String(message.text).includes("Researching"),
617
+ );
618
+
619
+ const replayedStart = await follower.waitFor(
620
+ (message) => message.type === "skillsPreparationStarted",
621
+ );
622
+ expect(replayedStart).toBeDefined();
623
+ const replayedQuestion = await follower.waitFor(
624
+ (message) => message.type === "question",
625
+ );
626
+ expect(replayedQuestion.id).toBe(1);
627
+ await follower.waitFor(
628
+ (message) =>
629
+ message.type === "activity" && String(message.text).includes("Researching"),
630
+ );
631
+
632
+ follower.send({ type: "answer", id: replayedQuestion.id, value: "Beta" });
633
+ const stopped = await leader.waitFor(
634
+ (message) => message.type === "skillsPreparationStopped",
635
+ );
636
+ expect(stopped.completed).toBe(true);
637
+ const preparedNames = skillNames(stopped);
638
+
639
+ const reloaded = await connect(first.url);
640
+ reloaded.send({ type: "getSkills" });
641
+ const state = await reloaded.waitFor((message) => message.type === "skillsState");
642
+ expect(skillNames(state)).toEqual(preparedNames);
643
+ expect(state.contradictions).toEqual([]);
644
+ reloaded.close();
645
+
646
+ follower.close();
647
+ leader.close();
648
+ first.server.stop(true);
649
+
650
+ const restarted = await startServer({ root: first.root });
651
+ const afterRestart = await connect(restarted.url);
652
+ try {
653
+ afterRestart.send({ type: "getSkills" });
654
+ const replay = await afterRestart.waitFor((message) => message.type === "skillsState");
655
+ expect(skillNames(replay)).toEqual(preparedNames);
656
+ expect(replay.contradictions).toEqual([]);
657
+ } finally {
658
+ afterRestart.close();
659
+ restarted.server.stop(true);
660
+ }
661
+ } finally {
662
+ leader.close();
663
+ follower.close();
664
+ first.server.stop(true);
665
+ }
666
+ },
667
+ 20000,
668
+ );
669
+
670
+ test(
671
+ "a second run replaces managed skills and preserves hand-authored skills byte for byte",
672
+ async () => {
673
+ const { server, skillsDir, logPath, url } = await startServer({
674
+ agentArguments: "--prepare-skills",
675
+ });
676
+ const client = await connect(url);
677
+ const unmanagedSource = unmanagedSkill("house-style", "Prefer one response shape.");
678
+ const unmanagedPath = join(skillsDir, "house-style", "SKILL.md");
679
+
680
+ try {
681
+ client.send({ type: "prepareSkills" });
682
+ const first = await client.waitFor(
683
+ (message) => message.type === "skillsPreparationStopped",
684
+ );
685
+ expect(first.completed).toBe(true);
686
+ const firstNames = skillNames(first);
687
+
688
+ await mkdir(dirname(unmanagedPath), { recursive: true });
689
+ await writeFile(unmanagedPath, unmanagedSource);
690
+ await mkdir(join(skillsDir, "legacy-managed"), { recursive: true });
691
+ await writeFile(
692
+ join(skillsDir, "legacy-managed", "SKILL.md"),
693
+ managedSkill("legacy-managed", "architecture"),
694
+ );
695
+
696
+ client.send({ type: "prepareSkills" });
697
+ const second = await client.waitFor(
698
+ (message) => message.type === "skillsPreparationStopped",
699
+ );
700
+ expect(second.completed).toBe(true);
701
+ expect(skillNames(second)).toEqual(firstNames);
702
+ expect(skillDirectories(skillsDir)).toEqual(
703
+ [...firstNames, "house-style"].sort(),
704
+ );
705
+ expect(await readFile(unmanagedPath, "utf8")).toBe(unmanagedSource);
706
+ expect(spawnCount(logPath)).toBe(2);
707
+
708
+ const reloaded = await connect(url);
709
+ try {
710
+ reloaded.send({ type: "getSkills" });
711
+ const state = await reloaded.waitFor((message) => message.type === "skillsState");
712
+ expect(skillNames(state)).toEqual(firstNames);
713
+ } finally {
714
+ reloaded.close();
715
+ }
716
+ } finally {
717
+ client.close();
718
+ server.stop(true);
719
+ }
720
+ },
721
+ 20000,
722
+ );
723
+
724
+ test(
725
+ "fails on a contradiction with an unmanaged skill, names it, and preserves every file",
726
+ async () => {
727
+ const managedBefore = {
728
+ "dependency-direction": managedSkill(
729
+ "dependency-direction",
730
+ "Keep policy separate from delivery and storage concerns.",
731
+ ),
732
+ "cohesive-units": managedSkill(
733
+ "cohesive-units",
734
+ "Prefer one responsibility per unit.",
735
+ ),
736
+ "behavior-first-testing": managedSkill(
737
+ "behavior-first-testing",
738
+ "Test the published behavior.",
739
+ ),
740
+ };
741
+ const files: Record<string, string> = {};
742
+ for (const [name, source] of Object.entries(managedBefore)) {
743
+ files[`.omp/skills/${name}/SKILL.md`] = source;
744
+ }
745
+ files[".omp/skills/house-style/SKILL.md"] = unmanagedSkill(
746
+ "house-style",
747
+ "Never use dependency injection.",
748
+ );
749
+
750
+ const { server, skillsDir, goalFile, adrDir, url } = await startServer({
751
+ files,
752
+ agentArguments: "--prepare-skills --prepare-skills-conflict",
753
+ });
754
+ const client = await connect(url);
755
+ const goalBefore = await readFile(goalFile, "utf8");
756
+ const adrBefore = directoryBytes(adrDir);
757
+
758
+ try {
759
+ client.send({ type: "prepareSkills" });
760
+ const failure = await client.waitFor(
761
+ (message) => message.type === "error" && /contradiction/i.test(String(message.message)),
762
+ );
763
+ expect(String(failure.message)).toContain("house-style");
764
+
765
+ const stopped = await client.waitFor(
766
+ (message) => message.type === "skillsPreparationStopped",
767
+ );
768
+ expect(stopped.completed).toBe(false);
769
+ expect(stopped.skills).toBeUndefined();
770
+
771
+ expect(await readFile(join(skillsDir, "house-style", "SKILL.md"), "utf8")).toBe(
772
+ files[".omp/skills/house-style/SKILL.md"],
773
+ );
774
+ for (const [name, source] of Object.entries(managedBefore)) {
775
+ expect(await readFile(join(skillsDir, name, "SKILL.md"), "utf8")).toBe(source);
776
+ }
777
+ expect(skillDirectories(skillsDir)).toEqual(
778
+ [...Object.keys(managedBefore), "house-style"].sort(),
779
+ );
780
+
781
+ const reloaded = await connect(url);
782
+ try {
783
+ reloaded.send({ type: "getSkills" });
784
+ const state = await reloaded.waitFor((message) => message.type === "skillsState");
785
+ expect(skillNames(state)).toEqual(Object.keys(managedBefore).sort());
786
+ } finally {
787
+ reloaded.close();
788
+ }
789
+
790
+ expect(await readFile(goalFile, "utf8")).toBe(goalBefore);
791
+ expect(directoryBytes(adrDir)).toEqual(adrBefore);
792
+ } finally {
793
+ client.close();
794
+ server.stop(true);
795
+ }
796
+ },
797
+ 20000,
798
+ );
799
+
800
+ test(
801
+ "fails on a managed name colliding with an unmanaged skill and preserves the unmanaged file",
802
+ async () => {
803
+ const unmanagedSource = unmanagedSkill("custom-skill", "Keep the local convention.");
804
+ const managedBefore = managedSkill("behavior-first-testing", "Test observable behavior.");
805
+ const { server, skillsDir, url } = await startServer({
806
+ files: {
807
+ ".omp/skills/custom-skill/SKILL.md": unmanagedSource,
808
+ ".omp/skills/behavior-first-testing/SKILL.md": managedBefore,
809
+ },
810
+ agentArguments: "--prepare-skills --prepare-skills-collision",
811
+ });
812
+ const client = await connect(url);
813
+
814
+ try {
815
+ client.send({ type: "prepareSkills" });
816
+ const failure = await client.waitFor(
817
+ (message) => message.type === "error" && /custom-skill/.test(String(message.message)),
818
+ );
819
+ expect(String(failure.message)).toMatch(/collid|unmanaged/i);
820
+ const stopped = await client.waitFor(
821
+ (message) => message.type === "skillsPreparationStopped",
822
+ );
823
+ expect(stopped.completed).toBe(false);
824
+
825
+ expect(await readFile(join(skillsDir, "custom-skill", "SKILL.md"), "utf8")).toBe(
826
+ unmanagedSource,
827
+ );
828
+ expect(await readFile(join(skillsDir, "behavior-first-testing", "SKILL.md"), "utf8")).toBe(
829
+ managedBefore,
830
+ );
831
+ } finally {
832
+ client.close();
833
+ server.stop(true);
834
+ }
835
+ },
836
+ 15000,
837
+ );
838
+
839
+ test(
840
+ "fails on invalid skill output and leaves the previous managed set unchanged",
841
+ async () => {
842
+ const managedBefore = managedSkill("behavior-first-testing", "Test behavior.");
843
+ const { server, skillsDir, url } = await startServer({
844
+ files: { ".omp/skills/behavior-first-testing/SKILL.md": managedBefore },
845
+ agentArguments: "--prepare-skills --prepare-skills-invalid",
846
+ });
847
+ const client = await connect(url);
848
+
849
+ try {
850
+ client.send({ type: "prepareSkills" });
851
+ const failure = await client.waitFor(
852
+ (message) => message.type === "error" && /invalid-skill/.test(String(message.message)),
853
+ );
854
+ expect(String(failure.message)).toMatch(/description|name/i);
855
+ const stopped = await client.waitFor(
856
+ (message) => message.type === "skillsPreparationStopped",
857
+ );
858
+ expect(stopped.completed).toBe(false);
859
+
860
+ expect(await readFile(join(skillsDir, "behavior-first-testing", "SKILL.md"), "utf8")).toBe(
861
+ managedBefore,
862
+ );
863
+ expect(skillDirectories(skillsDir)).toEqual(["behavior-first-testing"]);
864
+ } finally {
865
+ client.close();
866
+ server.stop(true);
867
+ }
868
+ },
869
+ 15000,
870
+ );
871
+
872
+ test(
873
+ "fails when the peer exits without completing and restores the previous managed set",
874
+ async () => {
875
+ const managedBefore = managedSkill("behavior-first-testing", "Test behavior.");
876
+ const { server, skillsDir, url } = await startServer({
877
+ files: { ".omp/skills/behavior-first-testing/SKILL.md": managedBefore },
878
+ agentArguments: "--prepare-skills --prepare-skills-error",
879
+ });
880
+ const client = await connect(url);
881
+
882
+ try {
883
+ client.send({ type: "prepareSkills" });
884
+ const failure = await client.waitFor((message) => message.type === "error");
885
+ expect(String(failure.message)).toMatch(/exited/i);
886
+ const stopped = await client.waitFor(
887
+ (message) => message.type === "skillsPreparationStopped",
888
+ );
889
+ expect(stopped.completed).toBe(false);
890
+ expect(await readFile(join(skillsDir, "behavior-first-testing", "SKILL.md"), "utf8")).toBe(
891
+ managedBefore,
892
+ );
893
+ expect(skillDirectories(skillsDir)).toEqual(["behavior-first-testing"]);
894
+ } finally {
895
+ client.close();
896
+ server.stop(true);
897
+ }
898
+ },
899
+ 15000,
900
+ );
901
+
902
+ test(
903
+ "ends an unreadable goal with an error and a failed stop without spawning a peer",
904
+ async () => {
905
+ const { server, goalFile, logPath, url } = await startServer({
906
+ goalText: null,
907
+ agentArguments: "--prepare-skills",
908
+ });
909
+ await mkdir(goalFile, { recursive: true });
910
+ const client = await connect(url);
911
+
912
+ try {
913
+ client.send({ type: "prepareSkills" });
914
+ const failure = await client.waitFor((message) => message.type === "error");
915
+ expect(String(failure.message)).toMatch(/could not read the goal/i);
916
+ const stopped = await client.waitFor(
917
+ (message) => message.type === "skillsPreparationStopped",
918
+ );
919
+ expect(stopped).toMatchObject({ completed: false });
920
+ expect(spawnCount(logPath)).toBe(0);
921
+ expect(
922
+ client.events.some((message) => message.type === "skillsPreparationStarted"),
923
+ ).toBe(false);
924
+ } finally {
925
+ client.close();
926
+ server.stop(true);
927
+ }
928
+ },
929
+ 15000,
930
+ );
931
+
932
+ test(
933
+ "fails when the run leaves no managed skill and restores the previous set",
934
+ async () => {
935
+ const managedBefore = managedSkill("behavior-first-testing", "Test behavior.");
936
+ const unmanagedSource = unmanagedSkill("house-style", "Prefer one response shape.");
937
+ const { server, skillsDir, url } = await startServer({
938
+ files: {
939
+ ".omp/skills/behavior-first-testing/SKILL.md": managedBefore,
940
+ ".omp/skills/house-style/SKILL.md": unmanagedSource,
941
+ },
942
+ agentArguments: "--prepare-skills --prepare-skills-empty",
943
+ });
944
+ const client = await connect(url);
945
+
946
+ try {
947
+ client.send({ type: "prepareSkills" });
948
+ const failure = await client.waitFor(
949
+ (message) => message.type === "error" && /managed/i.test(String(message.message)),
950
+ );
951
+ expect(String(failure.message)).toMatch(/no managed skill/i);
952
+ const stopped = await client.waitFor(
953
+ (message) => message.type === "skillsPreparationStopped",
954
+ );
955
+ expect(stopped.completed).toBe(false);
956
+ expect(stopped.skills).toBeUndefined();
957
+
958
+ expect(await readFile(join(skillsDir, "behavior-first-testing", "SKILL.md"), "utf8")).toBe(
959
+ managedBefore,
960
+ );
961
+ expect(await readFile(join(skillsDir, "house-style", "SKILL.md"), "utf8")).toBe(
962
+ unmanagedSource,
963
+ );
964
+ expect(skillDirectories(skillsDir)).toEqual(["behavior-first-testing", "house-style"]);
965
+ } finally {
966
+ client.close();
967
+ server.stop(true);
968
+ }
969
+ },
970
+ 15000,
971
+ );
972
+
973
+ test("deletes one prepared skill and refuses every other owner", async () => {
974
+ const skill = (name: string, family: string | null) =>
975
+ [
976
+ "---",
977
+ `name: ${name}`,
978
+ `description: The ${name} policy.`,
979
+ "metadata:",
980
+ ' devboard-managed: "true"',
981
+ ...(family === null ? [] : [` devboard-family: ${family}`]),
982
+ "---",
983
+ "",
984
+ `# ${name}`,
985
+ "",
986
+ "- Keep one policy per bullet.",
987
+ "",
988
+ ].join("\n");
989
+ const fixture = await startServer({
990
+ files: {
991
+ ".omp/skills/tidy-commits/SKILL.md": skill("tidy-commits", null),
992
+ ".omp/skills/discover-naming/SKILL.md": skill("discover-naming", "discover"),
993
+ ".omp/skills/hand-written/SKILL.md": [
994
+ "---",
995
+ "name: hand-written",
996
+ "description: The developer's own policy.",
997
+ "---",
998
+ "",
999
+ "- Do not touch this file.",
1000
+ "",
1001
+ ].join("\n"),
1002
+ },
1003
+ });
1004
+ const client = await connect(fixture.url);
1005
+ try {
1006
+ client.send({ type: "deleteSkill", name: "tidy-commits" });
1007
+ const state = await client.waitFor((message) => message.type === "skillsState");
1008
+ const names = (state.skills as Array<{ name: string }>).map((entry) => entry.name);
1009
+ expect(names).not.toContain("tidy-commits");
1010
+ expect(existsSync(join(fixture.skillsDir, "tidy-commits"))).toBe(false);
1011
+
1012
+ for (const name of ["discover-naming", "hand-written"]) {
1013
+ client.send({ type: "deleteSkill", name });
1014
+ const error = await client.waitFor((message) => message.type === "error");
1015
+ expect(String(error.message)).toContain(`No prepared skill named "${name}"`);
1016
+ }
1017
+ client.send({ type: "deleteSkill", name: "../tidy-commits" });
1018
+ const invalid = await client.waitFor((message) => message.type === "error");
1019
+ expect(String(invalid.message)).toBe("Choose a prepared skill to delete.");
1020
+
1021
+ expect(existsSync(join(fixture.skillsDir, "hand-written", "SKILL.md"))).toBe(true);
1022
+ expect(existsSync(join(fixture.skillsDir, "discover-naming", "SKILL.md"))).toBe(true);
1023
+ } finally {
1024
+ client.close();
1025
+ fixture.server.stop(true);
1026
+ }
1027
+ });
1028
+
1029
+ test("the installed command reads the harness prompt", async () => {
1030
+ const prompt = await readFile(join(import.meta.dir, "..", "prompts", "prepare-skills.md"), "utf8");
1031
+ const command = await readFile(
1032
+ join(import.meta.dir, "..", "omp", "commands", "prepare-skills.md"),
1033
+ "utf8",
1034
+ );
1035
+ const stub = await readFile(join(import.meta.dir, "fixtures", "stub-agent.ts"), "utf8");
1036
+ const categoryMarker = ["devboard", "category"].join("-");
1037
+
1038
+ expect(prompt).toContain("project-agnostic");
1039
+ expect(prompt).toContain("GOAL.md` is the only project-specific input");
1040
+ expect(prompt).toMatch(/only to select which\s+public standards and pattern families/i);
1041
+ expect(prompt).toContain("public standards and specifications");
1042
+ expect(prompt).toContain("general engineering best practices");
1043
+ expect(prompt).toContain("software-architecture patterns");
1044
+ expect(prompt).toContain("testing patterns");
1045
+ expect(prompt).toMatch(/halt: web research is unavailable/);
1046
+ expect(prompt).toMatch(/no project-specific implementation detail/i);
1047
+ expect(prompt).toMatch(/absolute path/);
1048
+ expect(prompt).toMatch(/programming language/);
1049
+ expect(prompt).toMatch(/Public domain names, formats, and standard identifiers are allowed/i);
1050
+ expect(prompt).toMatch(/At least one skill is required/i);
1051
+ expect(prompt).toMatch(/there is no maximum and no required topic/i);
1052
+ expect(prompt).toContain("metadata:");
1053
+ expect(prompt).toContain('devboard-managed: "true"');
1054
+ expect(prompt).toContain("^[a-z0-9]+(-[a-z0-9]+)*$");
1055
+ expect(prompt).toMatch(/at most 64 characters/);
1056
+ expect(prompt).toMatch(/at most 1024 characters/);
1057
+ expect(prompt).toMatch(/one policy per bullet/i);
1058
+ expect(prompt).toMatch(/internally consistent/i);
1059
+ expect(prompt).toMatch(/Replace only the skills this feature owns/);
1060
+ expect(prompt).toMatch(/Install no third-party skill/);
1061
+ expect(prompt).toMatch(/covers every skill in `<host>\/\.omp\/skills\/`/);
1062
+ expect(prompt).toMatch(/unresolvable contradiction with unmanaged skill/);
1063
+ expect(prompt).not.toContain(categoryMarker);
1064
+ expect(prompt).not.toMatch(/category/i);
1065
+ expect(prompt).not.toContain("code-organization");
1066
+ expect(prompt).toMatch(/carries no provenance line, source list, or citation field/i);
1067
+
1068
+ expect(stub).not.toContain(categoryMarker);
1069
+ expect(command.trim().split("\n").at(-1)).toContain(
1070
+ "Read `.devboard/prompts/prepare-skills.md`",
1071
+ );
1072
+ });
1073
+ });