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,382 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { z } from "zod";
13
+ import { CatherdError } from "../domain/errors.ts";
14
+ import { latestOutcomes } from "../domain/route.ts";
15
+ import {
16
+ assertLabel,
17
+ SCORECARD_TASKS,
18
+ assertScorecardRows,
19
+ type ScorecardFile,
20
+ ScorecardFileSchema,
21
+ type ScorecardTask,
22
+ ScorecardTaskSchema,
23
+ type ScorecardTaskId,
24
+ type TaskScore,
25
+ } from "../domain/scorecard.ts";
26
+ import { assetPath } from "../infra/assets.ts";
27
+ import { git, gitHead } from "../infra/git.ts";
28
+ import { dataDir } from "../infra/paths.ts";
29
+ import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
30
+ import { landedMilestones, reviewRounds } from "./milestones.ts";
31
+ import { checkEnvFor, runCheck } from "./preflight.ts";
32
+ import { runMilestones } from "./protocol.ts";
33
+ import { openQuestions } from "./questions.ts";
34
+ import {
35
+ findRun,
36
+ listRuns,
37
+ readAgentRuns,
38
+ readOutcomes,
39
+ readRecords,
40
+ type Run,
41
+ supersededBy,
42
+ } from "./run-store.ts";
43
+ import { failoverRecovered, interventionsOf, pausedMs, runEnd } from "./scorecard-metrics.ts";
44
+ import { quotaByBackend } from "./summary.ts";
45
+
46
+ // Plan 28: prepare a scorecard task's reproducible starting point, score the run that ended there, store the row.
47
+
48
+ /** The checkout's `scorecard/` folder, or null for an installed package (it ships no fixtures). */
49
+ export const scorecardRoot = (): string | null => {
50
+ const root = assetPath("scorecard");
51
+ return existsSync(root) ? root : null;
52
+ };
53
+
54
+ export const defaultPreparedRoot = (): string => join(dataDir(), "scorecard");
55
+
56
+ const minutes = (ms: number): number => Math.round(ms / 6000) / 10;
57
+
58
+ export function loadTask(id: string, root: string): ScorecardTask {
59
+ if (!(SCORECARD_TASKS as readonly string[]).includes(id))
60
+ throw new CatherdError("E_INPUT_INVALID", `no scorecard task ${id}`, {
61
+ fix: `catherd scorecard prepare ${SCORECARD_TASKS.join("|")}`,
62
+ });
63
+ const file = join(root, "tasks", id, "task.json");
64
+ const task = readVersioned(file, ScorecardTaskSchema, 1);
65
+ if (task.id !== id)
66
+ throw new CatherdError("E_CONFIG_INVALID", `${file} names task ${task.id}, not ${id}`, {
67
+ fix: `set "id" to ${id} in ${file}`,
68
+ });
69
+ return task;
70
+ }
71
+
72
+ const PreparedSchema = z.looseObject({
73
+ schema: z.literal(1),
74
+ task: z.enum(SCORECARD_TASKS),
75
+ dir: z.string(),
76
+ repo: z.string(),
77
+ plan: z.string(),
78
+ startCommit: z.string(),
79
+ limits: z.string().nullable(),
80
+ shim: z.string().nullable(),
81
+ preparedAt: z.string(),
82
+ prompt: z.string(),
83
+ });
84
+ export type Prepared = z.infer<typeof PreparedSchema> & { task: ScorecardTaskId };
85
+
86
+ /** A URL (`scheme://`) or an scp-like `user@host:path`: anything else is a local path. */
87
+ const REMOTE = /^(?:[a-z][a-z0-9+.-]*:\/\/|[^/\s]+@[^/\s:]+:)/i;
88
+
89
+ const GIT_LONG = 30 * 60_000;
90
+
91
+ /** A git command that must succeed: its failure is `fail`'s error. */
92
+ async function must(
93
+ repo: string,
94
+ args: string[],
95
+ fail: () => CatherdError,
96
+ timeoutMs?: number,
97
+ ): Promise<string> {
98
+ const r = await git(repo, args, timeoutMs);
99
+ if (r.kind !== "ok") throw fail();
100
+ return r.out;
101
+ }
102
+
103
+ export async function prepareTask(i: {
104
+ task: ScorecardTask;
105
+ root: string;
106
+ into: string;
107
+ source?: string;
108
+ now: Date;
109
+ setup: boolean;
110
+ }): Promise<Prepared> {
111
+ const { task } = i;
112
+ const given = i.source ?? task.source;
113
+ if (!given)
114
+ throw new CatherdError("E_INPUT_INVALID", `${task.id} names no source`, {
115
+ fix: `catherd scorecard prepare ${task.id} --source <path or URL>`,
116
+ });
117
+ // the clone runs inside the new folder, so a local path must be absolute before it gets there
118
+ const src = REMOTE.test(given) ? given : resolve(given);
119
+ ensurePrivateDir(dirname(i.into));
120
+ try {
121
+ mkdirSync(i.into);
122
+ } catch (e) {
123
+ if ((e as NodeJS.ErrnoException).code === "EEXIST")
124
+ throw new CatherdError("E_INPUT_INVALID", `${i.into} already exists`, {
125
+ fix: "prepare again: the folder name carries the time",
126
+ });
127
+ throw e;
128
+ }
129
+ // the run's own repo toplevel is the real path; findRun and listRuns key runs by it
130
+ const into = realpathSync(i.into);
131
+ const repo = join(into, "repo");
132
+ const io = (what: string) => () =>
133
+ new CatherdError("E_IO_UNEXPECTED", `${what} failed in ${into}`, {
134
+ fix: `check that git works in ${into}`,
135
+ });
136
+ await must(
137
+ into,
138
+ ["clone", "--quiet", "--no-checkout", src, "repo"],
139
+ () =>
140
+ new CatherdError("E_INPUT_INVALID", `cannot clone ${src}`, {
141
+ fix: `catherd scorecard prepare ${task.id} --source <path or URL>`,
142
+ }),
143
+ GIT_LONG,
144
+ );
145
+ await must(
146
+ repo,
147
+ ["checkout", "--quiet", "-b", `scorecard/${task.id}`, task.commit],
148
+ () =>
149
+ new CatherdError("E_INPUT_INVALID", `${src} has no commit ${task.commit}`, {
150
+ fix: `catherd scorecard prepare ${task.id} --source <a clone that has it>`,
151
+ }),
152
+ );
153
+ await must(repo, ["remote", "remove", "origin"], io("git remote remove"));
154
+
155
+ if (task.inject) {
156
+ for (const e of task.inject.edits) {
157
+ const file = join(repo, e.path);
158
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
159
+ const parts = text.split(e.find);
160
+ if (parts.length !== 2)
161
+ throw new CatherdError(
162
+ "E_CONFIG_INVALID",
163
+ `${e.path}: the inject text is there ${parts.length - 1} times, not once`,
164
+ {
165
+ fix: `update scorecard/tasks/${task.id}/task.json`,
166
+ },
167
+ );
168
+ writeFileSync(file, parts.join(e.replace));
169
+ }
170
+ await must(repo, ["add", "-A"], io("git add"));
171
+ await must(
172
+ repo,
173
+ [
174
+ "-c",
175
+ "user.name=catherd-scorecard",
176
+ "-c",
177
+ "user.email=scorecard@catherd.invalid",
178
+ "commit",
179
+ "--quiet",
180
+ "-m",
181
+ task.inject.message,
182
+ ],
183
+ io("git commit"),
184
+ );
185
+ }
186
+
187
+ let plan: string;
188
+ if ("file" in task.plan) {
189
+ plan = join(into, "plan.md");
190
+ copyFileSync(join(i.root, "tasks", task.id, task.plan.file), plan);
191
+ } else {
192
+ plan = join(repo, task.plan.repoPath);
193
+ if (!existsSync(plan))
194
+ throw new CatherdError("E_CONFIG_INVALID", `${task.plan.repoPath} is not in ${src} at ${task.commit}`, {
195
+ fix: `update scorecard/tasks/${task.id}/task.json`,
196
+ });
197
+ }
198
+
199
+ let limits: string | null = null;
200
+ if (task.limits > 0) {
201
+ limits = join(into, "limits");
202
+ mkdirSync(join(limits, "taken"), { recursive: true });
203
+ mkdirSync(join(limits, "tokens"));
204
+ for (let n = 1; n <= task.limits; n++)
205
+ writeFileSync(join(limits, "tokens", String(n)), "", { flag: "w" });
206
+ }
207
+
208
+ if (i.setup) {
209
+ const env = await checkEnvFor(repo);
210
+ for (const cmd of task.setup) {
211
+ const r = await runCheck(repo, cmd, GIT_LONG, env);
212
+ if (r.code !== 0 || r.timedOut)
213
+ throw new CatherdError(
214
+ "E_IO_UNEXPECTED",
215
+ `setup \`${cmd}\` failed in ${repo}: ${r.tail.at(-1) ?? "no output"}`,
216
+ {
217
+ fix: `fix it and run it in ${repo}, or prepare again`,
218
+ },
219
+ );
220
+ }
221
+ }
222
+
223
+ const startCommit = (await must(repo, ["rev-parse", "HEAD"], io("git rev-parse"))).trim();
224
+ const prepared: Prepared = {
225
+ schema: 1,
226
+ task: task.id,
227
+ dir: into,
228
+ repo,
229
+ plan,
230
+ startCommit,
231
+ limits,
232
+ shim: limits ? join(i.root, "shim") : null,
233
+ preparedAt: i.now.toISOString(),
234
+ prompt: task.prompt.replaceAll("{plan}", () => plan).replaceAll("{acceptance}", () => task.acceptance),
235
+ };
236
+ writeJsonAtomic(join(into, "prepared.json"), prepared);
237
+ return prepared;
238
+ }
239
+
240
+ export function readPrepared(dir: string): Prepared {
241
+ const file = join(dir, "prepared.json");
242
+ if (!existsSync(file))
243
+ throw new CatherdError("E_INPUT_INVALID", `${dir} is not a prepared scorecard task`, {
244
+ fix: `catherd scorecard prepare ${SCORECARD_TASKS.join("|")}`,
245
+ });
246
+ return readVersioned(file, PreparedSchema, 1) as Prepared;
247
+ }
248
+
249
+ /** The one run to score: the named one, else the only open run created in the prepared repo since it was prepared. */
250
+ function pickRun(p: Prepared, id: string | undefined): Run {
251
+ if (id) {
252
+ const run = findRun(id);
253
+ if (run.meta.repo !== p.repo)
254
+ throw new CatherdError("E_INPUT_INVALID", `run ${id} belongs to ${run.meta.repo}, not ${p.repo}`, {
255
+ fix: "name a run of the prepared repo, or leave --run out",
256
+ });
257
+ return run;
258
+ }
259
+ const since = Date.parse(p.preparedAt);
260
+ const runs = listRuns([p.repo]).runs.filter(
261
+ (r) => Date.parse(r.meta.createdAt) >= since && !supersededBy(r),
262
+ );
263
+ if (runs.length === 0)
264
+ throw new CatherdError("E_INPUT_INVALID", `no run in ${p.repo} yet`, {
265
+ fix: "run the task's start line, paste its prompt, and record once it has landed or been given up",
266
+ });
267
+ if (runs.length > 1)
268
+ throw new CatherdError("E_INPUT_INVALID", `${p.repo} has runs ${runs.map((r) => r.id).join(", ")}`, {
269
+ fix: `catherd scorecard record ${p.dir} --label <l> --run <id>`,
270
+ });
271
+ return runs[0] as Run;
272
+ }
273
+
274
+ export async function scoreTask(i: {
275
+ dir: string;
276
+ root: string;
277
+ run?: string;
278
+ chat?: number;
279
+ now: number;
280
+ }): Promise<TaskScore> {
281
+ const p = readPrepared(i.dir);
282
+ const task = loadTask(p.task, i.root);
283
+ const run = pickRun(p, i.run);
284
+ const milestones = runMilestones(run);
285
+ const landed = landedMilestones(run);
286
+ const finished =
287
+ milestones.length > 0 &&
288
+ milestones.every((m) => landed.includes(m)) &&
289
+ openQuestions(run).length === 0 &&
290
+ !supersededBy(run);
291
+ const end = runEnd(run);
292
+ const start = Date.parse(run.meta.createdAt);
293
+
294
+ const copies = task.acceptFiles.map((f) => ({
295
+ from: join(i.root, "tasks", task.id, f.from),
296
+ to: join(p.repo, f.to),
297
+ }));
298
+ for (const c of copies)
299
+ if (existsSync(c.to))
300
+ throw new CatherdError(
301
+ "E_INPUT_INVALID",
302
+ `${c.to.slice(p.repo.length + 1)} already exists in the run's tree`,
303
+ {
304
+ fix: "the run wrote a file the hidden acceptance test needs; rename the file in the run, or score it by hand",
305
+ },
306
+ );
307
+ const done: string[] = [];
308
+ let check: Awaited<ReturnType<typeof runCheck>>;
309
+ const head = await gitHead(p.repo);
310
+ try {
311
+ for (const c of copies) {
312
+ mkdirSync(dirname(c.to), { recursive: true });
313
+ copyFileSync(c.from, c.to);
314
+ done.push(c.to);
315
+ }
316
+ check = await runCheck(
317
+ p.repo,
318
+ task.acceptance,
319
+ task.acceptanceTimeoutMin * 60_000,
320
+ await checkEnvFor(p.repo),
321
+ );
322
+ } finally {
323
+ for (const f of done) rmSync(f, { force: true });
324
+ }
325
+ const acceptance = {
326
+ passed: check.code === 0 && !check.timedOut,
327
+ code: check.code,
328
+ timedOut: check.timedOut,
329
+ tail: check.tail,
330
+ };
331
+
332
+ const outcomes = latestOutcomes(readOutcomes(run));
333
+ const takenDir = p.limits ? join(p.limits, "taken") : null;
334
+ return {
335
+ task: task.id,
336
+ run: run.id,
337
+ catherdVersion: run.meta.catherdVersion,
338
+ startCommit: p.startCommit,
339
+ head,
340
+ milestones,
341
+ landed,
342
+ finished,
343
+ interventions: interventionsOf(run, end, i.chat ?? 0),
344
+ reviewRounds: milestones.reduce((n, m) => n + reviewRounds(run, m), 0),
345
+ lanes: {
346
+ total: outcomes.length,
347
+ landed: outcomes.filter((o) => o.landed).length,
348
+ climbs: outcomes.reduce((n, o) => n + o.climbs.length, 0),
349
+ },
350
+ wallMinutes: minutes(end - start),
351
+ pausedMinutes: minutes(pausedMs(run, end)),
352
+ quota: {
353
+ backends: quotaByBackend(readRecords(run).records),
354
+ claudeAgentTokens: readAgentRuns(run).reduce((n, a) => n + a.totalTokens, 0),
355
+ },
356
+ acceptance,
357
+ firstTry: acceptance.passed && finished && new Set(landed).size === landed.length,
358
+ injected:
359
+ task.limits > 0
360
+ ? {
361
+ limits: takenDir && existsSync(takenDir) ? readdirSync(takenDir).length : 0,
362
+ standInOk: failoverRecovered(run),
363
+ }
364
+ : null,
365
+ recordedAt: new Date(i.now).toISOString(),
366
+ };
367
+ }
368
+
369
+ export const resultsFile = (dir: string, label: string): string => join(dir, `${assertLabel(label)}.json`);
370
+
371
+ export function readScorecard(file: string): ScorecardFile | null {
372
+ return existsSync(file) ? assertScorecardRows(readVersioned(file, ScorecardFileSchema, 1), file) : null;
373
+ }
374
+
375
+ /** Sets the task's row in the label's file (a re-record replaces it) and returns the file's contents. */
376
+ export function saveTaskScore(file: string, label: string, s: TaskScore): ScorecardFile {
377
+ const f = readScorecard(file) ?? { schema: 1 as const, label, tasks: {} };
378
+ f.tasks[s.task] = s;
379
+ ensurePrivateDir(dirname(file));
380
+ writeJsonAtomic(file, f);
381
+ return f;
382
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tryLock } from "../infra/filelock.ts";
4
+ import { liveDispatches } from "./dispatches.ts";
5
+ import { findRun, listRuns, type Run, runPaths } from "./run-store.ts";
6
+
7
+ export interface ScratchCleaned {
8
+ /** the runs whose scratch folder was removed, with what it held */
9
+ removed: { run: string; bytes: number }[];
10
+ /** the runs left alone, and why: a role still writes there */
11
+ kept: { run: string; why: string }[];
12
+ }
13
+
14
+ /** The bytes under `dir`, links not followed; 0 for what cannot be read. */
15
+ function sizeOf(dir: string): number {
16
+ let total = 0;
17
+ try {
18
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
19
+ const p = join(dir, e.name);
20
+ total += e.isDirectory() ? sizeOf(p) : (lstatSync(p, { throwIfNoEntry: false })?.size ?? 0);
21
+ }
22
+ } catch {
23
+ // gone or unreadable: counts as nothing
24
+ }
25
+ return total;
26
+ }
27
+
28
+ /**
29
+ * Spec 1.5 plan 21: `catherd runs clean [run]` removes the roles' scratch folders (`<run>/scratch/`, each role's
30
+ * TMPDIR) of a run with no live role, or of every such run. The run's own files, records and replies stay. A run
31
+ * whose admission is in progress is kept: that admission may have just created a role's scratch.
32
+ */
33
+ export function cleanScratch(o: { run?: string; now?: number } = {}): ScratchCleaned {
34
+ const runs: Run[] = o.run ? [findRun(o.run)] : listRuns().runs;
35
+ const out: ScratchCleaned = { removed: [], kept: [] };
36
+ for (const run of runs) {
37
+ const dir = runPaths(run.dir).scratch;
38
+ if (!existsSync(dir)) continue;
39
+ // admission creates a role's scratch and admits its dispatch under this lock, so while cleanup holds it no
40
+ // scratch is half-admitted, and one an admission holds now belongs to a dispatch about to go live
41
+ const release = tryLock(runPaths(run.dir).admission);
42
+ if (!release) {
43
+ out.kept.push({ run: run.id, why: "a dispatch is being admitted" });
44
+ continue;
45
+ }
46
+ try {
47
+ const live = liveDispatches(run, o.now ?? Date.now());
48
+ if (live.length) {
49
+ out.kept.push({ run: run.id, why: `${live.map((d) => d.admit.name).join(", ")} still running` });
50
+ continue;
51
+ }
52
+ const bytes = sizeOf(dir);
53
+ rmSync(dir, { recursive: true, force: true });
54
+ out.removed.push({ run: run.id, bytes });
55
+ } finally {
56
+ release();
57
+ }
58
+ }
59
+ return out;
60
+ }
@@ -69,9 +69,11 @@ export function readSessionRows(run: Run): SessionRow[] {
69
69
 
70
70
  /**
71
71
  * Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
72
- * append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing. Returns whether the owner changed.
72
+ * append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing, and neither does a role's process (`deps.role`, spec 1.5 plan 21). Returns whether the owner changed.
73
73
  */
74
74
  export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
75
+ // spec 1.5 plan 21: a role's process never becomes the run's owner, whatever host identity it has
76
+ if (deps.role) return false;
75
77
  const me = currentSession(deps);
76
78
  if (!me) return false;
77
79
  const p = runPaths(run.dir);
@@ -19,6 +19,8 @@ const NotesSchema = z.looseObject({
19
19
  lastLandedAt: z.string().nullable(),
20
20
  /** spec 1.1 §8: the milestones waiting on the owner */
21
21
  parked: z.array(z.string()).optional(),
22
+ /** spec 1.5: what changed since the run's pin, as dispatch last saw it */
23
+ pinChanges: z.array(z.string()).optional(),
22
24
  });
23
25
  export type Notes = z.infer<typeof NotesSchema>;
24
26
  export type NotesPatch = Partial<Omit<Notes, "schema">>;
@@ -68,6 +70,7 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
68
70
  brief: relative(run.dir, dispatchPaths(d.dir).brief),
69
71
  })),
70
72
  lastCheck: next.lastCheck,
73
+ pinChanges: next.pinChanges ?? [],
71
74
  next: next.next,
72
75
  protocol: protocolNext(run, next.parked ?? []),
73
76
  });
@@ -76,6 +79,24 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
76
79
  });
77
80
  }
78
81
 
82
+ /**
83
+ * Writes state.json's notes only, with no git, under the state lock; for a writer that must not wait on git
84
+ * while it holds another lock (`land` in a workspace, #43 finding 5). It waits out a refresh's git (15 s).
85
+ */
86
+ export function patchNotes(run: Run, change: (n: Notes) => NotesPatch): Promise<Notes> {
87
+ const stateJson = runPaths(run.dir).stateJson;
88
+ return withFileLock(
89
+ stateJson,
90
+ () => {
91
+ const notes = readNotes(run);
92
+ const next = parkedNext({ ...notes, ...change(notes) });
93
+ writeJsonAtomic(stateJson, next);
94
+ return next;
95
+ },
96
+ { timeoutMs: 30_000 },
97
+ );
98
+ }
99
+
79
100
  /**
80
101
  * Ruling (b): a failed state.md refresh (git broken) never fails the call. The notes still go to state.json
81
102
  * under the state lock (state.md stays as it was), and the message comes back as a hint.
@@ -3,16 +3,22 @@ import type { QueueCapability } from "../infra/codex-queue.ts";
3
3
  import { inspectionHost, inspectDeliveries, type DeliveryInspection } from "./run-debug.ts";
4
4
  import { resolve } from "node:path";
5
5
  import { type BudgetStatus, budgetStatus } from "../domain/budget.ts";
6
- import { isCatherdError } from "../domain/errors.ts";
7
- import type { Tokens } from "../domain/record.ts";
6
+ import { errorMessage, isCatherdError } from "../domain/errors.ts";
7
+ import type { RunRecord, Tokens } from "../domain/record.ts";
8
+ import type { QuotaRow } from "../domain/scorecard.ts";
8
9
  import { median } from "../domain/util.ts";
9
10
  import { nonBlankLines, readJsonl } from "../infra/store.ts";
10
11
  import { spendOf } from "./budget.ts";
11
- import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
12
+ import { ensureDaemon } from "./daemon.ts";
13
+ import { verifierStepView, type VerifierStepView } from "./verifier-step.ts";
14
+ import { type Pause, pausesOver } from "./pause.ts";
15
+ import { pinChanges } from "./run-pin.ts";
12
16
  import { type OpenQuestion, openQuestions } from "./questions.ts";
13
17
  import { type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
18
+ import { readDelivery, ROLE_THREAD_REFUSAL } from "../infra/delivery.ts";
14
19
  import { type RunSession, sessionFacts } from "./session-view.ts";
15
20
  import type { Deps } from "./ports.ts";
21
+ import { orchestratorWait, type OrchestratorWait } from "./orchestrator-wait.ts";
16
22
  import {
17
23
  findRun,
18
24
  listRuns,
@@ -21,11 +27,18 @@ import {
21
27
  readRoutes,
22
28
  type Run,
23
29
  runPaths,
30
+ supersededBy,
24
31
  } from "./run-store.ts";
25
32
 
26
33
  export interface RunSummary {
34
+ /** No live roles and at least one durable completion the orchestrator has not collected. */
35
+ waiting?: OrchestratorWait | null;
27
36
  delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
28
37
  id: string;
38
+ /** spec 1.5 "runs supersede": the run that took this one over; status() without a run hides it */
39
+ supersededBy?: string | null;
40
+ /** spec 1.5: what changed since the run's pin; dispatch keeps the pinned values */
41
+ pinChanges?: string[];
29
42
  /** spec 1.1 §8: the owner questions not answered yet, listed first */
30
43
  questions: OpenQuestion[];
31
44
  title: string;
@@ -45,14 +58,21 @@ export interface RunSummary {
45
58
  harness: { backend: string; native: number; isolated: number }[];
46
59
  budget: BudgetStatus | null;
47
60
  milestones: string[];
48
- /** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is */
49
- verifier: VerifierStep | null;
61
+ /** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is; plan 22: its age, and what closed it */
62
+ verifier: VerifierStepView | null;
50
63
  warnings: string[];
51
64
  }
52
65
 
53
66
  /** One run at a glance. Spec §3.4: reads are pure; nothing here finalizes a dispatch or writes a file. */
54
67
  export function summarizeRun(deps: Deps, run: Run): RunSummary {
55
68
  const now = deps.now();
69
+ const warnings: string[] = [];
70
+ // an unfinished 2.0 run whose daemon died gets it back from any reader: status, watch, the panel
71
+ try {
72
+ ensureDaemon(run);
73
+ } catch (e) {
74
+ warnings.push(`daemon: ${errorMessage(e)}`);
75
+ }
56
76
  const { records, corrupt } = readRecords(run);
57
77
  const live = liveDispatches(run, now);
58
78
  const recorded = new Set(records.map((r) => r.dispatchId));
@@ -60,7 +80,10 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
60
80
  const jev = readJsonl<{ source?: string }>(runPaths(run.dir).jev).rows;
61
81
  const sum = (f: (t: Tokens) => number) => records.reduce((n, r) => n + f(r.tokens), 0);
62
82
  let budget: BudgetStatus | null = null;
63
- const warnings = corrupt ? [`runs.jsonl: skipped ${corrupt} unreadable row(s)`] : [];
83
+ if (corrupt) warnings.push(`runs.jsonl: skipped ${corrupt} unreadable row(s)`);
84
+ // spec 1.5 "Pinned per run": a change since the pin, which dispatch does not follow
85
+ const pinned = pinChanges(deps, run);
86
+ for (const c of pinned) warnings.push(`pinned: ${c}; dispatch keeps the pinned value (run_pin re-pins)`);
64
87
  try {
65
88
  budget = budgetStatus(
66
89
  spendOf(run, records, live, now),
@@ -69,7 +92,18 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
69
92
  } catch (e) {
70
93
  warnings.push(`budget: ${isCatherdError(e) ? e.message : String(e)}`);
71
94
  }
95
+ // spec 1.5 plan 21: a notice refused because its target was a role's thread, while that is its latest attempt
96
+ for (const d of listDispatches(run)) {
97
+ try {
98
+ const last = readDelivery(d.dir).at(-1);
99
+ if (last?.status === "failed" && last.reason?.startsWith(ROLE_THREAD_REFUSAL))
100
+ warnings.push(`${d.admit.name}: its notice was not sent: ${last.reason}`);
101
+ } catch {
102
+ // unreadable delivery evidence is the delivery row's to show
103
+ }
104
+ }
72
105
  return {
106
+ waiting: orchestratorWait(run, now, records, live.length > 0),
73
107
  delivery: listDispatches(run).flatMap((d) =>
74
108
  inspectDeliveries(run, d, recorded.has(d.admit.dispatchId)).map((inspection) => ({
75
109
  name: d.admit.name,
@@ -78,6 +112,8 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
78
112
  })),
79
113
  ),
80
114
  id: run.id,
115
+ supersededBy: supersededBy(run)?.by ?? null,
116
+ pinChanges: pinned,
81
117
  questions: openQuestions(run),
82
118
  title: run.meta.title,
83
119
  repo: run.meta.repo,
@@ -116,40 +152,53 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
116
152
  }),
117
153
  budget,
118
154
  milestones: nonBlankLines(runPaths(run.dir).ledger).slice(1),
119
- verifier: latestVerifierStep(run),
155
+ verifier: verifierStepView(run, now),
120
156
  warnings,
121
157
  };
122
158
  }
123
159
 
124
- /** `status(run?)`: that run; without one, every run with a live role, else the newest run. */
160
+ /**
161
+ * `status(run?)`: that run; without one, the live runs, else the runs waiting for their orchestrator (a current
162
+ * owner, a recent unread record: plan 22), else the newest run.
163
+ */
125
164
  export function status(
126
165
  deps: Deps,
127
166
  runId?: string,
128
167
  queue: QueueCapability | null = null,
129
168
  ): {
169
+ /** spec 1.5 "Group pause": the machine's pause and the listed runs' workspace pauses, shown first */
170
+ paused: Pause[];
130
171
  version: string;
131
172
  runs: RunSummary[];
132
173
  warnings: string[];
133
174
  host: HostContext;
134
175
  queue: QueueCapability | null;
135
176
  } {
136
- if (runId)
177
+ if (runId) {
178
+ const run = findRun(runId);
137
179
  return {
180
+ paused: pausesOver([run]),
138
181
  host: inspectionHost(deps.host),
139
182
  queue,
140
183
  version: deps.version,
141
- runs: [summarizeRun(deps, findRun(runId))],
184
+ runs: [summarizeRun(deps, run)],
142
185
  warnings: [],
143
186
  };
144
- const { runs, corrupt } = listRuns();
187
+ }
188
+ const { runs: listed, corrupt } = listRuns();
145
189
  const warnings = corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`);
190
+ // a superseded run is closed: only status(run) shows it
191
+ const runs = listed.filter((r) => !supersededBy(r));
146
192
  const all = runs.map((r) => summarizeRun(deps, r));
147
193
  const live = all.filter((s) => s.live.length > 0);
194
+ const waiting = all.filter((s) => s.waiting);
195
+ const shown = live.length ? live : waiting.length ? waiting : all.slice(0, 1);
148
196
  return {
197
+ paused: pausesOver(runs.filter((r) => shown.some((s) => s.id === r.id))),
149
198
  host: inspectionHost(deps.host),
150
199
  queue,
151
200
  version: deps.version,
152
- runs: live.length ? live : all.slice(0, 1),
201
+ runs: shown,
153
202
  warnings,
154
203
  };
155
204
  }
@@ -210,6 +259,21 @@ function harnessCosts(runs: Run[]): HarnessCost[] {
210
259
  });
211
260
  }
212
261
 
262
+ /** Plan 28: tokens and usage limits per backend over `records`, sorted by backend. */
263
+ export function quotaByBackend(records: RunRecord[]): QuotaRow[] {
264
+ const by = new Map<string, QuotaRow>();
265
+ for (const r of records) {
266
+ const q = by.get(r.backend) ?? { backend: r.backend, runs: 0, input: 0, cached: 0, output: 0, limits: 0 };
267
+ q.runs++;
268
+ q.input += r.tokens.input;
269
+ q.cached += r.tokens.cached;
270
+ q.output += r.tokens.output;
271
+ if (r.status === "limit") q.limits++;
272
+ by.set(r.backend, q);
273
+ }
274
+ return [...by.values()].sort((a, b) => a.backend.localeCompare(b.backend));
275
+ }
276
+
213
277
  /** Per role and rung over past runs: runs, ok, refusals, climbs from it, seconds and tokens. Reads only. */
214
278
  export function runsSummary(f: { run?: string; repo?: string; role?: string; sinceDays?: number }): {
215
279
  rungs: RungStats[];