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
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CatherdError } from "../domain/errors.ts";
4
4
  import type { RunRecord } from "../domain/record.ts";
5
- import { git } from "../infra/git.ts";
5
+ import { commitExists, git } from "../infra/git.ts";
6
6
  import { nonBlankLines } from "../infra/store.ts";
7
7
  import { listDispatches } from "./dispatches.ts";
8
8
  import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
@@ -50,27 +50,99 @@ export interface MilestoneReviewer {
50
50
  record: RunRecord | null;
51
51
  }
52
52
 
53
- /**
54
- * The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
55
- * ok; or a record_agent_run row with role reviewer and the same name, status ok. Null when there is none.
56
- */
57
- export function milestoneReviewer(
53
+ /** Every reviewer attempt of the milestone since its lanes started that ended ok, oldest first, partial or not. */
54
+ function reviewerAttempts(
58
55
  run: Run,
59
56
  m: string,
60
- start = milestoneStart(run, m),
61
- ): MilestoneReviewer | null {
62
- const found: MilestoneReviewer[] = [
57
+ start: string | null,
58
+ ): (MilestoneReviewer & { partial: boolean })[] {
59
+ return [
63
60
  ...readRecords(run)
64
61
  .records.filter((r) => reviewsMilestone(r.name, m) && r.status === "ok" && since(r.endedAt, start))
65
- .map((r) => ({ name: r.name, at: r.endedAt, record: r })),
62
+ .map((r) => ({ name: r.name, at: r.endedAt, record: r, partial: r.replyStatus === "partial" })),
66
63
  ...readAgentRuns(run)
67
64
  .filter(
68
65
  (a) =>
69
66
  a.role === "reviewer" && reviewsMilestone(a.name, m) && a.status === "ok" && since(a.at, start),
70
67
  )
71
- .map((a) => ({ name: a.name, at: a.at, record: null })),
72
- ];
73
- return found.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).at(-1) ?? null;
68
+ .map((a) => ({ name: a.name, at: a.at, record: null, partial: a.replyStatus === "partial" })),
69
+ ].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
70
+ }
71
+
72
+ /**
73
+ * The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
74
+ * ok; or a record_agent_run row with role reviewer and the same name, status ok. A reply of STATUS: partial
75
+ * read only part of the diff and is not the milestone's review (plan 23). Null when there is none.
76
+ */
77
+ export function milestoneReviewer(
78
+ run: Run,
79
+ m: string,
80
+ start = milestoneStart(run, m),
81
+ ): MilestoneReviewer | null {
82
+ const full = reviewerAttempts(run, m, start).filter((a) => !a.partial);
83
+ const last = full.at(-1);
84
+ return last ? { name: last.name, at: last.at, record: last.record } : null;
85
+ }
86
+
87
+ /** Plan 23: the milestone's latest reviewer that stopped at STATUS: partial, when no full review stands; else null. */
88
+ export function partialReviewer(
89
+ run: Run,
90
+ m: string,
91
+ start = milestoneStart(run, m),
92
+ ): MilestoneReviewer | null {
93
+ if (milestoneReviewer(run, m, start)) return null;
94
+ const last = reviewerAttempts(run, m, start)
95
+ .filter((a) => a.partial)
96
+ .at(-1);
97
+ return last ? { name: last.name, at: last.at, record: last.record } : null;
98
+ }
99
+
100
+ /** Plan 28: the milestone's full reviewer passes since its lanes started (a partial pass is not a round). */
101
+ export const reviewRounds = (run: Run, m: string): number =>
102
+ reviewerAttempts(run, m, milestoneStart(run, m)).filter((a) => !a.partial).length;
103
+
104
+ export const FINDING = /^\s*(?:[-*]\s*)?(BLOCKER|BUG|NIT)\b/;
105
+
106
+ /** A reviewer reply's findings by severity; null when there is no reply to read. */
107
+ export function countFindings(run: Run, r: RunRecord): { BLOCKER: number; BUG: number; NIT: number } | null {
108
+ const file = join(run.dir, r.replyPath);
109
+ if (!r.replyPath || !existsSync(file)) return null;
110
+ const n = { BLOCKER: 0, BUG: 0, NIT: 0 };
111
+ for (const line of readFileSync(file, "utf8").split("\n")) {
112
+ const k = FINDING.exec(line)?.[1] as keyof typeof n | undefined;
113
+ if (k) n[k]++;
114
+ }
115
+ return n;
116
+ }
117
+
118
+ /** The roles a fix round runs as. */
119
+ const FIX_ROLES = new Set(["worker", "writer", "artist"]);
120
+
121
+ /**
122
+ * Plan 23: the milestone reviewer's BLOCKER and BUG lines while no fix round has ended since (a worker,
123
+ * writer or artist of the milestone, ended ok after the review); null when there are none, or for a native
124
+ * reviewer, whose reply catherd does not keep.
125
+ */
126
+ export function openFindings(
127
+ run: Run,
128
+ m: string,
129
+ start = milestoneStart(run, m),
130
+ ): { name: string; blocker: number; bug: number } | null {
131
+ const reviewer = milestoneReviewer(run, m, start);
132
+ if (!reviewer?.record) return null;
133
+ const n = countFindings(run, reviewer.record);
134
+ if (!n || n.BLOCKER + n.BUG === 0) return null;
135
+ const after = (at: string) => Date.parse(at) > Date.parse(reviewer.at);
136
+ const ofM = (name: string, lane: string | null | undefined) =>
137
+ inMilestone(lane, m) || namesMilestone(name, m);
138
+ const fixed =
139
+ readRecords(run).records.some(
140
+ (r) => FIX_ROLES.has(r.role) && r.status === "ok" && ofM(r.name, r.lane) && after(r.endedAt),
141
+ ) ||
142
+ readAgentRuns(run).some(
143
+ (a) => FIX_ROLES.has(a.role) && a.status === "ok" && ofM(a.name, a.lane) && after(a.at),
144
+ );
145
+ return fixed ? null : { name: reviewer.name, blocker: n.BLOCKER, bug: n.BUG };
74
146
  }
75
147
 
76
148
  /** A reviewer record for the milestone since its lanes started (see milestoneReviewer). */
@@ -80,6 +152,15 @@ export const reviewerPassed = (run: Run, m: string, start = milestoneStart(run,
80
152
  /** The verifier's contract puts `VERDICT: PASS` or `VERDICT: FAIL` on the reply's first line (role-prompts.ts). */
81
153
  const VERDICT_PASS = /^VERDICT: PASS\b/;
82
154
 
155
+ /**
156
+ * Plan 23: `VERDICT: BLOCKED: environment — <the probe that proves it>`, a verdict on the machine, not on the
157
+ * work. Its probe, or null when `line` is no such verdict ("" when it names none).
158
+ */
159
+ export function blockedByEnvironment(line: string | null | undefined): string | null {
160
+ const m = /^VERDICT:\s*BLOCKED:\s*environment\b\s*(?:—|–|-|:)?\s*(.*)$/i.exec(line?.trim() ?? "");
161
+ return m ? (m[1] ?? "").trim() : null;
162
+ }
163
+
83
164
  /** A headless verifier's reply: its first non-blank line, trimmed; null when there is no reply to read. */
84
165
  export function replyVerdict(run: Run, r: RunRecord): string | null {
85
166
  const file = join(run.dir, r.replyPath);
@@ -106,11 +187,19 @@ export interface MilestoneVerifier {
106
187
  passed: boolean;
107
188
  /** what the attempt said: a native row's status, or a headless reply's first non-blank line ("no reply") */
108
189
  verdict: string;
190
+ /** plan 23: the probe of a `VERDICT: BLOCKED: environment` attempt ("" when it named none); null otherwise */
191
+ blocked: string | null;
192
+ /** plan 30b: the commit the verdict says it checked (a headless reply's `HEAD:` line); null for a native row */
193
+ head: string | null;
109
194
  }
110
195
 
196
+ /** The one name a milestone's verifier is counted under (spec 1.5 "The ledger"): verifier-<m>, exactly. */
197
+ export const verifierName = (m: string): string => `verifier-${m}`;
198
+
111
199
  /**
112
200
  * The milestone's latest verifier attempt since its lanes started, whatever it said: a record_agent_run row
113
- * with role verifier whose name names the milestone (the skill records a FAIL as failed), passing when its
201
+ * with role verifier named exactly verifier-<m> (the skill records a FAIL as failed; verifier-M2-pre or
202
+ * verifier-M2-gate1 is not M2's verifier), passing when its
114
203
  * status is ok; or a headless verifier's dispatch record of the same shape, passing when it exited ok and its
115
204
  * reply opens VERDICT: PASS (status ok means only the CLI exited). A later FAIL undoes an earlier PASS. Null
116
205
  * when there is none.
@@ -122,16 +211,22 @@ export function milestoneVerifier(
122
211
  ): MilestoneVerifier | null {
123
212
  const found: MilestoneVerifier[] = [
124
213
  ...readAgentRuns(run)
125
- .filter((a) => a.role === "verifier" && namesMilestone(a.name, m) && since(a.at, start))
126
- .map((a) => ({
127
- name: a.name,
128
- at: a.at,
129
- headless: false,
130
- passed: a.status === "ok",
131
- verdict: a.status,
132
- })),
214
+ .filter((a) => a.role === "verifier" && a.name === verifierName(m) && since(a.at, start))
215
+ .map((a) => {
216
+ // plan 23: a native verifier's verdict line, when record_agent_run passed it
217
+ const blocked = a.status === "ok" ? null : blockedByEnvironment(a.verdict);
218
+ return {
219
+ name: a.name,
220
+ at: a.at,
221
+ headless: false,
222
+ passed: a.status === "ok",
223
+ verdict: blocked === null ? a.status : (a.verdict ?? a.status),
224
+ blocked,
225
+ head: null,
226
+ };
227
+ }),
133
228
  ...readRecords(run)
134
- .records.filter((r) => r.role === "verifier" && namesMilestone(r.name, m) && since(r.endedAt, start))
229
+ .records.filter((r) => r.role === "verifier" && r.name === verifierName(m) && since(r.endedAt, start))
135
230
  .map((r) => {
136
231
  const first = replyVerdict(run, r);
137
232
  const passed = r.status === "ok" && first !== null && VERDICT_PASS.test(first);
@@ -142,6 +237,8 @@ export function milestoneVerifier(
142
237
  headless: true,
143
238
  passed,
144
239
  verdict: r.status === "ok" ? said : `${r.status}, ${said}`,
240
+ blocked: r.status === "ok" ? blockedByEnvironment(first) : null,
241
+ head: r.head ?? null,
145
242
  };
146
243
  }),
147
244
  ];
@@ -161,6 +258,16 @@ export function landedCommits(run: Run): string[] {
161
258
  .filter((c) => /^[0-9a-f]{7,40}$/.test(c));
162
259
  }
163
260
 
261
+ /** The commit the ledger holds for `m`, its latest landing; null when it never landed or the row has no commit. */
262
+ export function landedCommitOf(run: Run, m: string): string | null {
263
+ const rows = nonBlankLines(runPaths(run.dir).ledger)
264
+ .slice(1)
265
+ .map((row) => row.split(" | "))
266
+ .filter((cells) => cells[0]?.trim() === m);
267
+ const commit = rows.at(-1)?.[2]?.trim() ?? "";
268
+ return /^[0-9a-f]{7,40}$/.test(commit) ? commit : null;
269
+ }
270
+
164
271
  /** The milestones the ledger holds, in landing order. */
165
272
  export function landedMilestones(run: Run): string[] {
166
273
  return nonBlankLines(runPaths(run.dir).ledger)
@@ -169,16 +276,29 @@ export function landedMilestones(run: Run): string[] {
169
276
  .filter(Boolean);
170
277
  }
171
278
 
279
+ /** The 1.5 range: see milestoneFiles. */
280
+ async function oneFiveFiles(run: Run, commit: string): ReturnType<typeof git> {
281
+ const repo = run.meta.repo;
282
+ const start = run.meta.startHead;
283
+ const base =
284
+ landedCommits(run).at(-1) ??
285
+ (start && (await commitExists(repo, start).catch(() => false)) ? start : undefined);
286
+ return base
287
+ ? await git(repo, ["diff", "--name-only", base, commit])
288
+ : await git(repo, ["show", "--name-only", "--format=", "--first-parent", commit]);
289
+ }
290
+
172
291
  /**
173
- * The files the milestone's commit range changed: from the previous landed commit (else the commit's own
174
- * parent) to `commit`. Throws E_IO_UNEXPECTED when git cannot say.
292
+ * The files the milestone's commit range changed: from the previous landed commit, else the HEAD the run
293
+ * started on (meta.json `startHead`, when that commit is still there), else the commit's own parent, to
294
+ * `commit`. A base run's milestone is its branch since origin's base (plan 35). Throws E_IO_UNEXPECTED when git cannot say.
175
295
  */
176
296
  export async function milestoneFiles(run: Run, commit: string): Promise<string[]> {
177
- const base = landedCommits(run).at(-1);
178
297
  const repo = run.meta.repo;
179
- const r = base
180
- ? await git(repo, ["diff", "--name-only", base, commit])
181
- : await git(repo, ["show", "--name-only", "--format=", "--first-parent", commit]);
298
+ const r = run.meta.base
299
+ ? // plan 35: a base run's milestone is its branch since origin's base; three dots, since other milestones land on the base meanwhile
300
+ await git(repo, ["diff", "--name-only", `origin/${run.meta.base}...${commit}`])
301
+ : await oneFiveFiles(run, commit);
182
302
  if (r.kind !== "ok")
183
303
  throw new CatherdError("E_IO_UNEXPECTED", `git could not list the files ${commit} changed`, {
184
304
  fix: `check that git works in ${repo}`,
@@ -186,6 +306,24 @@ export async function milestoneFiles(run: Run, commit: string): Promise<string[]
186
306
  return r.out.split("\n").filter(Boolean);
187
307
  }
188
308
 
309
+ /** The most commits a digest lists; the rest are counted. */
310
+ const DIGEST_COMMITS = 20;
311
+
312
+ /**
313
+ * The commits the milestone lands, `<short hash> <subject>`, oldest first: from the previous landed commit
314
+ * (else the commit alone) to `commit`. Just the hash when git cannot say: a digest never fails a landing.
315
+ */
316
+ export async function milestoneCommits(run: Run, commit: string): Promise<string[]> {
317
+ const base = run.meta.base ? `origin/${run.meta.base}` : landedCommits(run).at(-1);
318
+ const range = base ? [`${base}..${commit}`] : ["-1", commit];
319
+ const r = await git(run.meta.repo, ["log", "--reverse", "--format=%h %s", ...range]);
320
+ if (r.kind !== "ok") return [commit.slice(0, 7)];
321
+ const all = r.out.split("\n").filter(Boolean);
322
+ return all.length > DIGEST_COMMITS
323
+ ? [...all.slice(-DIGEST_COMMITS), `and ${all.length - DIGEST_COMMITS} earlier`]
324
+ : all;
325
+ }
326
+
189
327
  /** `rev`'s full commit hash in the run's repo. Throws E_IO_UNEXPECTED when git cannot say. */
190
328
  export async function fullCommit(run: Run, rev: string): Promise<string> {
191
329
  const r = await git(run.meta.repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { adapterFor } from "../adapters/registry.ts";
4
+ import "../adapters/all.ts";
3
5
  import { CatherdError, errorMessage } from "../domain/errors.ts";
4
6
  import { envelope, formatNotices, type Notice, type NoticePriority, priorityOf } from "../domain/notice.ts";
5
7
  import { sessionKey, type HostSessionRef } from "../domain/host.ts";
@@ -7,15 +9,17 @@ import { sendToCodexQueue, type QueueSendResult } from "../infra/codex-queue.ts"
7
9
  import {
8
10
  deliveryState,
9
11
  readDelivery,
12
+ ROLE_THREAD_REFUSAL,
10
13
  writeDeliveryAttempt,
11
14
  type DeliveryAttempt,
12
15
  } from "../infra/delivery.ts";
13
16
  import type { RunRecord } from "../domain/record.ts";
17
+ import type { RunEvent } from "../domain/run-events.ts";
14
18
  import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
15
19
  import { tryLock } from "../infra/filelock.ts";
16
20
  import { log } from "../infra/log.ts";
17
21
  import { type SendResult, sendToInbox } from "../infra/peer-inbox.ts";
18
- import { writeJsonAtomic } from "../infra/store.ts";
22
+ import { ensurePrivateDir, nonBlankLines, writeJsonAtomic } from "../infra/store.ts";
19
23
  import {
20
24
  readStallQuietMs,
21
25
  type Settled,
@@ -23,8 +27,9 @@ import {
23
27
  type Stalled,
24
28
  stallHooks,
25
29
  } from "./dispatch-service.ts";
26
- import { type Dispatch, listDispatches, readFailover } from "./dispatches.ts";
30
+ import { type Dispatch, listDispatches, readFailover, roleThreadOf } from "./dispatches.ts";
27
31
  import type { Deps } from "./ports.ts";
32
+ import { eventsFile, isDaemonRun, readEvents } from "./run-events-store.ts";
28
33
  import { listRuns, readRecords, type Run } from "./run-store.ts";
29
34
  import { currentSession, ownsRun, readSessionRows, runOwner } from "./sessions.ts";
30
35
 
@@ -32,6 +37,8 @@ export interface NotifierOptions {
32
37
  /** notices this close to each other go as one message (spec: 3 s); tests shorten it */
33
38
  coalesceMs?: number;
34
39
  sendCodex?: (target: HostSessionRef, content: string) => Promise<QueueSendResult>;
40
+ /** how often the reports of the session's 2.0 runs are looked for between scans; 0 (the default) never */
41
+ reportPollMs?: number;
35
42
  /** the sender; tests may replace it */
36
43
  send?: (
37
44
  target: { socketPath: string | null; token: string | null },
@@ -76,7 +83,8 @@ function legacyNotified(q: Queued, target: HostSessionRef): boolean {
76
83
  }
77
84
  }
78
85
 
79
- function recoverSubmission(dir: string, eventId: string): void {
86
+ /** A "submitting" claim left by a sender that died is ambiguous: retrying it may duplicate input. Under the notify lock. */
87
+ export function recoverSubmission(dir: string, eventId: string): void {
80
88
  for (const a of readDelivery(dir)) {
81
89
  if (a.status === "submitting" && a.eventIds.includes(eventId))
82
90
  writeDeliveryAttempt(dir, {
@@ -119,6 +127,7 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
119
127
  name: r.name,
120
128
  role: r.role,
121
129
  lane: r.lane,
130
+ thread: r.thread,
122
131
  rung: r.rung,
123
132
  status,
124
133
  replyStatus: r.replyStatus,
@@ -129,6 +138,43 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
129
138
  };
130
139
  }
131
140
 
141
+ /** One report of a 2.0 run, as the session is told of it: the unit, what kind it is, its text. */
142
+ export function reportNotice(run: Run, e: Extract<RunEvent, { type: "report.posted" }>): Notice {
143
+ return {
144
+ kind: "report",
145
+ eventId: JSON.stringify([run.id, `report-${e.seq}`, "report"]),
146
+ runId: run.id,
147
+ runTitle: run.meta.title,
148
+ dispatchId: `report-${e.seq}`,
149
+ name: e.unit,
150
+ role: "run",
151
+ lane: null,
152
+ thread: null,
153
+ rung: "",
154
+ status: e.kind,
155
+ replyStatus: null,
156
+ secs: 0,
157
+ changedOwned: 0,
158
+ reply: e.text,
159
+ priority: e.kind === "parked" || e.kind === "failed" ? "next" : "later",
160
+ };
161
+ }
162
+
163
+ /** A live fresh role's thread, as its CLI named it early in its stream; null before it did. */
164
+ function streamThread(d: Dispatch): string | null {
165
+ const a = adapterFor(d.admit.backend);
166
+ if (!a) return null;
167
+ for (const line of nonBlankLines(dispatchPaths(d.dir).events)) {
168
+ try {
169
+ const t = a.parse(line).thread;
170
+ if (t) return t;
171
+ } catch {
172
+ // a line the adapter cannot read names no thread
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
132
178
  /** Spec §3.6: a live role that went quiet, once, at `next`. */
133
179
  export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: number): Notice {
134
180
  return {
@@ -140,6 +186,7 @@ export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: numbe
140
186
  name: d.admit.name,
141
187
  role: d.admit.role,
142
188
  lane: d.admit.lane,
189
+ thread: d.admit.thread ?? streamThread(d),
143
190
  rung: d.admit.rung,
144
191
  status: `stalled: no output for ${Math.max(1, Math.round(quietMs / 60_000))} min`,
145
192
  replyStatus: null,
@@ -242,6 +289,38 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
242
289
  log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
243
290
  }
244
291
  }
292
+ // spec 1.5 plan 21: a notice never goes to a role's own thread (a `codex exec` thread exits after its turn,
293
+ // so what is queued there is lost). It fails loudly, and `status` says so, instead of enqueue-accepted.
294
+ const roles = new Map(due.map((q) => [q, roleThreadOf(q.run, target.sessionId)]));
295
+ for (const [q, role] of roles) {
296
+ if (role === null) continue;
297
+ due.splice(due.indexOf(q), 1);
298
+ const reason = `${ROLE_THREAD_REFUSAL} (${role}, ${target.host} ${target.sessionId}), not the orchestrator's; peek(run) from the orchestrator's session takes the run back`;
299
+ log("error", "notify", {
300
+ run: q.run.id,
301
+ dispatch: q.notice.dispatchId,
302
+ target: target.sessionId,
303
+ reason,
304
+ });
305
+ try {
306
+ writeDeliveryAttempt(q.dir, {
307
+ attemptId: crypto.randomUUID(),
308
+ target: {
309
+ host: target.host,
310
+ sessionId: target.sessionId,
311
+ hostSessionId: target.hostSessionId,
312
+ name: target.name,
313
+ },
314
+ eventIds: [q.notice.eventId],
315
+ at: new Date(deps.now()).toISOString(),
316
+ status: "failed",
317
+ msgId: null,
318
+ reason,
319
+ });
320
+ } catch (e) {
321
+ log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
322
+ }
323
+ }
245
324
  // nothing is sent unless every notice's claim is on disk. A claim that cannot be written rolls the batch's
246
325
  // written claims back to failed (provably not submitted, so still deliverable), keeps that notice for a
247
326
  // later pass, and claims the rest again at once: one bad file never holds back the others
@@ -430,7 +509,35 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
430
509
  }
431
510
  };
432
511
 
512
+ /** The reports of a 2.0 run this session owns: queued once each, under `<run>/reports/<seq>/`. */
513
+ const reportSizes = new Map<string, number>();
514
+ const enqueueReports = (run: Run, force: boolean): void => {
515
+ const size = statSync(eventsFile(run)).size;
516
+ if (!force && reportSizes.get(run.id) === size) return;
517
+ reportSizes.set(run.id, size);
518
+ for (const e of readEvents(run)) {
519
+ if (e.type !== "report.posted") continue;
520
+ const dir = join(run.dir, "reports", String(e.seq));
521
+ ensurePrivateDir(dir);
522
+ const notice = reportNotice(run, e);
523
+ queueNotice({ key: notice.eventId, run, dir, notice, mark: join(dir, "notified"), due: () => true });
524
+ }
525
+ };
526
+
527
+ const scanReports = (force: boolean): void => {
528
+ if (stopped || !currentSession(deps)) return;
529
+ for (const run of listRuns().runs) {
530
+ try {
531
+ if (isDaemonRun(run) && owned(run)) enqueueReports(run, force);
532
+ } catch (e) {
533
+ log("warn", "notify", { run: run.id, error: errorMessage(e) });
534
+ }
535
+ }
536
+ };
537
+
433
538
  const onSettled = (s: Settled): void => {
539
+ // the run's reports tell the session of a 2.0 run, not each role's finish
540
+ if (isDaemonRun(s.run)) return;
434
541
  try {
435
542
  enqueue(s.run, s.d, s.record);
436
543
  } catch (e) {
@@ -446,6 +553,10 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
446
553
  for (const run of listRuns().runs) {
447
554
  try {
448
555
  if (!owned(run)) continue;
556
+ if (isDaemonRun(run)) {
557
+ enqueueReports(run, true);
558
+ continue;
559
+ }
449
560
  const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
450
561
  for (const d of listDispatches(run)) {
451
562
  // one dispatch's damaged evidence stays ambiguous for it alone: the rest of the run is still scanned
@@ -499,6 +610,15 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
499
610
  settledHooks.add(onSettled);
500
611
  stallHooks.add(onStall);
501
612
  }
613
+ if (!retryEvent && o.reportPollMs) {
614
+ const poll = setInterval(() => scanReports(false), o.reportPollMs);
615
+ poll.unref();
616
+ const stopRest = n.stop;
617
+ n.stop = () => {
618
+ clearInterval(poll);
619
+ stopRest();
620
+ };
621
+ }
502
622
  return n;
503
623
  }
504
624
 
@@ -0,0 +1,26 @@
1
+ import { notifyMessage } from "../domain/notify.ts";
2
+ import type { NotifyMoment } from "../domain/profile.ts";
3
+ import { postNotify } from "../infra/ntfy.ts";
4
+ import type { Deps } from "./ports.ts";
5
+ import { runProfile } from "./run-pin.ts";
6
+ import type { Run } from "./run-store.ts";
7
+
8
+ /**
9
+ * Tells the owner's phone about a moment of a run, when the run's profile lists that moment. False when it is
10
+ * not listed, or the message did not go out; it never throws.
11
+ */
12
+ export async function sendRunNotice(
13
+ deps: Deps,
14
+ run: Run,
15
+ i: { unit: string; moment: NotifyMoment },
16
+ o: { fetch?: typeof fetch; timeoutMs?: number } = {},
17
+ ): Promise<boolean> {
18
+ if (!runProfile(deps, run).notify.includes(i.moment)) return false;
19
+ // CATHERD_NO_END_PUSH=1 (the test preload sets it) keeps every real send off; a test's own fetch still goes
20
+ if (process.env.CATHERD_NO_END_PUSH === "1" && !o.fetch) return false;
21
+ return postNotify(
22
+ deps.profiles.notifyUrl(),
23
+ notifyMessage({ runId: run.id, runTitle: run.meta.title, unit: i.unit, moment: i.moment }),
24
+ o,
25
+ );
26
+ }
@@ -0,0 +1,82 @@
1
+ import type { RunRecord } from "../domain/record.ts";
2
+ import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
3
+ import { awaitsCollect } from "../infra/dispatch-dir.ts";
4
+ import { listDispatches, liveDispatches } from "./dispatches.ts";
5
+ import { readRecords, type Run } from "./run-store.ts";
6
+ import { runOwner } from "./sessions.ts";
7
+
8
+ export const ORCHESTRATOR_STALL_MS = 5 * 60_000;
9
+
10
+ /**
11
+ * Plan 22 (#42 finding 3): the line shows only for a current owner (its Claude Code session live, or seen in the
12
+ * last day) and an unread record that ended in the last day, so an abandoned run never reads "stalled" forever.
13
+ */
14
+ export const WAIT_WINDOW_MS = 24 * 3_600_000;
15
+
16
+ export interface OrchestratorWait {
17
+ since: string;
18
+ seconds: number;
19
+ stalled: boolean;
20
+ unread: number;
21
+ /** when the line stops showing, unless the owner is seen again or another record ends */
22
+ until: string;
23
+ }
24
+
25
+ /**
26
+ * When the owner was last seen acting on the run: it took the run (`since`), or one of the run's dispatches was
27
+ * admitted from its session.
28
+ */
29
+ function ownerSeen(run: Run, owner: { sessionId: string; since: string }): number {
30
+ let seen = Date.parse(owner.since) || 0;
31
+ for (const d of listDispatches(run))
32
+ if (d.admit.sessionId?.toLowerCase() === owner.sessionId.toLowerCase())
33
+ seen = Math.max(seen, Date.parse(d.admit.admittedAt) || 0);
34
+ return seen;
35
+ }
36
+
37
+ /**
38
+ * Pure diagnostic: queue acceptance cannot show that the orchestrator processed a completion. Null unless the
39
+ * run has a current owner, no live role, and an unread record that ended within WAIT_WINDOW_MS.
40
+ */
41
+ export function orchestratorWait(
42
+ run: Run,
43
+ now: number,
44
+ records: RunRecord[] = readRecords(run).records,
45
+ hasLive = liveDispatches(run, now).length > 0,
46
+ files: () => SessionFile[] = readSessionFiles,
47
+ ): OrchestratorWait | null {
48
+ const owner = runOwner(run);
49
+ if (!owner || hasLive) return null;
50
+ const unread = new Set(
51
+ listDispatches(run)
52
+ .filter((d) => awaitsCollect(d.dir))
53
+ .map((d) => d.admit.dispatchId),
54
+ );
55
+ const recent = records.filter(
56
+ (r) => unread.has(r.dispatchId) && now - (Date.parse(r.endedAt) || 0) < WAIT_WINDOW_MS,
57
+ );
58
+ if (!recent.length) return null;
59
+ const ended = Math.max(...recent.map((r) => Date.parse(r.endedAt) || 0));
60
+ const live = owner.host === "claude-code" && liveSessionFile(owner.sessionId, files()) !== null;
61
+ const seen = live ? now : ownerSeen(run, owner);
62
+ if (now - seen >= WAIT_WINDOW_MS) return null;
63
+ const since = Math.max(ended, Date.parse(owner.since) || 0);
64
+ const elapsed = Math.max(0, now - since);
65
+ return {
66
+ since: new Date(since).toISOString(),
67
+ seconds: Math.floor(elapsed / 1000),
68
+ stalled: elapsed >= ORCHESTRATOR_STALL_MS,
69
+ unread: records.filter((r) => unread.has(r.dispatchId)).length,
70
+ until: new Date(Math.min(ended, seen) + WAIT_WINDOW_MS).toISOString(),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The one "waiting for orchestrator" line every view shows (#42 finding 7): seconds and `stalled` from `since` at
76
+ * `now`, so a memoised wait stays true between recomputes; null once it is past `until`.
77
+ */
78
+ export function waitingLine(w: OrchestratorWait | null | undefined, now: number): string | null {
79
+ if (!w || now >= Date.parse(w.until)) return null;
80
+ const elapsed = Math.max(0, now - Date.parse(w.since));
81
+ return `${elapsed >= ORCHESTRATOR_STALL_MS ? "stalled · " : ""}waiting for orchestrator ${Math.floor(elapsed / 1000)}s`;
82
+ }