@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,1029 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import type { Server } from "bun";
6
+ import { createApp } from "./app";
7
+ import type { OpenEpic } from "./tracker";
8
+
9
+ const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
10
+ /** Every temporary host lives inside the worktree, never the system temp directory. */
11
+ const TEST_ROOT = join(import.meta.dir, "..", ".tmp", "parallel-lifecycle");
12
+ const MANAGED_SKILL = [
13
+ "---",
14
+ "name: behavior-first-testing",
15
+ "description: Choose tests by observable behavior.",
16
+ "metadata:",
17
+ ' devboard-managed: "true"',
18
+ "---",
19
+ "",
20
+ "# Behavior-first testing",
21
+ "",
22
+ "- Prefer observable behavior over implementation details.",
23
+ "",
24
+ ].join("\n");
25
+
26
+ interface Host {
27
+ root: string;
28
+ adrDir: string;
29
+ staticDir: string;
30
+ goalFile: string;
31
+ trackerFile: string;
32
+ logPath: string;
33
+ remote: string;
34
+ }
35
+
36
+ interface LaneView {
37
+ id: string;
38
+ kind: string;
39
+ epic: { number: number; title: string; url: string } | null;
40
+ record: { file: string; title: string } | null;
41
+ attempt: number;
42
+ status: string;
43
+ activity: string[];
44
+ question: { id: number | string; message: string } | null;
45
+ }
46
+
47
+ interface LaneStateView {
48
+ lanes: LaneView[];
49
+ openEpics: number;
50
+ runningLanes: number;
51
+ }
52
+
53
+ interface TestClient {
54
+ events: Record<string, unknown>[];
55
+ send(command: unknown): void;
56
+ waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
57
+ close(): void;
58
+ }
59
+
60
+ const temporaryDirectories: string[] = [];
61
+
62
+ function epic(number: number, title = `Fixture epic ${number}`): OpenEpic {
63
+ return { number, title, url: `https://fixture.test/issues/${number}` };
64
+ }
65
+
66
+ async function git(directory: string, args: string[]): Promise<string> {
67
+ const result = Bun.spawnSync({
68
+ cmd: [
69
+ "git",
70
+ "-C",
71
+ directory,
72
+ "-c",
73
+ "user.email=fixture@example.test",
74
+ "-c",
75
+ "user.name=Fixture",
76
+ ...args,
77
+ ],
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+ if (result.exitCode !== 0) {
82
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`);
83
+ }
84
+ return result.stdout.toString();
85
+ }
86
+
87
+ async function makeHost(open: OpenEpic[] = []): Promise<Host> {
88
+ await mkdir(TEST_ROOT, { recursive: true });
89
+ const root = await mkdtemp(join(TEST_ROOT, "host-"));
90
+ temporaryDirectories.push(root);
91
+ const adrDir = join(root, "adrs");
92
+ const staticDir = join(root, "static");
93
+ const goalFile = join(root, "GOAL.md");
94
+ const trackerFile = join(root, "tracker.json");
95
+ const logPath = join(root, "agent.log");
96
+ // The bare origin sits beside the host so a reset inside the host cannot
97
+ // delete the remote it pushes to.
98
+ const remote = `${root}-remote.git`;
99
+ temporaryDirectories.push(remote);
100
+
101
+ await mkdir(adrDir, { recursive: true });
102
+ await mkdir(staticDir, { recursive: true });
103
+ await writeFile(join(staticDir, "index.html"), "<!doctype html><title>parallel</title>");
104
+ await writeFile(goalFile, "# Goal\n\nShip the parallel pipeline.\n");
105
+ await writeFile(trackerFile, `${JSON.stringify(open, null, 2)}\n`);
106
+ await mkdir(join(root, ".omp", "skills", "behavior-first-testing"), { recursive: true });
107
+ await writeFile(join(root, ".omp", "skills", "behavior-first-testing", "SKILL.md"), MANAGED_SKILL);
108
+
109
+ await git(root, ["init", "-b", "main"]);
110
+ await git(root, ["config", "user.email", "fixture@example.test"]);
111
+ await git(root, ["config", "user.name", "Fixture"]);
112
+ await writeFile(join(root, "README.md"), "fixture host\n");
113
+ await git(root, ["add", "-A"]);
114
+ await git(root, ["commit", "-m", "fixture base"]);
115
+ await git(root, ["init", "--bare", remote]);
116
+ await git(root, ["remote", "add", "origin", remote]);
117
+ await git(root, ["push", "-u", "origin", "main"]);
118
+
119
+ return { root, adrDir, staticDir, goalFile, trackerFile, logPath, remote };
120
+ }
121
+
122
+ function readTracker(file: string): OpenEpic[] {
123
+ try {
124
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
125
+ return Array.isArray(parsed) ? (parsed as OpenEpic[]) : [];
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ async function startServer(
132
+ host: Host,
133
+ options: {
134
+ agentArguments?: string;
135
+ laneBackoffMs?: number;
136
+ laneRetentionMs?: number;
137
+ mainPollMs?: number;
138
+ } = {},
139
+ ): Promise<{ server: Server<unknown>; url: string }> {
140
+ const command = [
141
+ `bun ${STUB_AGENT}`,
142
+ `--log=${host.logPath}`,
143
+ `--fixture-tracker=${host.trackerFile}`,
144
+ "--delay-ms=0",
145
+ options.agentArguments ?? "",
146
+ ]
147
+ .filter((part) => part.length > 0)
148
+ .join(" ");
149
+ const server = createApp({
150
+ adrDir: host.adrDir,
151
+ staticDir: host.staticDir,
152
+ hostname: "127.0.0.1",
153
+ port: 0,
154
+ repoDir: host.root,
155
+ stateDir: join(host.root, "state"),
156
+ goalFile: host.goalFile,
157
+ agentCommand: command,
158
+ tracker: async () => readTracker(host.trackerFile),
159
+ trackerSettle: { attempts: 1 },
160
+ laneBackoffMs: options.laneBackoffMs,
161
+ laneRetentionMs: options.laneRetentionMs,
162
+ mainPollMs: options.mainPollMs,
163
+ });
164
+ return { server, url: `127.0.0.1:${server.port}` };
165
+ }
166
+
167
+ function connect(url: string): Promise<TestClient> {
168
+ const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
169
+ const socket = new WebSocket(`ws://${url}/ws`);
170
+ const events: Record<string, unknown>[] = [];
171
+ const buffered: Record<string, unknown>[] = [];
172
+ const waiters: {
173
+ predicate: (message: Record<string, unknown>) => boolean;
174
+ resolve: (message: Record<string, unknown>) => void;
175
+ }[] = [];
176
+
177
+ socket.onopen = () => {
178
+ const client: TestClient = {
179
+ events,
180
+ send(command) {
181
+ socket.send(JSON.stringify(command));
182
+ },
183
+ waitFor(predicate) {
184
+ const index = buffered.findIndex(predicate);
185
+ if (index >= 0) {
186
+ const message = buffered.splice(index, 1)[0];
187
+ if (message !== undefined) return Promise.resolve(message);
188
+ }
189
+ const { promise: waiting, resolve: settle } =
190
+ Promise.withResolvers<Record<string, unknown>>();
191
+ waiters.push({ predicate, resolve: settle });
192
+ return waiting;
193
+ },
194
+ close() {
195
+ socket.close();
196
+ },
197
+ };
198
+ resolve(client);
199
+ };
200
+
201
+ socket.onmessage = (event) => {
202
+ let message: Record<string, unknown>;
203
+ try {
204
+ message = JSON.parse(String(event.data)) as Record<string, unknown>;
205
+ } catch {
206
+ return;
207
+ }
208
+ events.push(message);
209
+ const index = waiters.findIndex((waiter) => waiter.predicate(message));
210
+ if (index >= 0) {
211
+ const waiter = waiters.splice(index, 1)[0];
212
+ if (waiter !== undefined) waiter.resolve(message);
213
+ } else {
214
+ buffered.push(message);
215
+ }
216
+ };
217
+
218
+ socket.onerror = () => reject(new Error("websocket failed"));
219
+ return promise;
220
+ }
221
+
222
+ function isLaneView(value: unknown): value is LaneView {
223
+ return (
224
+ typeof value === "object" &&
225
+ value !== null &&
226
+ "id" in value &&
227
+ typeof value.id === "string" &&
228
+ "kind" in value &&
229
+ typeof value.kind === "string" &&
230
+ "attempt" in value &&
231
+ typeof value.attempt === "number" &&
232
+ "status" in value &&
233
+ typeof value.status === "string" &&
234
+ "activity" in value &&
235
+ Array.isArray(value.activity) &&
236
+ value.activity.every((line) => typeof line === "string") &&
237
+ "question" in value
238
+ );
239
+ }
240
+
241
+ function laneState(message: Record<string, unknown>): LaneStateView | undefined {
242
+ if (message.type !== "laneState") return undefined;
243
+ if (!Array.isArray(message.lanes) || !message.lanes.every(isLaneView)) return undefined;
244
+ if (typeof message.openEpics !== "number" || typeof message.runningLanes !== "number") {
245
+ return undefined;
246
+ }
247
+ return {
248
+ lanes: message.lanes,
249
+ openEpics: message.openEpics,
250
+ runningLanes: message.runningLanes,
251
+ };
252
+ }
253
+
254
+ async function waitForLanes(
255
+ client: TestClient,
256
+ predicate: (state: LaneStateView) => boolean,
257
+ timeoutMs = 10_000,
258
+ ): Promise<LaneStateView> {
259
+ const existing = [...client.events]
260
+ .reverse()
261
+ .map((message) => laneState(message))
262
+ .find((state) => state !== undefined && predicate(state));
263
+ if (existing !== undefined) return existing;
264
+
265
+ const { promise, resolve, reject } = Promise.withResolvers<LaneStateView>();
266
+ const timer = setTimeout(() => reject(new Error("timed out waiting for lane state")), timeoutMs);
267
+ void client.waitFor((message) => {
268
+ const state = laneState(message);
269
+ if (state === undefined || !predicate(state)) return false;
270
+ clearTimeout(timer);
271
+ resolve(state);
272
+ return true;
273
+ });
274
+ return promise;
275
+ }
276
+
277
+ function prompts(logPath: string): string[] {
278
+ if (!existsSync(logPath)) return [];
279
+ return readFileSync(logPath, "utf8")
280
+ .split("\n")
281
+ .filter((line) => line.startsWith("prompt "))
282
+ .map((line) => line.slice("prompt ".length));
283
+ }
284
+
285
+ function spawnCount(logPath: string): number {
286
+ if (!existsSync(logPath)) return 0;
287
+ return readFileSync(logPath, "utf8")
288
+ .split("\n")
289
+ .filter((line) => line.startsWith("spawn ")).length;
290
+ }
291
+
292
+ async function waitUntil(
293
+ predicate: () => boolean,
294
+ message: string,
295
+ timeoutMs = 10_000,
296
+ ): Promise<void> {
297
+ const deadline = Date.now() + timeoutMs;
298
+ while (!predicate() && Date.now() < deadline) await Bun.sleep(10);
299
+ if (!predicate()) throw new Error(message);
300
+ }
301
+
302
+ function implementationEpics(state: LaneStateView): number[] {
303
+ return state.lanes
304
+ .filter((lane) => lane.kind === "implementation" && lane.epic !== null)
305
+ .map((lane) => lane.epic?.number ?? 0);
306
+ }
307
+
308
+ afterAll(async () => {
309
+ await Promise.all(
310
+ temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
311
+ );
312
+ });
313
+
314
+ describe("parallel lifecycle", () => {
315
+ test(
316
+ "stops every running lane at once and returns to the idle stage",
317
+ async () => {
318
+ const host = await makeHost([epic(1), epic(2), epic(3)]);
319
+ const { server, url } = await startServer(host, {
320
+ agentArguments: "--lane-delay-ms=5000",
321
+ });
322
+ const client = await connect(url);
323
+
324
+ try {
325
+ client.send({ type: "startLifecycle" });
326
+ await waitForLanes(
327
+ client,
328
+ (state) =>
329
+ state.lanes.filter(
330
+ (lane) => lane.kind === "implementation" && lane.status === "running",
331
+ ).length === 3,
332
+ );
333
+
334
+ client.send({ type: "stopLifecycle" });
335
+ expect(
336
+ await client.waitFor(
337
+ (message) => message.type === "lifecycleState" && message.stage === "goal",
338
+ ),
339
+ ).toEqual({
340
+ type: "lifecycleState",
341
+ stage: "goal",
342
+ pass: 1,
343
+ work: null,
344
+ paused: false,
345
+ });
346
+
347
+ const stopped = await waitForLanes(
348
+ client,
349
+ (state) =>
350
+ state.runningLanes === 0 &&
351
+ state.lanes
352
+ .filter((lane) => lane.kind === "implementation")
353
+ .every((lane) => lane.status === "failed"),
354
+ );
355
+ expect(stopped.lanes.filter((lane) => lane.kind === "implementation")).toHaveLength(3);
356
+ expect(stopped.openEpics).toBe(3);
357
+
358
+ client.send({ type: "stopLifecycle" });
359
+ expect(await client.waitFor((message) => message.type === "error")).toEqual({
360
+ type: "error",
361
+ message: "No lifecycle run is active.",
362
+ });
363
+ } finally {
364
+ client.close();
365
+ server.stop(true);
366
+ }
367
+ },
368
+ 20000,
369
+ );
370
+
371
+ test(
372
+ "runs one overlapping implementation lane per open epic with no lane cap",
373
+ async () => {
374
+ const ids = [1, 2, 3, 4, 5];
375
+ const host = await makeHost(ids.map((id) => epic(id)));
376
+ const delays = ids.map((id) => `--lane-delay=${id}:900`).join(" ");
377
+ const { server, url } = await startServer(host, { agentArguments: delays });
378
+ const client = await connect(url);
379
+
380
+ try {
381
+ client.send({ type: "startLifecycle" });
382
+ const running = await waitForLanes(
383
+ client,
384
+ (state) =>
385
+ state.lanes.filter(
386
+ (lane) => lane.kind === "implementation" && lane.status === "running",
387
+ ).length === 5,
388
+ );
389
+ expect(running.openEpics).toBe(5);
390
+ expect(running.runningLanes).toBe(5);
391
+ expect(implementationEpics(running).sort((left, right) => left - right)).toEqual(ids);
392
+
393
+ await waitUntil(
394
+ () => prompts(host.logPath).filter((line) => line.startsWith("/implement-next-epic")).length === 5,
395
+ "not every epic received an implementation peer",
396
+ );
397
+ const assigned = prompts(host.logPath)
398
+ .filter((line) => line.startsWith("/implement-next-epic"))
399
+ .map((line) => Number(line.split(" ")[1]));
400
+ expect(assigned.sort((left, right) => left - right)).toEqual(ids);
401
+ expect(new Set(running.lanes.map((lane) => lane.id)).size).toBe(5);
402
+
403
+ await client.waitFor(
404
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
405
+ );
406
+ const finished = [...client.events]
407
+ .reverse()
408
+ .map((message) => laneState(message))
409
+ .find((state) => state !== undefined);
410
+ expect(finished?.lanes.filter((lane) => lane.kind === "implementation")).toHaveLength(5);
411
+ expect(finished?.lanes.every((lane) => lane.status === "closed")).toBe(true);
412
+ } finally {
413
+ client.close();
414
+ server.stop(true);
415
+ }
416
+ },
417
+ 30_000,
418
+ );
419
+
420
+ test(
421
+ "generation files several epics and each one starts its own lane immediately",
422
+ async () => {
423
+ const host = await makeHost([]);
424
+ const { server, url } = await startServer(host, {
425
+ agentArguments:
426
+ "--generate-epic=11:Alpha --generate-epic=12:Beta --generate-epic=13:Gamma " +
427
+ "--lane-delay=11:900 --lane-delay=12:900 --lane-delay=13:900",
428
+ });
429
+ const client = await connect(url);
430
+
431
+ try {
432
+ client.send({ type: "startLifecycle" });
433
+ const running = await waitForLanes(
434
+ client,
435
+ (state) =>
436
+ state.openEpics === 3 &&
437
+ state.lanes.filter(
438
+ (lane) => lane.kind === "implementation" && lane.status === "running",
439
+ ).length === 3,
440
+ );
441
+ expect(running.runningLanes).toBe(3);
442
+ expect(implementationEpics(running).sort((left, right) => left - right)).toEqual([
443
+ 11, 12, 13,
444
+ ]);
445
+ expect(
446
+ running.lanes.filter((lane) => lane.kind === "generation").length,
447
+ ).toBe(1);
448
+ expect(
449
+ prompts(host.logPath).filter((line) => line === "/epic-next"),
450
+ ).toHaveLength(1);
451
+
452
+ await client.waitFor(
453
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
454
+ );
455
+ } finally {
456
+ client.close();
457
+ server.stop(true);
458
+ }
459
+ },
460
+ 30_000,
461
+ );
462
+
463
+ test(
464
+ "refills a new lane on close while other lanes keep implementing",
465
+ async () => {
466
+ const host = await makeHost([epic(21, "Long lane"), epic(22, "Short lane"), epic(23, "Shorter lane")]);
467
+ const { server, url } = await startServer(host, {
468
+ agentArguments:
469
+ "--generate-epic=24:Filed by generation --generation-budget=1 " +
470
+ "--lane-delay=21:2500 --lane-delay=22:200 --lane-delay=23:350 --lane-delay=24:2500",
471
+ });
472
+ const client = await connect(url);
473
+
474
+ try {
475
+ client.send({ type: "startLifecycle" });
476
+ const refilled = await waitForLanes(client, (state) => {
477
+ const running = state.lanes.filter(
478
+ (lane) => lane.kind === "implementation" && lane.status === "running",
479
+ );
480
+ return (
481
+ running.some((lane) => lane.epic?.number === 21) &&
482
+ running.some((lane) => lane.epic?.number === 24)
483
+ );
484
+ });
485
+ expect(refilled.lanes.some((lane) => lane.kind === "generation")).toBe(true);
486
+ const longLane = refilled.lanes.find(
487
+ (lane) => lane.kind === "implementation" && lane.epic?.number === 21,
488
+ );
489
+ expect(longLane?.status).toBe("running");
490
+ await waitForLanes(
491
+ client,
492
+ (state) =>
493
+ state.openEpics === 2 &&
494
+ state.lanes.some(
495
+ (lane) =>
496
+ lane.kind === "implementation" &&
497
+ lane.epic?.number === 21 &&
498
+ lane.status === "running",
499
+ ),
500
+ );
501
+
502
+ await client.waitFor(
503
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
504
+ );
505
+ expect(
506
+ prompts(host.logPath).filter((line) => line === "/implement-next-epic 24"),
507
+ ).toHaveLength(1);
508
+ } finally {
509
+ client.close();
510
+ server.stop(true);
511
+ }
512
+ },
513
+ 30_000,
514
+ );
515
+
516
+ test(
517
+ "coalesces refill triggers that arrive during an active generation run",
518
+ async () => {
519
+ const host = await makeHost([epic(31, "Long"), epic(32, "Early"), epic(33, "Later")]);
520
+ const { server, url } = await startServer(host, {
521
+ agentArguments:
522
+ "--delay-ms=400 --lane-delay=31:2600 --lane-delay=32:150 --lane-delay=33:250",
523
+ });
524
+ const client = await connect(url);
525
+
526
+ try {
527
+ client.send({ type: "startLifecycle" });
528
+ await waitUntil(
529
+ () => prompts(host.logPath).filter((line) => line === "/epic-next").length === 2,
530
+ "the two coalesced generation runs did not start",
531
+ 5000,
532
+ );
533
+ const running = [...client.events]
534
+ .reverse()
535
+ .map((message) => laneState(message))
536
+ .find(
537
+ (state) =>
538
+ state !== undefined &&
539
+ state.lanes.some(
540
+ (lane) =>
541
+ lane.kind === "implementation" &&
542
+ lane.epic?.number === 31 &&
543
+ lane.status === "running",
544
+ ),
545
+ );
546
+ expect(running).toBeDefined();
547
+ expect(
548
+ prompts(host.logPath).filter((line) => line === "/epic-next").length,
549
+ ).toBe(2);
550
+ expect(spawnCount(host.logPath)).toBe(5);
551
+
552
+ await client.waitFor(
553
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
554
+ );
555
+ expect(
556
+ prompts(host.logPath).filter((line) => line === "/epic-next").length,
557
+ ).toBe(3);
558
+ } finally {
559
+ client.close();
560
+ server.stop(true);
561
+ }
562
+ },
563
+ 30_000,
564
+ );
565
+
566
+ test(
567
+ "retries an epic that stays open after the backoff without a concurrent duplicate",
568
+ async () => {
569
+ const host = await makeHost([epic(41, "Keeps failing")]);
570
+ const { server, url } = await startServer(host, {
571
+ agentArguments: "--no-close --lane-delay=41:40",
572
+ laneBackoffMs: 500,
573
+ mainPollMs: 60_000,
574
+ });
575
+ const client = await connect(url);
576
+
577
+ try {
578
+ client.send({ type: "startLifecycle" });
579
+ const retried = await waitForLanes(client, (state) => {
580
+ const lanes = state.lanes.filter(
581
+ (lane) => lane.kind === "implementation" && lane.epic?.number === 41,
582
+ );
583
+ return (
584
+ lanes.length === 2 &&
585
+ lanes.some((lane) => lane.attempt === 1 && lane.status === "retrying") &&
586
+ lanes.some((lane) => lane.attempt === 2 && lane.status === "running")
587
+ );
588
+ });
589
+ expect(retried.runningLanes).toBe(1);
590
+ expect(
591
+ retried.lanes.filter(
592
+ (lane) => lane.kind === "implementation" && lane.status === "running",
593
+ ),
594
+ ).toHaveLength(1);
595
+ await waitUntil(
596
+ () =>
597
+ prompts(host.logPath).filter((line) => line === "/implement-next-epic 41").length === 2,
598
+ "the retried lane did not spawn",
599
+ );
600
+ } finally {
601
+ client.close();
602
+ server.stop(true);
603
+ }
604
+ },
605
+ 30_000,
606
+ );
607
+
608
+ test(
609
+ "retries a retrying epic immediately when main advances",
610
+ async () => {
611
+ const host = await makeHost([epic(51, "Waits for main")]);
612
+ const { server, url } = await startServer(host, {
613
+ agentArguments: "--no-close --lane-delay=51:40",
614
+ laneBackoffMs: 60_000,
615
+ mainPollMs: 25,
616
+ });
617
+ const client = await connect(url);
618
+
619
+ try {
620
+ client.send({ type: "startLifecycle" });
621
+ await waitForLanes(client, (state) =>
622
+ state.lanes.some(
623
+ (lane) =>
624
+ lane.kind === "implementation" &&
625
+ lane.epic?.number === 51 &&
626
+ lane.status === "retrying",
627
+ ),
628
+ );
629
+ await git(host.root, ["commit", "--allow-empty", "-m", "advance main"]);
630
+
631
+ await waitForLanes(client, (state) =>
632
+ state.lanes.some(
633
+ (lane) =>
634
+ lane.kind === "implementation" &&
635
+ lane.epic?.number === 51 &&
636
+ lane.attempt === 2,
637
+ ),
638
+ );
639
+ await waitUntil(
640
+ () =>
641
+ prompts(host.logPath).filter((line) => line === "/implement-next-epic 51").length === 2,
642
+ "the retried lane did not spawn",
643
+ );
644
+ } finally {
645
+ client.close();
646
+ server.stop(true);
647
+ }
648
+ },
649
+ 30_000,
650
+ );
651
+
652
+ test(
653
+ "parks a blocked lane without delaying others, retries it on main, then stops with an error",
654
+ async () => {
655
+ const host = await makeHost([epic(61, "Blocked"), epic(62, "Healthy")]);
656
+ const { server, url } = await startServer(host, {
657
+ agentArguments:
658
+ "--blocker-epic=61 --lane-delay=61:40 --lane-delay=62:1500",
659
+ mainPollMs: 25,
660
+ });
661
+ const client = await connect(url);
662
+
663
+ try {
664
+ client.send({ type: "startLifecycle" });
665
+ const isolated = await waitForLanes(client, (state) => {
666
+ const blocked = state.lanes.find(
667
+ (lane) => lane.kind === "implementation" && lane.epic?.number === 61,
668
+ );
669
+ const healthy = state.lanes.find(
670
+ (lane) => lane.kind === "implementation" && lane.epic?.number === 62,
671
+ );
672
+ return blocked?.status === "parked" && healthy?.status === "running";
673
+ });
674
+ expect(isolated.lanes.every((lane) => lane.epic?.number !== 61 || lane.status !== "running")).toBe(true);
675
+ expect(
676
+ client.events.filter(
677
+ (message) => message.type === "error" && String(message.message).includes("parked"),
678
+ ),
679
+ ).toEqual([]);
680
+
681
+ await git(host.root, ["commit", "--allow-empty", "-m", "advance for parked lane"]);
682
+ await waitForLanes(client, (state) =>
683
+ state.lanes.some(
684
+ (lane) =>
685
+ lane.kind === "implementation" &&
686
+ lane.epic?.number === 61 &&
687
+ lane.attempt === 2,
688
+ ),
689
+ );
690
+
691
+ const error = await client.waitFor(
692
+ (message) =>
693
+ message.type === "error" &&
694
+ message.message === "Lifecycle stopped with parked epics: #61.",
695
+ );
696
+ expect(error.message).toBe("Lifecycle stopped with parked epics: #61.");
697
+ await client.waitFor(
698
+ (message) => message.type === "lifecycleState" && message.stage === "goal",
699
+ );
700
+ expect(readTracker(host.trackerFile).map((entry) => entry.number)).toEqual([61]);
701
+ } finally {
702
+ client.close();
703
+ server.stop(true);
704
+ }
705
+ },
706
+ 30_000,
707
+ );
708
+
709
+ test(
710
+ "replays counts, tabs, activity, and pending questions to a reconnecting client",
711
+ async () => {
712
+ const host = await makeHost([epic(71, "Asking"), epic(72, "Quiet")]);
713
+ const { server, url } = await startServer(host, {
714
+ agentArguments:
715
+ "--question-epic=71 --lane-delay=71:1200 --lane-delay=72:1800",
716
+ mainPollMs: 60_000,
717
+ });
718
+ const client = await connect(url);
719
+
720
+ try {
721
+ client.send({ type: "startLifecycle" });
722
+ const questionEvent = await client.waitFor(
723
+ (message) => message.type === "question" && typeof message.lane === "string",
724
+ );
725
+ const late = await connect(url);
726
+ try {
727
+ const state = await waitForLanes(late, (candidate) => candidate.runningLanes === 2);
728
+ expect(state.openEpics).toBe(2);
729
+ expect(implementationEpics(state).sort((left, right) => left - right)).toEqual([
730
+ 71, 72,
731
+ ]);
732
+ const asking = state.lanes.find((lane) => lane.epic?.number === 71);
733
+ const quiet = state.lanes.find((lane) => lane.epic?.number === 72);
734
+ expect(asking?.activity).toEqual(["Working on epic 71"]);
735
+ expect(quiet?.activity).toEqual(["Working on epic 72"]);
736
+ expect(asking?.question).toMatchObject({ id: questionEvent.id });
737
+ expect(quiet?.question).toBeNull();
738
+ expect(
739
+ late.events.filter(
740
+ (message) => message.type === "activity" && typeof message.lane !== "string",
741
+ ),
742
+ ).toEqual([]);
743
+ expect(asking?.id).toBe(String(questionEvent.lane));
744
+
745
+ client.send({
746
+ type: "answer",
747
+ lane: asking?.id,
748
+ id: asking?.question?.id,
749
+ value: "Beta",
750
+ });
751
+ await waitUntil(
752
+ () =>
753
+ readFileSync(host.logPath, "utf8")
754
+ .split("\n")
755
+ .includes("answer Beta"),
756
+ "the answer did not reach its lane",
757
+ );
758
+ const after = await waitForLanes(late, (candidate) => {
759
+ const lane = candidate.lanes.find((entry) => entry.id === asking?.id);
760
+ return lane?.question === null;
761
+ });
762
+ const stillQuiet = after.lanes.find((lane) => lane.epic?.number === 72);
763
+ expect(stillQuiet?.activity).toEqual(["Working on epic 72"]);
764
+ expect(stillQuiet?.question).toBeNull();
765
+ } finally {
766
+ late.close();
767
+ }
768
+ } finally {
769
+ client.close();
770
+ server.stop(true);
771
+ }
772
+ },
773
+ 30_000,
774
+ );
775
+
776
+ test(
777
+ "pauses every lane and resumes all of them",
778
+ async () => {
779
+ const host = await makeHost([epic(81, "Pause one"), epic(82, "Pause two")]);
780
+ const { server, url } = await startServer(host, {
781
+ agentArguments: "--lane-delay=81:1500 --lane-delay=82:1500",
782
+ mainPollMs: 60_000,
783
+ });
784
+ const client = await connect(url);
785
+
786
+ try {
787
+ client.send({ type: "startLifecycle" });
788
+ await waitForLanes(client, (state) => state.runningLanes === 2);
789
+
790
+ client.send({ type: "pauseLifecycle" });
791
+ const paused = await waitForLanes(
792
+ client,
793
+ (state) =>
794
+ state.runningLanes === 0 &&
795
+ state.lanes.filter(
796
+ (lane) => lane.kind === "implementation" && lane.status === "paused",
797
+ ).length === 2,
798
+ );
799
+ expect(paused.lanes.filter((lane) => lane.kind === "implementation")).toHaveLength(2);
800
+ const spawnsAtPause = spawnCount(host.logPath);
801
+ await Bun.sleep(400);
802
+ expect(spawnCount(host.logPath)).toBe(spawnsAtPause);
803
+
804
+ client.send({ type: "resumeLifecycle" });
805
+ await waitForLanes(
806
+ client,
807
+ (state) =>
808
+ state.runningLanes === 2 &&
809
+ state.lanes
810
+ .filter((lane) => lane.kind === "implementation")
811
+ .every((lane) => lane.status === "running"),
812
+ );
813
+ await client.waitFor(
814
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
815
+ );
816
+ } finally {
817
+ client.close();
818
+ server.stop(true);
819
+ }
820
+ },
821
+ 30_000,
822
+ );
823
+
824
+ test(
825
+ "reset stops every lane before the existing reset contract runs",
826
+ async () => {
827
+ const host = await makeHost([epic(91, "Reset one"), epic(92, "Reset two")]);
828
+ const { server, url } = await startServer(host, {
829
+ agentArguments: "--lane-delay=91:3000 --lane-delay=92:3000",
830
+ mainPollMs: 60_000,
831
+ });
832
+ const client = await connect(url);
833
+
834
+ try {
835
+ client.send({ type: "startLifecycle" });
836
+ await waitForLanes(client, (state) => state.runningLanes === 2);
837
+
838
+ client.send({ type: "resetImplementation" });
839
+ const emptied = await waitForLanes(client, (state) => state.lanes.length === 0);
840
+ expect(emptied.runningLanes).toBe(0);
841
+ const stopped = await client.waitFor(
842
+ (message) => message.type === "implementationResetStopped",
843
+ );
844
+ expect(stopped).toMatchObject({ completed: true });
845
+ expect(readdirSync(host.root).filter((name) => name === "adrs")).toEqual([]);
846
+ // The reset's own outcome is durable: pass 1, idle goal, no lanes.
847
+ // The server recreates its state directory to record exactly that.
848
+ const files = readdirSync(join(host.root, "state", "state"));
849
+ expect(files.length).toBe(1);
850
+ const stored = JSON.parse(
851
+ readFileSync(join(host.root, "state", "state", String(files[0])), "utf8"),
852
+ ) as { state: { lifecycle: unknown; lanes: unknown[] } };
853
+ expect(stored.state.lifecycle).toEqual({
854
+ stage: "goal",
855
+ pass: 1,
856
+ work: null,
857
+ paused: false,
858
+ });
859
+ expect(stored.state.lanes).toEqual([]);
860
+ } finally {
861
+ client.close();
862
+ server.stop(true);
863
+ }
864
+ },
865
+ 30_000,
866
+ );
867
+
868
+ test(
869
+ "finishes when no epic, ADR, or parked lane remains",
870
+ async () => {
871
+ const host = await makeHost([]);
872
+ const { server, url } = await startServer(host);
873
+ const client = await connect(url);
874
+
875
+ try {
876
+ client.send({ type: "startLifecycle" });
877
+ await client.waitFor(
878
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
879
+ );
880
+ expect(prompts(host.logPath)).toEqual(["/epic-next"]);
881
+ const state = [...client.events]
882
+ .reverse()
883
+ .map((message) => laneState(message))
884
+ .find((candidate) => candidate !== undefined);
885
+ expect(state?.openEpics).toBe(0);
886
+ expect(state?.runningLanes).toBe(0);
887
+ expect(state?.lanes.map((lane) => lane.status)).toEqual(["closed"]);
888
+ } finally {
889
+ client.close();
890
+ server.stop(true);
891
+ }
892
+ },
893
+ 30_000,
894
+ );
895
+
896
+ test(
897
+ "drops a closed lane from the active list after its retention window",
898
+ async () => {
899
+ const host = await makeHost([]);
900
+ const { server, url } = await startServer(host, { laneRetentionMs: 100 });
901
+ const client = await connect(url);
902
+
903
+ try {
904
+ client.send({ type: "startLifecycle" });
905
+ await client.waitFor(
906
+ (message) => message.type === "lifecycleState" && message.stage === "finished",
907
+ );
908
+ const closed = [...client.events]
909
+ .reverse()
910
+ .map((message) => laneState(message))
911
+ .find((state) => state?.lanes.some((lane) => lane.status === "closed"));
912
+ const laneId = closed?.lanes.find((lane) => lane.status === "closed")?.id;
913
+ expect(laneId).toBeDefined();
914
+
915
+ await waitUntil(
916
+ () => {
917
+ const states = client.events
918
+ .map((message) => laneState(message))
919
+ .filter((state) => state !== undefined);
920
+ const latest = states[states.length - 1];
921
+ return latest !== undefined && !latest.lanes.some((lane) => lane.id === laneId);
922
+ },
923
+ "the closed lane was not removed after its retention window",
924
+ );
925
+ } finally {
926
+ client.close();
927
+ server.stop(true);
928
+ }
929
+ },
930
+ 30_000,
931
+ );
932
+ });
933
+
934
+ describe("tracker reads every open epic", () => {
935
+ async function trackerHost(
936
+ remote: string,
937
+ cli: "gh" | "glab",
938
+ rows: unknown[],
939
+ ): Promise<{ root: string; bin: string }> {
940
+ await mkdir(TEST_ROOT, { recursive: true });
941
+ const root = await mkdtemp(join(TEST_ROOT, "tracker-"));
942
+ temporaryDirectories.push(root);
943
+ await git(root, ["init", "-b", "main"]);
944
+ await git(root, ["remote", "add", "origin", remote]);
945
+ const bin = join(root, "bin");
946
+ await mkdir(bin, { recursive: true });
947
+ const output = JSON.stringify(rows);
948
+ const script =
949
+ cli === "gh"
950
+ ? `#!/bin/sh
951
+ case "$*" in
952
+ "auth status --hostname github.com --json hosts") echo '{"hosts":{"github.com":[{"login":"reviewer","active":true,"state":"success"}]}}' ;;
953
+ "auth token --hostname github.com --user reviewer") echo "gho_test" ;;
954
+ "api --hostname github.com repos/owner/repo --jq .permissions") echo '{"admin":false,"maintain":false,"push":true,"triage":true,"pull":true}' ;;
955
+ "label list --search epic --limit 100 --json name") echo '[{"name":"epic"}]' ;;
956
+ "issue list --label epic --state open --json number,title,url --limit 100") echo '${output}' ;;
957
+ *) echo "unexpected gh $*" >&2; exit 1 ;;
958
+ esac
959
+ `
960
+ : `#!/bin/sh
961
+ case "$*" in
962
+ "auth status --hostname gitlab.com --show-token") echo " ✓ Token: glpat-test" ;;
963
+ "api --hostname gitlab.com user") echo '{"username":"reviewer"}' ;;
964
+ "api --hostname gitlab.com projects/group%2Fproject") echo '{"visibility":"private","permissions":{"project_access":{"access_level":40}}}' ;;
965
+ "label list --output json") echo '[{"name":"epic"}]' ;;
966
+ "issue list --label epic --output json") echo '${output}' ;;
967
+ *) echo "unexpected glab $*" >&2; exit 1 ;;
968
+ esac
969
+ `;
970
+ await writeFile(join(bin, cli), script);
971
+ await chmod(join(bin, cli), 0o755);
972
+ return { root, bin };
973
+ }
974
+
975
+ async function readThroughCli(root: string, bin: string): Promise<OpenEpic[]> {
976
+ const script = join(root, "read-epics.ts");
977
+ const trackerModule = join(import.meta.dir, "tracker.ts");
978
+ await writeFile(
979
+ script,
980
+ `import { findOpenEpics } from ${JSON.stringify(trackerModule)};\n` +
981
+ `process.stdout.write(JSON.stringify(await findOpenEpics(process.argv[2])));\n`,
982
+ );
983
+ const result = Bun.spawnSync({
984
+ cmd: [process.execPath, script, root],
985
+ env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` },
986
+ stdout: "pipe",
987
+ stderr: "pipe",
988
+ });
989
+ if (result.exitCode !== 0) {
990
+ throw new Error(`tracker read failed: ${result.stderr.toString()}`);
991
+ }
992
+ return JSON.parse(result.stdout.toString()) as OpenEpic[];
993
+ }
994
+
995
+ test("reads five open GitHub epics with the existing query shape", async () => {
996
+ const rows = [1, 2, 3, 4, 5].map((number) => ({
997
+ number,
998
+ title: `GitHub epic ${number}`,
999
+ url: `https://github.com/owner/repo/issues/${number}`,
1000
+ }));
1001
+ const { root, bin } = await trackerHost(
1002
+ "https://github.com/owner/repo.git",
1003
+ "gh",
1004
+ rows,
1005
+ );
1006
+
1007
+ const epics: OpenEpic[] = await readThroughCli(root, bin);
1008
+ expect(epics).toHaveLength(5);
1009
+ expect(epics.map((entry) => entry.number)).toEqual([1, 2, 3, 4, 5]);
1010
+ expect(epics[4]?.url).toBe("https://github.com/owner/repo/issues/5");
1011
+ });
1012
+
1013
+ test("reads five open GitLab epics with the existing query shape", async () => {
1014
+ const rows = [11, 12, 13, 14, 15].map((number) => ({
1015
+ iid: number,
1016
+ title: `GitLab epic ${number}`,
1017
+ web_url: `https://gitlab.com/group/project/-/issues/${number}`,
1018
+ }));
1019
+ const { root, bin } = await trackerHost(
1020
+ "https://gitlab.com/group/project.git",
1021
+ "glab",
1022
+ rows,
1023
+ );
1024
+
1025
+ const epics: OpenEpic[] = await readThroughCli(root, bin);
1026
+ expect(epics).toHaveLength(5);
1027
+ expect(epics.map((entry) => entry.number)).toEqual([11, 12, 13, 14, 15]);
1028
+ });
1029
+ });