catherd-cli 1.4.0 → 2.0.0-beta.20261022113309

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 (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -0,0 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import type { Run } from "./run-store.ts";
5
+
6
+ /** `<run dir>/trees/<m>`: where milestone `m` of a base run works. */
7
+ export const treeDir = (run: Run, m: string): string => join(run.dir, "trees", m);
8
+
9
+ /** The marker beside the tree, never inside it, that Setup: finished. */
10
+ export const readyFile = (run: Run, m: string): string => join(run.dir, "trees", `${m}.ready`);
11
+
12
+ /**
13
+ * Where milestone `m` works: its worktree in a base run once it exists, else the repo (a run without base, the
14
+ * run itself, a milestone not claimed yet). A claimed milestone whose folder is gone is an error, never the repo:
15
+ * its commits, checks and merge would land in the owner's own checkout. Kept apart from milestone-tree.ts so the services that only ask
16
+ * where a milestone works do not import the claiming code, which imports them.
17
+ */
18
+ export function treeOf(run: Run, m: string | null): string {
19
+ if (!run.meta.base || m === null) return run.meta.repo;
20
+ const tree = treeDir(run, m);
21
+ if (existsSync(tree)) return tree;
22
+ if (existsSync(readyFile(run, m)))
23
+ throw new CatherdError("E_TREE", `the worktree of ${m} is gone: ${tree}`, {
24
+ fix: "restore the tree (git worktree add it back on the milestone's branch) or cancel the run",
25
+ });
26
+ return run.meta.repo;
27
+ }
@@ -0,0 +1,23 @@
1
+ import { withGateNotes } from "../domain/gate-brief.ts";
2
+ import { gateEnvLines, readGateEnv } from "./gate-env.ts";
3
+ import { recordedItems } from "./gate-service.ts";
4
+ import type { Run } from "./run-store.ts";
5
+
6
+ // Plan 23: a dispatched verifier's brief ends with the gate's rules and the run's facts: the milestone's
7
+ // recorded items (reuse their names), the failed ones (a re-check runs them first, each command capped), and
8
+ // the repo's gate environment.
9
+
10
+ /** The milestone a verifier dispatch checks: the first `M<n>` its name names as a word (verifier-M1 → M1). */
11
+ export const milestoneOfName = (name: string): string | null =>
12
+ /(?:^|[^A-Za-z0-9])(M\d+)(?=$|[-._\s])/.exec(name)?.[1] ?? null;
13
+
14
+ /** A verifier's brief with the gate notes, once; any other role's brief as it is. */
15
+ export async function verifierBrief(run: Run, role: string, name: string, brief: string): Promise<string> {
16
+ if (role !== "verifier") return brief;
17
+ const milestone = milestoneOfName(name);
18
+ return withGateNotes(brief, {
19
+ milestone,
20
+ recorded: milestone ? await recordedItems(run, milestone) : [],
21
+ env: gateEnvLines(await readGateEnv(run.meta.repo)),
22
+ });
23
+ }
@@ -0,0 +1,48 @@
1
+ import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
2
+ import { readJsonl } from "../infra/store.ts";
3
+ import { gatesFile, latestVerifierStep, type VerifierStep } from "./gate-service.ts";
4
+ import { readAgentRuns, readRecords, type Run } from "./run-store.ts";
5
+ import { runOwner } from "./sessions.ts";
6
+
7
+ /** What ended a verifier step (plan 22: a step nothing ends shows as live forever). */
8
+ export type StepClosedBy = "gate_pass" | "agent-run" | "record" | "owner-gone";
9
+
10
+ export interface VerifierStepView extends VerifierStep {
11
+ /** seconds since the step began */
12
+ secs: number;
13
+ open: boolean;
14
+ closedBy: StepClosedBy | null;
15
+ }
16
+
17
+ /**
18
+ * Plan 22: the verifier's latest step with its age, closed by the first evidence after it: a `gate_pass` of its
19
+ * item in this run, a native verifier's `record_agent_run`, a verifier dispatch's record, or its owner session
20
+ * gone (another session took the run since, or the owner's Claude Code session no longer runs). A Codex owner
21
+ * that still holds the run has no liveness to read: its step stays open until other evidence ends it.
22
+ */
23
+ export function verifierStepView(
24
+ run: Run,
25
+ now: number,
26
+ files: () => SessionFile[] = readSessionFiles,
27
+ ): VerifierStepView | null {
28
+ const step = latestVerifierStep(run);
29
+ if (!step) return null;
30
+ const at = Date.parse(step.at) || 0;
31
+ const after = (t: string | undefined) => (Date.parse(t ?? "") || 0) >= at;
32
+ const passed = readJsonl<{ run?: unknown; item?: unknown; at?: string }>(
33
+ gatesFile(run.meta.repo),
34
+ ).rows.some((p) => p?.run === run.id && p.item === step.item && after(p.at));
35
+ const owner = runOwner(run);
36
+ const closedBy: StepClosedBy | null = passed
37
+ ? "gate_pass"
38
+ : readAgentRuns(run).some((a) => a.role === "verifier" && after(a.at))
39
+ ? "agent-run"
40
+ : readRecords(run).records.some((r) => r.role === "verifier" && after(r.endedAt))
41
+ ? "record"
42
+ : owner &&
43
+ ((Date.parse(owner.since) || 0) > at ||
44
+ (owner.host === "claude-code" && liveSessionFile(owner.sessionId, files()) === null))
45
+ ? "owner-gone"
46
+ : null;
47
+ return { ...step, secs: Math.max(0, Math.floor((now - at) / 1000)), open: closedBy === null, closedBy };
48
+ }
@@ -0,0 +1,259 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { budgetStatus, formatBudget, type BudgetStatus, type Spend } from "../domain/budget.ts";
3
+ import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
4
+ import type { Workspace, WorkspaceStep } from "../domain/workspace.ts";
5
+ import { withFileLock } from "../infra/filelock.ts";
6
+ import { gitToplevel, isAncestor } from "../infra/git.ts";
7
+ import { writeTextAtomic } from "../infra/store.ts";
8
+ import { spendOf } from "./budget.ts";
9
+ import { pendingDispatches } from "./dispatches.ts";
10
+ import { landedCommitOf, landedMilestones } from "./milestones.ts";
11
+ import { readRecords, type Run, runPaths } from "./run-store.ts";
12
+ import {
13
+ findWorkspace,
14
+ saveWorkspace,
15
+ WORKSPACE_LOCK_WAIT_MS,
16
+ workspaceChildren,
17
+ workspaceDirectory,
18
+ workspacePaths,
19
+ } from "./workspace-store.ts";
20
+
21
+ /**
22
+ * Count each recorded or unrecorded dispatch once; elapsed minutes belong to the parent and start at its first
23
+ * child (#43 finding 4), none before one. With `warnings`, a child whose cost evidence cannot be read is
24
+ * skipped and named there (status); without, it throws E_RUN_CORRUPT (admission against a cap, which cannot
25
+ * prove the cap holds without it).
26
+ */
27
+ export async function workspaceSpend(
28
+ workspace: Workspace,
29
+ now: number,
30
+ children = workspaceChildren(workspace),
31
+ warnings?: string[],
32
+ ): Promise<Spend> {
33
+ const first = Math.min(...children.map((c) => Date.parse(c.meta.createdAt)).filter(Number.isFinite));
34
+ const total: Spend = {
35
+ minutes: Number.isFinite(first) ? Math.max(0, (now - first) / 60_000) : 0,
36
+ tokens: 0,
37
+ usd: 0,
38
+ };
39
+ for (const child of children) {
40
+ try {
41
+ const spent = await withFileLock(runPaths(child.dir).runs, () => {
42
+ const { records, corrupt } = readRecords(child);
43
+ if (corrupt) throw new CatherdError("E_RUN_CORRUPT", `run ${child.id} has unreadable cost records`);
44
+ return spendOf(child, records, pendingDispatches(child, now, records, true), now, true);
45
+ });
46
+ total.tokens += spent.tokens;
47
+ total.usd += spent.usd;
48
+ } catch (e) {
49
+ if (!warnings || !isCatherdError(e) || e.code !== "E_RUN_CORRUPT") throw e;
50
+ warnings.push(`spend leaves out ${child.id}: ${errorMessage(e)}`);
51
+ }
52
+ }
53
+ return total;
54
+ }
55
+
56
+ /** The workspace budget a child run's routing sees; a child whose evidence is unreadable counts nothing. */
57
+ export async function workspaceBudget(run: Run, now: number): Promise<BudgetStatus | null> {
58
+ if (!run.meta.workspace) return null;
59
+ const workspace = findWorkspace(run.meta.workspace.id);
60
+ return budgetStatus(await workspaceSpend(workspace, now, undefined, []), workspace.budget);
61
+ }
62
+
63
+ /** Why a dependency keeps a step waiting: the dependency's step id and the reason, in words. */
64
+ export interface Blocker {
65
+ step: string;
66
+ why: string;
67
+ }
68
+
69
+ /** The dependency's uncollected dispatches, read under its records lock: a reason, or null when none. */
70
+ async function uncollected(child: Run, now: number): Promise<string | null> {
71
+ const step = child.meta.workspace?.step ?? child.id;
72
+ try {
73
+ const pending = await withFileLock(runPaths(child.dir).runs, () => {
74
+ const { records, corrupt } = readRecords(child);
75
+ if (corrupt) throw new CatherdError("E_RUN_CORRUPT", `run ${child.id} has unreadable dispatch records`);
76
+ return pendingDispatches(child, now, records, true);
77
+ });
78
+ return pending.length
79
+ ? `${step} has ${pending.length} dispatch(es) not collected (${pending.map((d) => d.admit.name).join(", ")})`
80
+ : null;
81
+ } catch (e) {
82
+ if (isCatherdError(e) && e.code === "E_RUN_CORRUPT") return `${step} cannot be read: ${e.message}`;
83
+ throw e;
84
+ }
85
+ }
86
+
87
+ /** A landed milestone that differs from the step's only by case: `m1` for `M1` (#43 finding 7). */
88
+ const caseTwin = (landed: string[], milestone: string): string | undefined =>
89
+ landed.find((m) => m !== milestone && m.toLowerCase() === milestone.toLowerCase());
90
+
91
+ /**
92
+ * #43 finding 8: the one dependency rule admission, child start and status share. A dependency releases its
93
+ * dependents once its child has landed the dependency's milestone and every dispatch it admitted is
94
+ * collected. Reads only the dependencies' own evidence (#43 finding 2); an unreadable one blocks, named.
95
+ */
96
+ export async function dependencyBlockers(
97
+ workspace: Workspace,
98
+ step: WorkspaceStep,
99
+ children: Run[],
100
+ now: number,
101
+ o: { merge?: boolean } = {},
102
+ ): Promise<Blocker[]> {
103
+ const out: Blocker[] = [];
104
+ for (const id of step.dependsOn) {
105
+ const before = workspace.steps.find((s) => s.id === id);
106
+ const child = children.find((c) => c.meta.workspace?.step === id);
107
+ if (!before || !child) {
108
+ out.push({ step: id, why: `${id} has not started` });
109
+ continue;
110
+ }
111
+ const landed = landedMilestones(child);
112
+ if (!landed.includes(before.milestone)) {
113
+ const twin = caseTwin(landed, before.milestone);
114
+ out.push({
115
+ step: id,
116
+ why: `${id} has not landed ${before.milestone}${twin ? ` (it landed ${twin}: the step completes on ${before.milestone})` : ""}`,
117
+ });
118
+ continue;
119
+ }
120
+ const why = (await uncollected(child, now)) ?? (o.merge ? await unmerged(before, child) : null);
121
+ if (why) out.push({ step: id, why });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * A `release: "merge"` step releases its dependents once its landed commit is an ancestor of its base ref
128
+ * (`git merge-base --is-ancestor`), in the repository as it stands: catherd never fetches or polls.
129
+ */
130
+ async function unmerged(step: WorkspaceStep, child: Run): Promise<string | null> {
131
+ if (step.release !== "merge" || !step.base) return null;
132
+ const commit = landedCommitOf(child, step.milestone);
133
+ if (!commit) return `${step.id} has no landed commit for ${step.milestone} in its ledger`;
134
+ const merged = await isAncestor(child.meta.repo, commit, step.base);
135
+ if (merged === true) return null;
136
+ if (merged === false)
137
+ return `${step.id}'s ${step.milestone} (${commit.slice(0, 7)}) is not merged into ${step.base} yet: merge it, git fetch, then ask again`;
138
+ return `${step.id}: git cannot tell whether ${commit.slice(0, 7)} is in ${step.base} (does the ref exist in ${child.meta.repo}?)`;
139
+ }
140
+
141
+ export function withRunAdmission<T>(run: Run, now: () => number, admit: () => Promise<T>): Promise<T> {
142
+ return withWorkspaceAdmission(run, now, () => withFileLock(runPaths(run.dir).admission, admit));
143
+ }
144
+
145
+ /** Parent → child admission → child records; siblings share the actual admission boundary. */
146
+ export async function withWorkspaceAdmission<T>(
147
+ run: Run,
148
+ clock: number | (() => number),
149
+ admit: () => Promise<T>,
150
+ ): Promise<T> {
151
+ const link = run.meta.workspace;
152
+ if (!link) return admit();
153
+ const workspace = findWorkspace(link.id);
154
+ // git outside the workspace lock (#43 finding 5): the checkout check needs no sibling to wait
155
+ if ((await gitToplevel(run.meta.repo)) !== run.meta.repo)
156
+ throw new CatherdError(
157
+ "E_IO_PATH",
158
+ `workspace repository of ${link.step} is no longer the captured git root`,
159
+ );
160
+ return withFileLock(
161
+ workspacePaths(workspaceDirectory(workspace.id)).admission,
162
+ async () => {
163
+ const now = typeof clock === "function" ? clock() : clock;
164
+ const children = workspaceChildren(workspace);
165
+ const step = workspace.steps.find((s) => s.id === link.step);
166
+ if (!step || !children.some((c) => c.id === run.id))
167
+ throw new CatherdError("E_INPUT_INVALID", "the run is not a member of this workspace execution", {
168
+ fix: "start the child with workspace_child_start",
169
+ });
170
+ // #43 finding 7: a landed step's child admits again (a post-land fix); its dependents wait for it
171
+ const blockers = await dependencyBlockers(workspace, step, children, now);
172
+ if (blockers.length)
173
+ throw new CatherdError(
174
+ "E_INPUT_INVALID",
175
+ `${step.id} is waiting: ${blockers.map((b) => b.why).join("; ")}`,
176
+ {
177
+ fix: "land the dependency's completion milestone and collect its finished dispatches first",
178
+ },
179
+ );
180
+ await assertWorkspaceBudget(workspace, now, children);
181
+ return admit();
182
+ },
183
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
184
+ );
185
+ }
186
+
187
+ /** Whether the budget caps anything: without a cap, no sibling's evidence is read (#43 finding 2). */
188
+ const capped = (workspace: Workspace): boolean =>
189
+ Object.values(workspace.budget).some((v) => typeof v === "number");
190
+
191
+ /** Refuses E_RUN_BUDGET once a capped workspace budget is spent; reads every child only when capped. */
192
+ export async function assertWorkspaceBudget(
193
+ workspace: Workspace,
194
+ now: number,
195
+ children: Run[],
196
+ ): Promise<void> {
197
+ if (!capped(workspace)) return;
198
+ const budget = budgetStatus(await workspaceSpend(workspace, now, children), workspace.budget);
199
+ if (budget && budget.fraction >= 1)
200
+ throw new CatherdError("E_RUN_BUDGET", `the workspace budget is spent: ${formatBudget(budget)}`, {
201
+ fix: `finish with the work already admitted, or raise it: workspace_budget(${workspace.id}, …)`,
202
+ });
203
+ }
204
+
205
+ /**
206
+ * `workspace_budget`: sets the caps given (a number) or removes them (null), keeping the others; the owner's
207
+ * way to raise a spent budget without a new workspace (#43 finding 4). Returns the budget and its status.
208
+ */
209
+ export async function setWorkspaceBudget(
210
+ deps: { now: () => number },
211
+ i: { workspace: string; minutes?: number | null; tokens?: number | null; usd?: number | null },
212
+ ): Promise<{ budget: Workspace["budget"]; spend: Spend; status: BudgetStatus | null }> {
213
+ const workspace = findWorkspace(i.workspace);
214
+ return withFileLock(
215
+ workspacePaths(workspaceDirectory(workspace.id)).admission,
216
+ async () => {
217
+ const budget: Workspace["budget"] = { ...findWorkspace(workspace.id).budget };
218
+ for (const key of ["minutes", "tokens", "usd"] as const) {
219
+ const v = i[key];
220
+ if (v === null) delete budget[key];
221
+ else if (v !== undefined) budget[key] = v;
222
+ }
223
+ const saved = saveWorkspace({ ...findWorkspace(workspace.id), budget });
224
+ const spend = await workspaceSpend(saved, deps.now(), undefined, []);
225
+ return { budget: saved.budget, spend, status: budgetStatus(spend, saved.budget) };
226
+ },
227
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
228
+ );
229
+ }
230
+
231
+ /** The shared contract is frozen when the first child starts, then copied into child dossiers. */
232
+ export async function workspaceContract(i: {
233
+ workspace: string;
234
+ content?: string;
235
+ }): Promise<{ path: string; content: string }> {
236
+ const workspace = findWorkspace(i.workspace);
237
+ const paths = workspacePaths(workspaceDirectory(workspace.id));
238
+ if (i.content === undefined)
239
+ return {
240
+ path: paths.contract,
241
+ content: existsSync(paths.contract) ? readFileSync(paths.contract, "utf8") : "",
242
+ };
243
+ return withFileLock(
244
+ paths.admission,
245
+ () => {
246
+ if (workspaceChildren(workspace).length)
247
+ throw new CatherdError(
248
+ "E_INPUT_INVALID",
249
+ "the workspace contract is frozen because a child has started",
250
+ {
251
+ fix: "start a new workspace execution to change the shared contract",
252
+ },
253
+ );
254
+ writeTextAtomic(paths.contract, i.content as string);
255
+ return { path: paths.contract, content: i.content as string };
256
+ },
257
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
258
+ );
259
+ }
@@ -0,0 +1,259 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { type Budget, budgetStatus } from "../domain/budget.ts";
5
+ import { CatherdError } from "../domain/errors.ts";
6
+ import { WORKSPACE_LIMIT, WorkspaceIdSchema, WorkspaceSchema, type Workspace } from "../domain/workspace.ts";
7
+ import { withFileLock } from "../infra/filelock.ts";
8
+ import { gitToplevel } from "../infra/git.ts";
9
+ import { indexPath } from "../infra/paths.ts";
10
+ import { readIndexFile } from "../infra/slopalytics.ts";
11
+ import { readVersioned, writeTextAtomic } from "../infra/store.ts";
12
+ import { writeRunIndex } from "./catalog-service.ts";
13
+ import { listDispatches } from "./dispatches.ts";
14
+ import { landedCommits, landedMilestones } from "./milestones.ts";
15
+ import { assertNotPaused, pausesOver } from "./pause.ts";
16
+ import { writePin } from "./run-pin.ts";
17
+ import type { Deps } from "./ports.ts";
18
+ import { createRun, readRecords, type Run } from "./run-store.ts";
19
+ import { claimRun, currentSession } from "./sessions.ts";
20
+ import { refreshState } from "./state.ts";
21
+ import { summarizeRun } from "./summary.ts";
22
+ import { assertWorkspaceBudget, dependencyBlockers, workspaceSpend } from "./workspace-admission.ts";
23
+ import {
24
+ createWorkspace,
25
+ findWorkspace,
26
+ unreadableWarning,
27
+ WORKSPACE_LOCK_WAIT_MS,
28
+ workspaceChildren,
29
+ workspaceDirectory,
30
+ workspaceListing,
31
+ workspacePaths,
32
+ } from "./workspace-store.ts";
33
+
34
+ const ManifestSchema = z.strictObject({
35
+ schema: z.literal(1),
36
+ repos: z
37
+ .record(WorkspaceIdSchema, z.string().min(1))
38
+ .refine((repos) => Object.keys(repos).length <= WORKSPACE_LIMIT),
39
+ });
40
+ const invalid = (message: string) => new CatherdError("E_INPUT_INVALID", message);
41
+
42
+ function registry(root: string, repos?: Record<string, string>): Record<string, string> {
43
+ if (repos !== undefined) {
44
+ const parsed = ManifestSchema.safeParse({ schema: 1, repos });
45
+ if (!parsed.success) throw invalid("workspace repositories require safe aliases and nonempty paths");
46
+ return parsed.data.repos;
47
+ }
48
+ return readVersioned(join(root, "catherd.workspace.json"), ManifestSchema, 1).repos;
49
+ }
50
+
51
+ function workspaceRoot(root: string): string {
52
+ try {
53
+ const real = realpathSync(root);
54
+ if (!statSync(real).isDirectory()) throw new Error("not a directory");
55
+ return real;
56
+ } catch {
57
+ throw new CatherdError("E_IO_PATH", `workspace root is not an existing directory: ${root}`);
58
+ }
59
+ }
60
+
61
+ /** No repository discovery scan: paths are an explicit registry, relative to the workspace root. */
62
+ export async function inspectWorkspace(
63
+ root: string,
64
+ repos?: Record<string, string>,
65
+ ): Promise<{ root: string; repos: Record<string, string> }> {
66
+ root = workspaceRoot(root);
67
+ const declared = registry(root, repos);
68
+ if (!Object.keys(declared).length) throw invalid("workspace requires at least one repository");
69
+ const canonical: Record<string, string> = {};
70
+ for (const [alias, path] of Object.entries(declared)) {
71
+ const top = await gitToplevel(resolve(root, path));
72
+ if (!top)
73
+ throw new CatherdError(
74
+ "E_IO_PATH",
75
+ `workspace repository ${alias} is not inside a git repository: ${path}`,
76
+ );
77
+ const real = realpathSync(top);
78
+ if (Object.values(canonical).includes(real))
79
+ throw invalid(`workspace repository ${alias} duplicates a canonical git root`);
80
+ canonical[alias] = real;
81
+ }
82
+ return { root, repos: canonical };
83
+ }
84
+
85
+ export async function startWorkspace(
86
+ deps: Deps,
87
+ input: {
88
+ root: string;
89
+ title: string;
90
+ aLines: string[];
91
+ repos?: Record<string, string>;
92
+ steps: Array<{
93
+ id: string;
94
+ repo: string;
95
+ title: string;
96
+ aLines: string[];
97
+ dependsOn?: string[];
98
+ milestone?: string;
99
+ release?: "land" | "merge";
100
+ base?: string;
101
+ }>;
102
+ budget?: Budget;
103
+ },
104
+ ): Promise<{ workspace: Workspace; dir: string }> {
105
+ const root = workspaceRoot(input.root);
106
+ const declared = registry(root, input.repos);
107
+ // Only selected repositories are captured. An unused unavailable checkout cannot block this task.
108
+ const selected: Record<string, string> = {};
109
+ for (const step of input.steps) {
110
+ if (!Object.hasOwn(declared, step.repo)) throw invalid(`unknown workspace repository ${step.repo}`);
111
+ selected[step.repo] = declared[step.repo]!;
112
+ }
113
+ const snapshot = WorkspaceSchema.safeParse({
114
+ schema: 1,
115
+ id: "validation",
116
+ root,
117
+ title: input.title,
118
+ aLines: input.aLines,
119
+ createdAt: new Date(deps.now()).toISOString(),
120
+ repos: selected,
121
+ steps: input.steps,
122
+ // #43 finding 4: no cap unless the owner sets one; each child keeps its own profile budget
123
+ budget: input.budget ?? {},
124
+ });
125
+ if (!snapshot.success)
126
+ throw invalid(
127
+ `workspace requires unique step ids, ordered repository reuse, valid budget, and an acyclic dependency graph: ${snapshot.error.issues[0]?.message ?? "invalid"}`,
128
+ );
129
+ const canonical = await inspectWorkspace(root, selected);
130
+ const workspace = createWorkspace({ ...snapshot.data, repos: canonical.repos }, new Date(deps.now()));
131
+ return { workspace, dir: workspaceDirectory(workspace.id) };
132
+ }
133
+
134
+ /** Whether a child still has dispatches nobody collected; null when its records cannot be read. */
135
+ function ownPending(run: Run): boolean | null {
136
+ const { records, corrupt } = readRecords(run);
137
+ if (corrupt) return null;
138
+ const recorded = new Set(records.map((r) => r.dispatchId));
139
+ try {
140
+ return listDispatches(run, true).some((d) => !recorded.has(d.admit.dispatchId));
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ export async function startWorkspaceChild(
147
+ deps: Deps,
148
+ input: { workspace: string; step: string },
149
+ ): Promise<{ run: string; dir: string; contract: string | null }> {
150
+ const workspace = findWorkspace(input.workspace);
151
+ const paths = workspacePaths(workspaceDirectory(workspace.id));
152
+ const step = workspace.steps.find((s) => s.id === input.step);
153
+ if (!step) throw invalid(`unknown workspace step ${input.step}`);
154
+ // Recovery must validate the checkout too; stored status remains readable without it. The git work runs
155
+ // outside the workspace lock (#43 finding 5).
156
+ const repo = workspace.repos[step.repo]!;
157
+ if ((await gitToplevel(repo)) !== repo)
158
+ throw new CatherdError(
159
+ "E_IO_PATH",
160
+ `workspace repository ${step.repo} is no longer the captured git root`,
161
+ );
162
+ const run = await withFileLock(
163
+ paths.admission,
164
+ () => childFor(deps, workspace, step, repo, paths.contract),
165
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
166
+ );
167
+ const contract = existsSync(paths.contract) ? join(run.dir, "workspace-contract.md") : null;
168
+ await claimRun(deps, run);
169
+ await refreshState(run);
170
+ return { run: run.id, dir: run.dir, contract };
171
+ }
172
+
173
+ /** Under the workspace lock: the step's child, created once its dependencies release it. */
174
+ async function childFor(
175
+ deps: Deps,
176
+ workspace: Workspace,
177
+ step: Workspace["steps"][number],
178
+ repo: string,
179
+ contractFile: string,
180
+ ): Promise<Run> {
181
+ const children = workspaceChildren(workspace);
182
+ let run = children.find((r) => r.meta.workspace?.step === step.id);
183
+ if (!run) {
184
+ assertNotPaused({ meta: { workspace: { id: workspace.id, step: step.id } } });
185
+ const blockers = await dependencyBlockers(workspace, step, children, deps.now(), { merge: true });
186
+ if (blockers.length)
187
+ throw invalid(`workspace step ${step.id} waits: ${blockers.map((b) => b.why).join("; ")}`);
188
+ await assertWorkspaceBudget(workspace, deps.now(), children);
189
+ run = createRun({
190
+ repo,
191
+ title: step.title,
192
+ aLines: step.aLines,
193
+ version: deps.version,
194
+ now: new Date(deps.now()),
195
+ startedBy: currentSession(deps),
196
+ workspace: { id: workspace.id, step: step.id },
197
+ });
198
+ writePin(deps, run);
199
+ // pinned like startRun does, so a pair the machine index drops later never breaks the child midway
200
+ writeRunIndex(run.dir, readIndexFile(indexPath()));
201
+ }
202
+ const contract = existsSync(contractFile) ? join(run.dir, "workspace-contract.md") : null;
203
+ if (contract && !existsSync(contract)) writeTextAtomic(contract, readFileSync(contractFile, "utf8"));
204
+ return run;
205
+ }
206
+
207
+ export async function workspaceStatus(deps: Deps, id: string) {
208
+ const workspace = findWorkspace(id);
209
+ const { children, unreadable } = workspaceListing(workspace);
210
+ // #43 finding 2: a corrupt child is a warning here, never a failed status
211
+ const warnings = unreadable.map(unreadableWarning);
212
+ const steps = [];
213
+ for (const step of workspace.steps) {
214
+ const child = children.find((r) => r.meta.workspace?.step === step.id);
215
+ const summary = child ? summarizeRun(deps, child) : null;
216
+ const landed = child ? landedMilestones(child) : [];
217
+ const pending = child ? ownPending(child) : false;
218
+ if (child && pending === null)
219
+ warnings.push(`${step.id}: run ${child.id} has unreadable dispatch records`);
220
+ // #43 finding 7: a milestone that differs only by case never releases the dependents
221
+ const twin = landed.find((m) => m !== step.milestone && m.toLowerCase() === step.milestone.toLowerCase());
222
+ if (twin && !landed.includes(step.milestone))
223
+ warnings.push(`${step.id} completes on ${step.milestone}, but its run landed ${twin}`);
224
+ const blockers = await dependencyBlockers(workspace, step, children, deps.now(), { merge: true });
225
+ const state: "waiting" | "ready" | "active" | "landed" = child
226
+ ? landed.includes(step.milestone) && pending === false
227
+ ? "landed"
228
+ : "active"
229
+ : blockers.length
230
+ ? "waiting"
231
+ : "ready";
232
+ steps.push({
233
+ id: step.id,
234
+ repo: step.repo,
235
+ state,
236
+ run: child?.id ?? null,
237
+ blockedBy: blockers.map((b) => b.step),
238
+ waitingFor: blockers.map((b) => b.why),
239
+ landedMilestones: landed,
240
+ landedCommits: child ? landedCommits(child) : [],
241
+ failures: summary?.totals.notOk ?? [],
242
+ questions: summary?.questions ?? [],
243
+ live: summary?.live ?? [],
244
+ childBudget: summary?.budget ?? null,
245
+ warnings: summary?.warnings ?? [],
246
+ });
247
+ }
248
+ const spend = await workspaceSpend(workspace, deps.now(), children, warnings);
249
+ return {
250
+ // spec 1.5 "Group pause": a pause over the workspace comes first
251
+ paused: pausesOver([{ meta: { workspace: { id, step: workspace.steps[0]!.id } } }]),
252
+ workspace,
253
+ dir: workspaceDirectory(id),
254
+ steps,
255
+ spend,
256
+ budget: budgetStatus(spend, workspace.budget),
257
+ warnings,
258
+ };
259
+ }