@dsdenes/devboard 0.1.559 → 0.1.562

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +709 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,997 @@
1
+ import { fixtureRecord } from "./fixtures/adr-source";
2
+ import { afterAll, describe, expect, test } from "bun:test";
3
+ import {
4
+ access,
5
+ chmod,
6
+ mkdir,
7
+ mkdtemp,
8
+ readFile,
9
+ rm,
10
+ symlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { createApp } from "./app";
15
+ import type { EpicLookup, OpenEpic } from "./tracker";
16
+
17
+ // Every temporary checkout, remote, and hook lives inside the repository so a
18
+ // failed run leaves nothing outside the project.
19
+ const TEST_ROOT = join(import.meta.dir, "..", "..", ".reset-test-tmp");
20
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
21
+ const temporaryDirectories: string[] = [];
22
+
23
+ async function temporaryDirectory(): Promise<string> {
24
+ await mkdir(TEST_ROOT, { recursive: true });
25
+ const directory = await mkdtemp(join(TEST_ROOT, "case-"));
26
+ temporaryDirectories.push(directory);
27
+ return directory;
28
+ }
29
+
30
+ afterAll(async () => {
31
+ await Promise.all(
32
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
33
+ );
34
+ await rm(TEST_ROOT, { recursive: true, force: true });
35
+ });
36
+
37
+ interface GitResult {
38
+ code: number;
39
+ stdout: string;
40
+ stderr: string;
41
+ }
42
+
43
+ function git(
44
+ directory: string,
45
+ args: string[],
46
+ options: { allowFailure?: boolean } = {},
47
+ ): GitResult {
48
+ const result = Bun.spawnSync({
49
+ cmd: ["git", "-C", directory, ...args],
50
+ env: {
51
+ ...process.env,
52
+ GIT_CONFIG_NOSYSTEM: "1",
53
+ GIT_TERMINAL_PROMPT: "0",
54
+ },
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ });
58
+ const output = {
59
+ code: result.exitCode,
60
+ stdout: result.stdout.toString().trim(),
61
+ stderr: result.stderr.toString().trim(),
62
+ };
63
+ if (options.allowFailure !== true && output.code !== 0) {
64
+ throw new Error(`git ${args.join(" ")} failed: ${output.stderr || output.stdout}`);
65
+ }
66
+ return output;
67
+ }
68
+
69
+ function exists(path: string): Promise<boolean> {
70
+ return access(path).then(
71
+ () => true,
72
+ () => false,
73
+ );
74
+ }
75
+
76
+ interface HostOptions {
77
+ gitignore?: boolean;
78
+ goal?: string | null;
79
+ goalName?: string;
80
+ origin?: boolean;
81
+ baseCommit?: boolean;
82
+ skills?: boolean;
83
+ }
84
+
85
+ interface Host {
86
+ root: string;
87
+ host: string;
88
+ remote: string;
89
+ }
90
+
91
+ async function createHost(options: HostOptions = {}): Promise<Host> {
92
+ const root = await temporaryDirectory();
93
+ const host = join(root, "host");
94
+ const remote = join(root, "origin.git");
95
+ await mkdir(host, { recursive: true });
96
+
97
+ git(host, ["init", "-q", "-b", "main"]);
98
+ git(host, ["config", "user.name", "Devboard Test"]);
99
+ git(host, ["config", "user.email", "devboard@example.invalid"]);
100
+ git(host, ["config", "commit.gpgsign", "false"]);
101
+ git(host, ["init", "-q", "--bare", remote]);
102
+
103
+ if (options.gitignore !== false) {
104
+ await writeFile(join(host, ".gitignore"), ".worktree/\nignored/\n");
105
+ }
106
+ const goalName = options.goalName ?? "GOAL.md";
107
+ if (options.goal !== null) {
108
+ await writeFile(join(host, goalName), options.goal ?? "# Goal\n\nKeep this.\n");
109
+ }
110
+ await writeFile(join(host, "AGENTS.md"), "contract\n");
111
+ for (const directory of [
112
+ ".devboard",
113
+ ".omp",
114
+ "packages",
115
+ ".worktree",
116
+ "ignored",
117
+ "adrs",
118
+ ]) {
119
+ await mkdir(join(host, directory), { recursive: true });
120
+ }
121
+ await writeFile(join(host, ".devboard", "sentinel.txt"), "harness\n");
122
+ await writeFile(join(host, ".omp", "sentinel.txt"), "wrappers\n");
123
+ if (options.skills === true) {
124
+ await mkdir(join(host, ".omp", "skills", "prepared-skill"), { recursive: true });
125
+ await writeFile(
126
+ join(host, ".omp", "skills", "prepared-skill", "SKILL.md"),
127
+ [
128
+ "---",
129
+ "name: prepared-skill",
130
+ "description: A prepared skill for the reset lifecycle case.",
131
+ "metadata:",
132
+ ' devboard-managed: "true"',
133
+ "---",
134
+ "",
135
+ "# Prepared skill",
136
+ "",
137
+ "- Keep the lifecycle startable.",
138
+ "",
139
+ ].join("\n"),
140
+ );
141
+ }
142
+ await writeFile(join(host, "packages", "code.ts"), "implementation\n");
143
+ await writeFile(join(host, "untracked.ts"), "untracked\n");
144
+ await writeFile(join(host, "ignored", "cache.bin"), "ignored\n");
145
+ await writeFile(join(host, ".worktree", "sentinel.txt"), "alive\n");
146
+ await writeFile(join(host, ".worktree", "victim.txt"), "victim\n");
147
+ await writeFile(join(host, "adrs", "0001-old.md"), fixtureRecord("0001-old.md", "Old"));
148
+ await symlink(join(host, ".worktree", "victim.txt"), join(host, "linked-victim"));
149
+
150
+ if (options.origin !== false) {
151
+ git(host, ["remote", "add", "origin", remote]);
152
+ }
153
+ if (options.baseCommit !== false) {
154
+ git(host, ["add", "-A"]);
155
+ git(host, ["commit", "-qm", "base"]);
156
+ }
157
+ return { root, host, remote };
158
+ }
159
+
160
+ async function pushBase(host: Host) {
161
+ git(host.host, ["push", "-q", "origin", "main"]);
162
+ }
163
+
164
+ interface ServerHandle {
165
+ server: { stop(closeActiveConnections?: boolean): unknown };
166
+ url: string;
167
+ }
168
+
169
+ async function startServer(
170
+ host: Host,
171
+ options: {
172
+ repoDir?: string;
173
+ adrDir?: string;
174
+ goalFile?: string;
175
+ agentCommand?: string;
176
+ tracker?: EpicLookup;
177
+ trackerSettle?: { attempts: number; delayMs: number };
178
+ } = {},
179
+ ): Promise<ServerHandle> {
180
+ const staticDir = join(host.root, "static");
181
+ await mkdir(staticDir, { recursive: true });
182
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>reset</title>");
183
+
184
+ const server = createApp({
185
+ adrDir: options.adrDir ?? join(host.host, "adrs"),
186
+ staticDir,
187
+ hostname: "127.0.0.1",
188
+ port: 0,
189
+ repoDir: options.repoDir ?? host.host,
190
+ stateDir: join(host.root, "state"),
191
+ goalFile: options.goalFile ?? join(host.host, "GOAL.md"),
192
+ agentCommand: options.agentCommand,
193
+ ...(options.tracker === undefined ? {} : { tracker: options.tracker }),
194
+ ...(options.trackerSettle === undefined ? {} : { trackerSettle: options.trackerSettle }),
195
+ });
196
+ return { server, url: `127.0.0.1:${server.port}` };
197
+ }
198
+
199
+ interface TestClient {
200
+ events: { type?: unknown; [key: string]: unknown }[];
201
+ send: (command: unknown) => void;
202
+ waitFor: (
203
+ type: string,
204
+ predicate?: (event: Record<string, unknown>) => boolean,
205
+ timeoutMs?: number,
206
+ ) => Promise<Record<string, unknown>>;
207
+ close: () => void;
208
+ }
209
+
210
+ async function connect(url: string): Promise<TestClient> {
211
+ const events: { type?: unknown; [key: string]: unknown }[] = [];
212
+ const consumed = new Set<number>();
213
+ const waiters: {
214
+ type: string;
215
+ predicate: (event: Record<string, unknown>) => boolean;
216
+ resolve: (event: Record<string, unknown>) => void;
217
+ reject: (error: Error) => void;
218
+ timer: ReturnType<typeof setTimeout>;
219
+ }[] = [];
220
+
221
+ const socket = new WebSocket(`ws://${url}/ws`);
222
+
223
+ const tryConsume = (event: Record<string, unknown>, index: number) => {
224
+ for (const waiter of [...waiters]) {
225
+ if (consumed.has(index)) return;
226
+ if (event.type !== waiter.type || !waiter.predicate(event)) continue;
227
+ consumed.add(index);
228
+ clearTimeout(waiter.timer);
229
+ waiters.splice(waiters.indexOf(waiter), 1);
230
+ waiter.resolve(event);
231
+ }
232
+ };
233
+
234
+ socket.onmessage = (message) => {
235
+ const event = JSON.parse(String(message.data)) as Record<string, unknown>;
236
+ const index = events.push(event) - 1;
237
+ tryConsume(event, index);
238
+ };
239
+
240
+ const opened = Promise.withResolvers<void>();
241
+ socket.onopen = () => opened.resolve();
242
+ socket.onerror = () => opened.reject(new Error("websocket failed"));
243
+ await opened.promise;
244
+
245
+ return {
246
+ events,
247
+ send(command) {
248
+ socket.send(JSON.stringify(command));
249
+ },
250
+ waitFor(type, predicate = () => true, timeoutMs = 20000) {
251
+ const existing = events.findIndex(
252
+ (event, index) => !consumed.has(index) && event.type === type && predicate(event),
253
+ );
254
+ if (existing !== -1) {
255
+ consumed.add(existing);
256
+ return Promise.resolve(events[existing] as Record<string, unknown>);
257
+ }
258
+
259
+ const { promise, resolve, reject } = Promise.withResolvers<Record<string, unknown>>();
260
+ const timer = setTimeout(() => {
261
+ waiters.splice(
262
+ waiters.findIndex((waiter) => waiter.timer === timer),
263
+ 1,
264
+ );
265
+ reject(new Error(`timed out waiting for ${type}`));
266
+ }, timeoutMs);
267
+ waiters.push({ type, predicate, resolve, reject, timer });
268
+ return promise;
269
+ },
270
+ close() {
271
+ socket.close();
272
+ },
273
+ };
274
+ }
275
+
276
+ function eventIndex(client: TestClient, event: Record<string, unknown>): number {
277
+ return client.events.indexOf(event);
278
+ }
279
+
280
+ const FULL_SHA = /^[0-9a-f]{40}$/;
281
+
282
+ async function readMaybe(path: string): Promise<string | undefined> {
283
+ try {
284
+ return await readFile(path, "utf8");
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ describe("resetImplementation", () => {
291
+ test("deletes the implementation, commits it, and pushes it", async () => {
292
+ const host = await createHost();
293
+ await pushBase(host);
294
+ const keepBefore = new Map<string, string>();
295
+ for (const name of ["GOAL.md", "AGENTS.md", ".gitignore"]) {
296
+ const bytes = await readFile(join(host.host, name), "utf8");
297
+ keepBefore.set(name, bytes);
298
+ }
299
+ const sentinelBefore = await readFile(join(host.host, ".worktree", "sentinel.txt"), "utf8");
300
+ const baseSha = git(host.host, ["rev-parse", "main"]).stdout;
301
+ const logPath = join(host.root, "agent.log");
302
+ const { server, url } = await startServer(host, {
303
+ agentCommand: `bun ${STUB_AGENT} --log=${logPath}`,
304
+ });
305
+
306
+ try {
307
+ const client = await connect(url);
308
+ client.send({ type: "resetImplementation" });
309
+
310
+ const started = await client.waitFor("implementationResetStarted");
311
+ expect(started).toEqual({ type: "implementationResetStarted" });
312
+
313
+ const stopped = await client.waitFor("implementationResetStopped");
314
+ expect(stopped).toMatchObject({ type: "implementationResetStopped", completed: true });
315
+ expect(stopped.commit).toMatch(FULL_SHA);
316
+ const commit = String(stopped.commit);
317
+
318
+ const lifecycle = await client.waitFor("lifecycleState");
319
+ expect(lifecycle).toEqual({
320
+ type: "lifecycleState",
321
+ stage: "goal",
322
+ pass: 1,
323
+ work: null,
324
+ paused: false,
325
+ });
326
+ const records = await client.waitFor("records");
327
+ expect(records).toEqual({ type: "records", records: [], next: null });
328
+ const goal = await client.waitFor("goal");
329
+ expect(goal).toEqual({ type: "goal", text: "# Goal\n\nKeep this.\n" });
330
+
331
+ expect(eventIndex(client, stopped)).toBeLessThan(
332
+ eventIndex(client, lifecycle),
333
+ );
334
+ expect(eventIndex(client, lifecycle)).toBeLessThan(eventIndex(client, records));
335
+ expect(eventIndex(client, records)).toBeLessThan(eventIndex(client, goal));
336
+
337
+ expect(client.events.map((event) => event.type)).not.toContain("runStarted");
338
+ expect(client.events.map((event) => event.type)).not.toContain("activity");
339
+ expect(client.events.map((event) => event.type)).not.toContain("question");
340
+ expect(client.events.map((event) => event.type)).not.toContain("runStopped");
341
+ expect(await exists(logPath)).toBe(false);
342
+
343
+ expect(await exists(join(host.host, "packages"))).toBe(false);
344
+ expect(await exists(join(host.host, "untracked.ts"))).toBe(false);
345
+ expect(await exists(join(host.host, "ignored"))).toBe(false);
346
+ expect(await exists(join(host.host, "adrs"))).toBe(false);
347
+ expect(await exists(join(host.host, "linked-victim"))).toBe(false);
348
+ expect(await exists(join(host.host, ".worktree", "victim.txt"))).toBe(true);
349
+ expect(await readFile(join(host.host, ".worktree", "sentinel.txt"), "utf8")).toBe(
350
+ sentinelBefore,
351
+ );
352
+ for (const [name, bytes] of keepBefore) {
353
+ expect(await readFile(join(host.host, name), "utf8")).toBe(bytes);
354
+ }
355
+
356
+ expect(git(host.host, ["status", "--porcelain"]).stdout).toBe("");
357
+ expect(git(host.host, ["log", "-1", "--pretty=%s"]).stdout).toBe(
358
+ "chore: reset implementation",
359
+ );
360
+ expect(git(host.host, ["rev-parse", "main"]).stdout).toBe(commit);
361
+ expect(git(host.host, ["rev-parse", "main"]).stdout).not.toBe(baseSha);
362
+ expect(git(host.remote, ["rev-parse", "main"]).stdout).toBe(commit);
363
+ expect(git(host.remote, ["ls-tree", "-r", "--name-only", "main"]).stdout).toBe(
364
+ [
365
+ ".devboard/sentinel.txt",
366
+ ".gitignore",
367
+ ".omp/sentinel.txt",
368
+ "AGENTS.md",
369
+ "GOAL.md",
370
+ ].join("\n"),
371
+ );
372
+ client.close();
373
+ } finally {
374
+ server.stop(true);
375
+ }
376
+ });
377
+
378
+ test("answers an empty record list and preserves goal editing afterwards", async () => {
379
+ const host = await createHost();
380
+ await pushBase(host);
381
+ const { server, url } = await startServer(host);
382
+
383
+ try {
384
+ const first = await connect(url);
385
+ first.send({ type: "resetImplementation" });
386
+ await first.waitFor("implementationResetStarted");
387
+ const stopped = await first.waitFor("implementationResetStopped");
388
+ expect(stopped.completed).toBe(true);
389
+ first.close();
390
+
391
+ const client = await connect(url);
392
+ client.send({ type: "listRecords" });
393
+ expect(await client.waitFor("records")).toEqual({
394
+ type: "records",
395
+ records: [],
396
+ next: null,
397
+ });
398
+
399
+ client.send({ type: "getGoal" });
400
+ expect(await client.waitFor("goal")).toEqual({
401
+ type: "goal",
402
+ text: "# Goal\n\nKeep this.\n",
403
+ });
404
+
405
+ client.send({ type: "saveGoal", text: "Edited after reset\n" });
406
+ expect(await client.waitFor("goal")).toEqual({
407
+ type: "goal",
408
+ text: "Edited after reset\n",
409
+ });
410
+ expect(await readFile(join(host.host, "GOAL.md"), "utf8")).toBe(
411
+ "Edited after reset\n",
412
+ );
413
+ client.close();
414
+ } finally {
415
+ server.stop(true);
416
+ }
417
+ });
418
+
419
+ test("reuses HEAD when the reset stages no change", async () => {
420
+ const host = await createHost();
421
+ await pushBase(host);
422
+ const { server, url } = await startServer(host);
423
+
424
+ try {
425
+ const client = await connect(url);
426
+ client.send({ type: "resetImplementation" });
427
+ await client.waitFor("implementationResetStarted");
428
+ const first = await client.waitFor("implementationResetStopped");
429
+ expect(first.completed).toBe(true);
430
+
431
+ client.send({ type: "resetImplementation" });
432
+ await client.waitFor("implementationResetStarted");
433
+ const second = await client.waitFor("implementationResetStopped");
434
+ expect(second).toMatchObject({
435
+ type: "implementationResetStopped",
436
+ completed: true,
437
+ commit: first.commit,
438
+ });
439
+ expect(git(host.host, ["rev-list", "--count", "main"]).stdout).toBe("2");
440
+ expect(git(host.remote, ["rev-parse", "main"]).stdout).toBe(String(first.commit));
441
+ client.close();
442
+ } finally {
443
+ server.stop(true);
444
+ }
445
+ });
446
+
447
+ test("keeps a differently named top-level goal file", async () => {
448
+ const host = await createHost({ goalName: "OBJECTIVE.md" });
449
+ await pushBase(host);
450
+ const { server, url } = await startServer(host, {
451
+ goalFile: join(host.host, "OBJECTIVE.md"),
452
+ });
453
+
454
+ try {
455
+ const client = await connect(url);
456
+ client.send({ type: "resetImplementation" });
457
+ await client.waitFor("implementationResetStarted");
458
+ const stopped = await client.waitFor("implementationResetStopped");
459
+ expect(stopped.completed).toBe(true);
460
+
461
+ expect(await readFile(join(host.host, "OBJECTIVE.md"), "utf8")).toBe(
462
+ "# Goal\n\nKeep this.\n",
463
+ );
464
+ expect(await exists(join(host.host, "GOAL.md"))).toBe(false);
465
+ expect(await exists(join(host.host, "packages"))).toBe(false);
466
+ expect(git(host.remote, ["ls-tree", "-r", "--name-only", "main"]).stdout).toContain(
467
+ "OBJECTIVE.md",
468
+ );
469
+
470
+ const goal = await client.waitFor("goal");
471
+ expect(goal).toEqual({ type: "goal", text: "# Goal\n\nKeep this.\n" });
472
+ client.close();
473
+ } finally {
474
+ server.stop(true);
475
+ }
476
+ });
477
+
478
+ test("never stages .worktree, node_modules, or dist without a .gitignore", async () => {
479
+ const host = await createHost({ gitignore: false });
480
+ await mkdir(join(host.host, ".devboard", "node_modules"), { recursive: true });
481
+ await mkdir(join(host.host, ".devboard", "dist"), { recursive: true });
482
+ await writeFile(join(host.host, ".devboard", "node_modules", "cache.js"), "cache\n");
483
+ await writeFile(join(host.host, ".devboard", "dist", "app.js"), "built\n");
484
+ await writeFile(join(host.host, ".worktree", "scratch.txt"), "scratch\n");
485
+ await pushBase(host);
486
+ const { server, url } = await startServer(host);
487
+
488
+ try {
489
+ const client = await connect(url);
490
+ client.send({ type: "resetImplementation" });
491
+ await client.waitFor("implementationResetStarted");
492
+ const stopped = await client.waitFor("implementationResetStopped");
493
+ expect(stopped.completed).toBe(true);
494
+
495
+ const tree = git(host.remote, ["ls-tree", "-r", "--name-only", "main"]).stdout;
496
+ expect(tree).toBe(
497
+ [".devboard/sentinel.txt", ".omp/sentinel.txt", "AGENTS.md", "GOAL.md"].join("\n"),
498
+ );
499
+ expect(tree).not.toContain("node_modules");
500
+ expect(tree).not.toContain(".devboard/dist");
501
+ expect(tree).not.toContain(".worktree");
502
+ expect(await exists(join(host.host, ".devboard", "node_modules", "cache.js"))).toBe(
503
+ true,
504
+ );
505
+ expect(await exists(join(host.host, ".worktree", "sentinel.txt"))).toBe(true);
506
+ client.close();
507
+ } finally {
508
+ server.stop(true);
509
+ }
510
+ });
511
+
512
+ test("bypasses a failing pre-commit hook", async () => {
513
+ const host = await createHost();
514
+ await pushBase(host);
515
+ const hook = join(host.host, ".git", "hooks", "pre-commit");
516
+ await writeFile(hook, "#!/bin/sh\necho pre-commit-refused >&2\nexit 1\n");
517
+ await chmod(hook, 0o755);
518
+ const { server, url } = await startServer(host);
519
+
520
+ try {
521
+ const client = await connect(url);
522
+ client.send({ type: "resetImplementation" });
523
+ await client.waitFor("implementationResetStarted");
524
+ const stopped = await client.waitFor("implementationResetStopped");
525
+ expect(stopped.completed).toBe(true);
526
+ expect(stopped.commit).toMatch(FULL_SHA);
527
+ expect(git(host.remote, ["log", "-1", "--pretty=%s", "main"]).stdout).toBe(
528
+ "chore: reset implementation",
529
+ );
530
+ client.close();
531
+ } finally {
532
+ server.stop(true);
533
+ }
534
+ });
535
+
536
+ test("refuses off main, without origin, outside the top level, and without HEAD", async () => {
537
+ const offMain = await createHost();
538
+ await pushBase(offMain);
539
+ const branch = await startServer(offMain);
540
+ git(offMain.host, ["checkout", "-q", "-b", "feature"]);
541
+
542
+ const noOrigin = await createHost();
543
+ git(noOrigin.host, ["remote", "remove", "origin"]);
544
+
545
+ const nested = await createHost();
546
+ await pushBase(nested);
547
+
548
+ const unborn = await createHost({ baseCommit: false, origin: false });
549
+ git(unborn.host, ["remote", "add", "origin", unborn.remote]);
550
+
551
+ const cases: {
552
+ name: string;
553
+ host: Host;
554
+ handle: ServerHandle;
555
+ }[] = [
556
+ { name: "off main", host: offMain, handle: branch },
557
+ { name: "no origin", host: noOrigin, handle: await startServer(noOrigin) },
558
+ {
559
+ name: "nested REPO_DIR",
560
+ host: nested,
561
+ handle: await startServer(nested, { repoDir: join(nested.host, "packages") }),
562
+ },
563
+ { name: "unborn HEAD", host: unborn, handle: await startServer(unborn) },
564
+ ];
565
+
566
+ try {
567
+ for (const { name, host, handle } of cases) {
568
+ const before = await readFile(join(host.host, "packages", "code.ts"), "utf8");
569
+ const goalBefore = await readMaybe(join(host.host, "GOAL.md"));
570
+ const client = await connect(handle.url);
571
+ client.send({ type: "resetImplementation" });
572
+
573
+ const error = await client.waitFor("error");
574
+ expect(error.message).toEqual(expect.any(String));
575
+ const stopped = await client.waitFor("implementationResetStopped");
576
+ expect(stopped).toMatchObject({
577
+ type: "implementationResetStopped",
578
+ completed: false,
579
+ });
580
+ expect("commit" in stopped).toBe(false);
581
+ expect(await readFile(join(host.host, "packages", "code.ts"), "utf8")).toBe(
582
+ before,
583
+ );
584
+ expect(await readMaybe(join(host.host, "GOAL.md"))).toEqual(goalBefore);
585
+ expect(await exists(join(host.host, "untracked.ts"))).toBe(true);
586
+ expect(
587
+ client.events.filter((event) => event.type === "error").length,
588
+ `${name}: exactly one error`,
589
+ ).toBe(1);
590
+ client.close();
591
+ }
592
+ } finally {
593
+ branch.server.stop(true);
594
+ for (const { handle } of cases.slice(1)) handle.server.stop(true);
595
+ }
596
+ });
597
+
598
+ test(
599
+ "stops an active run and resets",
600
+ async () => {
601
+ const host = await createHost();
602
+ await pushBase(host);
603
+ await writeFile(
604
+ join(host.host, "adrs", "0002-pending.md"),
605
+ fixtureRecord("0002-pending.md", "Pending", "proposed"),
606
+ );
607
+ const { server, url } = await startServer(host, {
608
+ agentCommand: `bun ${STUB_AGENT} --delay-ms=5000`,
609
+ });
610
+
611
+ try {
612
+ const client = await connect(url);
613
+ client.send({ type: "startRun" });
614
+ await client.waitFor("runStarted");
615
+
616
+ client.send({ type: "resetImplementation" });
617
+ await client.waitFor("implementationResetStarted");
618
+ expect(await client.waitFor("runStopped")).toMatchObject({
619
+ type: "runStopped",
620
+ file: "0002-pending.md",
621
+ resolved: false,
622
+ });
623
+ const stopped = await client.waitFor("implementationResetStopped");
624
+ expect(stopped).toMatchObject({
625
+ type: "implementationResetStopped",
626
+ completed: true,
627
+ });
628
+ expect(stopped.commit).toMatch(FULL_SHA);
629
+ expect(client.events.filter((event) => event.type === "error")).toHaveLength(0);
630
+ expect(await exists(join(host.host, "packages", "code.ts"))).toBe(false);
631
+ expect(await exists(join(host.host, "untracked.ts"))).toBe(false);
632
+ expect(await exists(join(host.host, "adrs", "0001-old.md"))).toBe(false);
633
+ client.close();
634
+ } finally {
635
+ server.stop(true);
636
+ }
637
+ },
638
+ 20000,
639
+ );
640
+
641
+ test(
642
+ "cancels a skills preparation, restores the skills, and resets",
643
+ async () => {
644
+ const host = await createHost({ skills: true });
645
+ await pushBase(host);
646
+ const skill = join(host.host, ".omp", "skills", "prepared-skill", "SKILL.md");
647
+ const skillBefore = await readFile(skill, "utf8");
648
+ const { server, url } = await startServer(host, {
649
+ agentCommand: `bun ${STUB_AGENT} --prepare-skills --delay-ms=10000`,
650
+ });
651
+
652
+ try {
653
+ const client = await connect(url);
654
+ client.send({ type: "prepareSkills" });
655
+ await client.waitFor("skillsPreparationStarted");
656
+
657
+ client.send({ type: "resetImplementation" });
658
+ await client.waitFor("implementationResetStarted");
659
+ expect(await client.waitFor("skillsPreparationStopped")).toEqual({
660
+ type: "skillsPreparationStopped",
661
+ completed: false,
662
+ });
663
+ const stopped = await client.waitFor("implementationResetStopped");
664
+ expect(stopped).toMatchObject({
665
+ type: "implementationResetStopped",
666
+ completed: true,
667
+ });
668
+ expect(await readFile(skill, "utf8")).toBe(skillBefore);
669
+ expect(await exists(join(host.host, "packages"))).toBe(false);
670
+ expect(await exists(join(host.host, "adrs"))).toBe(false);
671
+ client.close();
672
+ } finally {
673
+ server.stop(true);
674
+ }
675
+ },
676
+ 20000,
677
+ );
678
+
679
+ test(
680
+ "stops a running lifecycle and resets",
681
+ async () => {
682
+ const host = await createHost({ skills: true });
683
+ await pushBase(host);
684
+ const epic: OpenEpic = {
685
+ number: 7,
686
+ title: "Reset the running lifecycle",
687
+ url: "https://example.test/issues/7",
688
+ };
689
+ const { server, url } = await startServer(host, {
690
+ agentCommand: `bun ${STUB_AGENT} --delay-ms=5000`,
691
+ tracker: async () => epic,
692
+ trackerSettle: { attempts: 1, delayMs: 0 },
693
+ });
694
+
695
+ try {
696
+ const client = await connect(url);
697
+ client.send({ type: "startLifecycle" });
698
+ await client.waitFor(
699
+ "lifecycleState",
700
+ (event) => event.stage === "implementation",
701
+ );
702
+ await client.waitFor("runStarted");
703
+
704
+ client.send({ type: "resetImplementation" });
705
+ await client.waitFor("implementationResetStarted");
706
+ await client.waitFor(
707
+ "laneState",
708
+ (event) => Array.isArray(event.lanes) && event.lanes.length === 0,
709
+ );
710
+ const stopped = await client.waitFor("implementationResetStopped");
711
+ expect(stopped).toMatchObject({
712
+ type: "implementationResetStopped",
713
+ completed: true,
714
+ });
715
+ expect(client.events.filter((event) => event.type === "error")).toHaveLength(0);
716
+ expect(await exists(join(host.host, "packages"))).toBe(false);
717
+ expect(await exists(join(host.host, "adrs"))).toBe(false);
718
+ expect(await exists(join(host.host, ".omp", "skills", "prepared-skill"))).toBe(true);
719
+ client.close();
720
+ } finally {
721
+ server.stop(true);
722
+ }
723
+ },
724
+ 20000,
725
+ );
726
+
727
+ test(
728
+ "stops a paused lifecycle and resets",
729
+ async () => {
730
+ const host = await createHost({ skills: true });
731
+ await pushBase(host);
732
+ const epic: OpenEpic = {
733
+ number: 8,
734
+ title: "Reset the paused lifecycle",
735
+ url: "https://example.test/issues/8",
736
+ };
737
+ const { server, url } = await startServer(host, {
738
+ agentCommand: `bun ${STUB_AGENT} --delay-ms=5000`,
739
+ tracker: async () => epic,
740
+ trackerSettle: { attempts: 1, delayMs: 0 },
741
+ });
742
+
743
+ try {
744
+ const client = await connect(url);
745
+ client.send({ type: "startLifecycle" });
746
+ await client.waitFor(
747
+ "lifecycleState",
748
+ (event) => event.stage === "implementation",
749
+ );
750
+ await client.waitFor("runStarted");
751
+ client.send({ type: "pauseLifecycle" });
752
+ await client.waitFor("lifecycleState", (event) => event.paused === true);
753
+
754
+ client.send({ type: "resetImplementation" });
755
+ await client.waitFor("implementationResetStarted");
756
+ await client.waitFor(
757
+ "laneState",
758
+ (event) => Array.isArray(event.lanes) && event.lanes.length === 0,
759
+ );
760
+ const stopped = await client.waitFor("implementationResetStopped");
761
+ expect(stopped).toMatchObject({
762
+ type: "implementationResetStopped",
763
+ completed: true,
764
+ });
765
+ expect(client.events.filter((event) => event.type === "error")).toHaveLength(0);
766
+ expect(await exists(join(host.host, "packages"))).toBe(false);
767
+ expect(await exists(join(host.host, "adrs"))).toBe(false);
768
+ client.close();
769
+ } finally {
770
+ server.stop(true);
771
+ }
772
+ },
773
+ 20000,
774
+ );
775
+
776
+ test("keeps the local commit and remote unchanged when the push is rejected", async () => {
777
+ const host = await createHost();
778
+ await pushBase(host);
779
+ const baseSha = git(host.remote, ["rev-parse", "main"]).stdout;
780
+ const baseTree = git(host.remote, ["ls-tree", "-r", "--name-only", "main"]).stdout;
781
+ const hook = join(host.remote, "hooks", "pre-receive");
782
+ await writeFile(
783
+ hook,
784
+ "#!/bin/sh\necho 'remote rejected the reset' >&2\nexit 1\n",
785
+ );
786
+ await chmod(hook, 0o755);
787
+ const { server, url } = await startServer(host);
788
+
789
+ try {
790
+ const client = await connect(url);
791
+ client.send({ type: "resetImplementation" });
792
+ await client.waitFor("implementationResetStarted");
793
+ const error = await client.waitFor("error");
794
+ expect(String(error.message)).toContain("remote rejected the reset");
795
+ const stopped = await client.waitFor("implementationResetStopped");
796
+ expect(stopped).toMatchObject({
797
+ type: "implementationResetStopped",
798
+ completed: false,
799
+ });
800
+ expect(stopped.commit).toMatch(FULL_SHA);
801
+
802
+ expect(await exists(join(host.host, "packages"))).toBe(false);
803
+ expect(await exists(join(host.host, "untracked.ts"))).toBe(false);
804
+ expect(git(host.host, ["status", "--porcelain"]).stdout).toBe("");
805
+ expect(git(host.host, ["log", "-1", "--pretty=%s"]).stdout).toBe(
806
+ "chore: reset implementation",
807
+ );
808
+ expect(git(host.host, ["rev-parse", "main"]).stdout).toBe(String(stopped.commit));
809
+ expect(git(host.remote, ["rev-parse", "main"]).stdout).toBe(baseSha);
810
+ expect(git(host.remote, ["ls-tree", "-r", "--name-only", "main"]).stdout).toBe(
811
+ baseTree,
812
+ );
813
+ client.close();
814
+ } finally {
815
+ server.stop(true);
816
+ }
817
+ });
818
+
819
+ test("reports a commit failure with the staged deletion left in place", async () => {
820
+ const host = await createHost();
821
+ await pushBase(host);
822
+ const baseSha = git(host.remote, ["rev-parse", "main"]).stdout;
823
+ const hook = join(host.host, ".git", "hooks", "prepare-commit-msg");
824
+ await writeFile(hook, "#!/bin/sh\necho 'commit refused by hook' >&2\nexit 1\n");
825
+ await chmod(hook, 0o755);
826
+ const { server, url } = await startServer(host);
827
+
828
+ try {
829
+ const client = await connect(url);
830
+ client.send({ type: "resetImplementation" });
831
+ await client.waitFor("implementationResetStarted");
832
+ const error = await client.waitFor("error");
833
+ expect(String(error.message)).toContain("commit refused by hook");
834
+ const stopped = await client.waitFor("implementationResetStopped");
835
+ expect(stopped).toMatchObject({
836
+ type: "implementationResetStopped",
837
+ completed: false,
838
+ });
839
+ expect("commit" in stopped).toBe(false);
840
+
841
+ expect(await exists(join(host.host, "packages"))).toBe(false);
842
+ expect(await exists(join(host.host, "untracked.ts"))).toBe(false);
843
+ const status = git(host.host, ["status", "--porcelain"]).stdout;
844
+ expect(status).toContain("D packages/code.ts");
845
+ expect(status).toContain("D untracked.ts");
846
+ expect(git(host.host, ["rev-parse", "main"]).stdout).toBe(baseSha);
847
+ expect(git(host.remote, ["rev-parse", "main"]).stdout).toBe(baseSha);
848
+ client.close();
849
+ } finally {
850
+ server.stop(true);
851
+ }
852
+ });
853
+
854
+ test("refuses a second reset and every mutating command while one runs", async () => {
855
+ const host = await createHost();
856
+ await pushBase(host);
857
+ const hook = join(host.remote, "hooks", "pre-receive");
858
+ await writeFile(hook, "#!/bin/sh\nsleep 1.2\nexit 0\n");
859
+ await chmod(hook, 0o755);
860
+ const { server, url } = await startServer(host);
861
+
862
+ try {
863
+ const client = await connect(url);
864
+ client.send({ type: "resetImplementation" });
865
+ await client.waitFor("implementationResetStarted");
866
+
867
+ const refused: unknown[] = [
868
+ { type: "resetImplementation" },
869
+ { type: "saveGoal", text: "blocked" },
870
+ { type: "startRun" },
871
+ { type: "startLifecycle" },
872
+ { type: "pauseLifecycle" },
873
+ { type: "resumeLifecycle" },
874
+ { type: "reviseGoal", request: "blocked" },
875
+ { type: "createGoal" },
876
+ { type: "prepareSkills" },
877
+ { type: "answer", id: 1, value: "blocked" },
878
+ ];
879
+ for (const command of refused) {
880
+ client.send(command);
881
+ const error = await client.waitFor("error");
882
+ expect(String(error.message)).toBe("An implementation reset is active.");
883
+ }
884
+ expect(
885
+ client.events.filter((event) => event.type === "implementationResetStarted"),
886
+ ).toHaveLength(1);
887
+
888
+ client.send({ type: "getGoal" });
889
+ expect(await client.waitFor("goal")).toEqual({
890
+ type: "goal",
891
+ text: "# Goal\n\nKeep this.\n",
892
+ });
893
+ client.send({ type: "getSkills" });
894
+ expect(await client.waitFor("skillsState")).toEqual({
895
+ type: "skillsState",
896
+ skills: [],
897
+ contradictions: [],
898
+ });
899
+
900
+ const stopped = await client.waitFor("implementationResetStopped");
901
+ expect(stopped.completed).toBe(true);
902
+
903
+ client.send({ type: "listRecords" });
904
+ expect(await client.waitFor("records")).toEqual({
905
+ type: "records",
906
+ records: [],
907
+ next: null,
908
+ });
909
+ client.close();
910
+ } finally {
911
+ server.stop(true);
912
+ }
913
+ });
914
+
915
+ test("replays the started event to a client that connects mid-reset", async () => {
916
+ const host = await createHost();
917
+ await pushBase(host);
918
+ const hook = join(host.remote, "hooks", "pre-receive");
919
+ await writeFile(hook, "#!/bin/sh\nsleep 1\nexit 0\n");
920
+ await chmod(hook, 0o755);
921
+ const { server, url } = await startServer(host);
922
+
923
+ try {
924
+ const first = await connect(url);
925
+ first.send({ type: "resetImplementation" });
926
+ await first.waitFor("implementationResetStarted");
927
+
928
+ const late = await connect(url);
929
+ late.send({ type: "getGoal" });
930
+ const started = await late.waitFor("implementationResetStarted");
931
+ const goal = await late.waitFor("goal");
932
+ expect(eventIndex(late, started)).toBeLessThan(eventIndex(late, goal));
933
+ expect(late.events[0]).toEqual({ type: "implementationResetStarted" });
934
+
935
+ const stopped = await first.waitFor("implementationResetStopped");
936
+ expect(stopped.completed).toBe(true);
937
+ first.close();
938
+ late.close();
939
+ } finally {
940
+ server.stop(true);
941
+ }
942
+ });
943
+
944
+ test("allows reset with an absent, empty, or whitespace-only goal", async () => {
945
+ const goals = [
946
+ { name: "absent", content: null, eventText: "" },
947
+ { name: "empty", content: "", eventText: "" },
948
+ { name: "whitespace", content: " \n\t\n", eventText: " \n\t\n" },
949
+ ];
950
+
951
+ for (const goal of goals) {
952
+ const host = await createHost({ goal: goal.content });
953
+ await pushBase(host);
954
+ const goalBytes = await readMaybe(join(host.host, "GOAL.md"));
955
+ const { server, url } = await startServer(host);
956
+
957
+ try {
958
+ const client = await connect(url);
959
+ client.send({ type: "resetImplementation" });
960
+ await client.waitFor("implementationResetStarted");
961
+ const stopped = await client.waitFor("implementationResetStopped");
962
+ expect(stopped.completed, goal.name).toBe(true);
963
+
964
+ const goalEvent = await client.waitFor("goal");
965
+ expect(goalEvent).toEqual({ type: "goal", text: goal.eventText });
966
+ expect(await readMaybe(join(host.host, "GOAL.md"))).toEqual(goalBytes);
967
+ expect(await exists(join(host.host, "packages"))).toBe(false);
968
+ client.close();
969
+ } finally {
970
+ server.stop(true);
971
+ }
972
+ }
973
+ });
974
+
975
+ test("removes a symlink without following it", async () => {
976
+ const host = await createHost();
977
+ await pushBase(host);
978
+ const external = join(host.root, "outside-victim.txt");
979
+ await writeFile(external, "outside bytes\n");
980
+ await symlink(external, join(host.host, "outside-link"));
981
+ const { server, url } = await startServer(host);
982
+
983
+ try {
984
+ const client = await connect(url);
985
+ client.send({ type: "resetImplementation" });
986
+ await client.waitFor("implementationResetStarted");
987
+ const stopped = await client.waitFor("implementationResetStopped");
988
+ expect(stopped.completed).toBe(true);
989
+
990
+ expect(await exists(join(host.host, "outside-link"))).toBe(false);
991
+ expect(await readFile(external, "utf8")).toBe("outside bytes\n");
992
+ client.close();
993
+ } finally {
994
+ server.stop(true);
995
+ }
996
+ });
997
+ });