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
@@ -16,6 +16,28 @@ export function median(xs: readonly number[]): number | null {
16
16
  return s.length % 2 ? (s[m] as number) : ((s[m - 1] as number) + (s[m] as number)) / 2;
17
17
  }
18
18
 
19
+ /** A stretch of time in epoch ms; `to` null while it is still open. */
20
+ export interface Span {
21
+ from: number;
22
+ to: number | null;
23
+ }
24
+
25
+ /** How many ms of [from, to] any of `spans` covers, overlaps counted once; an open span runs to `to`. */
26
+ export function coveredMs(from: number, to: number, spans: readonly Span[]): number {
27
+ const clipped = spans
28
+ .map((s) => [Math.max(from, s.from), Math.min(to, s.to ?? to)] as const)
29
+ .filter(([a, b]) => b > a)
30
+ .sort((x, y) => x[0] - y[0]);
31
+ let total = 0;
32
+ let end = -Infinity;
33
+ for (const [a, b] of clipped) {
34
+ if (b <= end) continue;
35
+ total += b - Math.max(a, end);
36
+ end = b;
37
+ }
38
+ return total;
39
+ }
40
+
19
41
  /** A plain object: not null and not an array. */
20
42
  export const isPlain = (v: unknown): v is Record<string, unknown> =>
21
43
  typeof v === "object" && v !== null && !Array.isArray(v);
@@ -0,0 +1,130 @@
1
+ import { z } from "zod";
2
+ import { ID_PATTERN } from "./ids.ts";
3
+
4
+ export const WORKSPACE_LIMIT = 100;
5
+
6
+ export const WorkspaceIdSchema = z
7
+ .string()
8
+ .regex(ID_PATTERN)
9
+ .refine((s) => !s.includes(".."));
10
+ export const WorkspaceBudgetSchema = z.strictObject({
11
+ minutes: z.number().finite().nonnegative().optional(),
12
+ tokens: z.number().finite().nonnegative().optional(),
13
+ usd: z.number().finite().nonnegative().optional(),
14
+ });
15
+ /** A git ref a release waits on (`origin/main`): no option, no range, no whitespace. */
16
+ export const BaseRefSchema = z
17
+ .string()
18
+ .regex(/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/)
19
+ .refine((s) => !s.includes("..") && !s.endsWith("/") && !s.endsWith(".lock"));
20
+ export const RELEASES = ["land", "merge"] as const;
21
+ export const WorkspaceStepSchema = z
22
+ .strictObject({
23
+ id: WorkspaceIdSchema,
24
+ repo: WorkspaceIdSchema,
25
+ title: z.string().min(1),
26
+ aLines: z.array(z.string()),
27
+ dependsOn: z.array(WorkspaceIdSchema).max(WORKSPACE_LIMIT).default([]),
28
+ milestone: WorkspaceIdSchema.default("M1"),
29
+ /**
30
+ * When the step releases its dependents: once its milestone lands (the default), or once the landed
31
+ * commit is an ancestor of `base` in its repository (`git merge-base --is-ancestor`), checked when a
32
+ * dependent asks, never polled.
33
+ */
34
+ release: z.enum(RELEASES).default("land"),
35
+ base: BaseRefSchema.optional(),
36
+ })
37
+ .refine((s) => (s.release === "merge") === (s.base !== undefined), {
38
+ message: 'release "merge" needs a base ref (e.g. origin/main), and only it takes one',
39
+ });
40
+ export type WorkspaceStep = z.infer<typeof WorkspaceStepSchema>;
41
+ export const WorkspaceSchema = z
42
+ .strictObject({
43
+ schema: z.literal(1),
44
+ id: WorkspaceIdSchema,
45
+ root: z.string().min(1),
46
+ title: z.string().min(1),
47
+ aLines: z.array(z.string()),
48
+ createdAt: z.iso.datetime(),
49
+ budget: WorkspaceBudgetSchema,
50
+ repos: z
51
+ .record(WorkspaceIdSchema, z.string().min(1))
52
+ .refine((repos) => Object.keys(repos).length <= WORKSPACE_LIMIT),
53
+ steps: z.array(WorkspaceStepSchema).min(1).max(WORKSPACE_LIMIT),
54
+ })
55
+ .superRefine((workspace, ctx) => {
56
+ // Zod may still refine an oversized array; do not traverse invalid graphs.
57
+ if (
58
+ workspace.steps.length > WORKSPACE_LIMIT ||
59
+ Object.keys(workspace.repos).length > WORKSPACE_LIMIT ||
60
+ workspace.steps.some((step) => step.dependsOn.length > WORKSPACE_LIMIT)
61
+ )
62
+ return;
63
+ const ids = new Set<string>();
64
+ let invalidIdentity = false;
65
+ for (const step of workspace.steps) {
66
+ if (ids.has(step.id) || !Object.hasOwn(workspace.repos, step.repo)) {
67
+ invalidIdentity = true;
68
+ ctx.addIssue({
69
+ code: "custom",
70
+ message: "steps require unique ids and repositories present in the snapshot",
71
+ });
72
+ }
73
+ ids.add(step.id);
74
+ }
75
+ if (invalidIdentity) return;
76
+ const byId = new Map(workspace.steps.map((step) => [step.id, step]));
77
+ const remaining = new Map<string, number>();
78
+ const dependents = new Map<string, string[]>();
79
+ const ready: string[] = [];
80
+ for (const step of workspace.steps) {
81
+ if (
82
+ new Set(step.dependsOn).size !== step.dependsOn.length ||
83
+ step.dependsOn.some((id) => !byId.has(id))
84
+ ) {
85
+ ctx.addIssue({
86
+ code: "custom",
87
+ message: "dependencies must form a graph without cycles or unknown steps",
88
+ });
89
+ return;
90
+ }
91
+ remaining.set(step.id, step.dependsOn.length);
92
+ if (!step.dependsOn.length) ready.push(step.id);
93
+ for (const id of step.dependsOn) {
94
+ const next = dependents.get(id) ?? [];
95
+ next.push(step.id);
96
+ dependents.set(id, next);
97
+ }
98
+ }
99
+ const ancestors = new Map<string, Set<string>>();
100
+ const latestByRepo = new Map<string, string>();
101
+ // Topological order makes adjacent owners sufficient: if each owner follows
102
+ // the previous owner transitively, every pair of owners is ordered.
103
+ for (let i = 0; i < ready.length; i++) {
104
+ const step = byId.get(ready[i]!)!;
105
+ const before = new Set<string>();
106
+ for (const id of step.dependsOn) {
107
+ before.add(id);
108
+ for (const ancestor of ancestors.get(id)!) before.add(ancestor);
109
+ }
110
+ ancestors.set(step.id, before);
111
+ const previous = latestByRepo.get(step.repo);
112
+ if (previous && !before.has(previous))
113
+ ctx.addIssue({
114
+ code: "custom",
115
+ message: "steps sharing a repository must be ordered by dependencies",
116
+ });
117
+ latestByRepo.set(step.repo, step.id);
118
+ for (const id of dependents.get(step.id) ?? []) {
119
+ const count = remaining.get(id)! - 1;
120
+ remaining.set(id, count);
121
+ if (!count) ready.push(id);
122
+ }
123
+ }
124
+ if (ready.length !== workspace.steps.length)
125
+ ctx.addIssue({
126
+ code: "custom",
127
+ message: "dependencies must form a graph without cycles or unknown steps",
128
+ });
129
+ });
130
+ export type Workspace = z.infer<typeof WorkspaceSchema>;
@@ -0,0 +1,39 @@
1
+ import { defineCommand } from "citty";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { answerUnit } from "../services/run-client.ts";
4
+ import { defaultDeps } from "./deps.ts";
5
+ import { JSON_ARG, printJson, refuseInRole } from "./cli-kit.ts";
6
+
7
+ /** What the phone message names: the run, the unit and the answer, as one command. */
8
+ export const answerCommand = defineCommand({
9
+ meta: {
10
+ name: "answer",
11
+ description: "Answer a parked unit of a run: it runs again with your answer",
12
+ },
13
+ args: {
14
+ run: { type: "positional", required: true, description: "the run id" },
15
+ unit: {
16
+ type: "positional",
17
+ required: true,
18
+ description: "the parked unit: the run, a milestone or a lane",
19
+ },
20
+ answer: {
21
+ type: "positional",
22
+ required: false,
23
+ description: "your answer (several words need no quotes)",
24
+ },
25
+ ...JSON_ARG,
26
+ },
27
+ async run({ args }) {
28
+ refuseInRole(process.env, "catherd answer");
29
+ // citty names the first words; the rest of the line is the answer's too
30
+ const answer = (args._ as string[]).slice(2).join(" ").trim();
31
+ if (!answer)
32
+ throw new CatherdError("E_INPUT_INVALID", "no answer given", {
33
+ fix: 'catherd answer <run> <unit> "<your answer>"',
34
+ });
35
+ const r = await answerUnit(defaultDeps(), { run: args.run, unit: args.unit, answer });
36
+ if (args.json) return printJson(r);
37
+ console.log(`answered ${r.unit}${r.parked.length ? `; still waiting: ${r.parked.join(", ")}` : ""}`);
38
+ },
39
+ });
@@ -1,4 +1,3 @@
1
- import { HOST_ARG, terminalHost } from "./host-arg.ts";
2
1
  import { defineCommand } from "citty";
3
2
  import { CatherdError, isCatherdError } from "../domain/errors.ts";
4
3
  import { ROLES, type Role } from "../domain/roles.ts";
@@ -6,68 +5,35 @@ import { gitToplevel } from "../infra/git.ts";
6
5
  import {
7
6
  type CatalogModel,
8
7
  catalogQuery,
8
+ type IndexStatus,
9
9
  type Refreshed,
10
10
  refreshDiscovery,
11
- saveTreatLike,
11
+ refreshIndex,
12
12
  } from "../services/catalog-service.ts";
13
- import { readDerived } from "../infra/sources/cache.ts";
14
- import { type SyncReport, syncSources } from "../services/source-sync.ts";
15
- import { clearTreatLike, type LeftOnStandIn, resetTreatLikes, suggestFor } from "../services/treat-likes.ts";
13
+ import { indexCheck } from "../services/doctor-checks.ts";
16
14
  import { exitCodeOf, JSON_ARG, mark, printError } from "./cli-kit.ts";
17
15
 
18
- /**
19
- * Spec 1.2 §9: a sync, one line per source (fetched, fresh, skipped, or failed with the answer it kept),
20
- * then the rungs newly scored, the user's stand-ins no longer needed and the cross-check warnings.
21
- */
22
- export function syncLines(r: SyncReport, plain = false): string[] {
23
- if (r.busy) return ["- sources: another sync is running; its results apply when it ends"];
24
- const out: string[] = [];
25
- for (const s of r.sources) {
26
- if (s.state === "fetched") out.push(`${mark("ok", plain)} ${s.source}: fetched`);
27
- else if (s.state === "fresh") out.push(`- ${s.source}: fresh (fetched ${s.fetchedAt})`);
28
- else if (s.state === "skipped") out.push(`- ${s.source}: ${s.detail}`);
29
- else
30
- out.push(
31
- `${mark("warn", plain)} ${s.source}: ${s.error}; ${s.fetchedAt ? `keeps the answer fetched ${s.fetchedAt}` : "no earlier answer"}`,
32
- );
33
- }
34
- if (r.newlyScored.length) out.push(`newly scored: ${r.newlyScored.join(", ")}`);
35
- for (const n of r.noLongerNeeded)
36
- out.push(`stand-in no longer needed: ${n.rung} has values of its own for what ${n.like} lent it`);
37
- for (const w of r.warnings) out.push(`${mark("warn", plain)} ${w}`);
38
- return out;
16
+ /** `sync`'s one line: the index's pair count and age, or why the last fetch failed and what was kept. */
17
+ export function syncLine(s: IndexStatus, plain = false, now = Date.now()): string {
18
+ const c = indexCheck(now, s);
19
+ return `${mark(c.state === "ok" ? "ok" : "warn", plain)} ${c.label}: ${c.detail}`;
39
20
  }
40
21
 
41
22
  const sync = defineCommand({
42
23
  meta: {
43
24
  name: "sync",
44
25
  description:
45
- "Fetch the public model facts and scores now (each source at most every 12 hours unless --force) and show what changed",
26
+ "Fetch the Slopalytics index now (it is refreshed at most hourly unless --force) and show how many pairs it holds",
46
27
  },
47
28
  args: {
48
- force: { type: "boolean", description: "fetch every source, however fresh" },
49
- unmatched: {
50
- type: "boolean",
51
- description: "also list each source's ids that match no model in the catalog",
52
- },
29
+ force: { type: "boolean", description: "fetch even when the index was checked within the hour" },
53
30
  ...JSON_ARG,
54
31
  },
55
32
  async run({ args }) {
56
- try {
57
- const r = await syncSources({ force: args.force === true });
58
- if (args.json) console.log(JSON.stringify(r, null, 2));
59
- else for (const l of syncLines(r)) console.log(l);
60
- if (args.unmatched && !args.json) {
61
- const unmatched = readDerived()?.unmatched ?? {};
62
- if (Object.keys(unmatched).length === 0) console.log("unmatched: none");
63
- for (const [source, ids] of Object.entries(unmatched))
64
- console.log(`unmatched in ${source}: ${ids.join(", ")}`);
65
- }
66
- const got = r.sources.some((s) => s.state === "fetched" || s.state === "fresh");
67
- process.exitCode = r.failed.length && !got ? 1 : 0;
68
- } catch (e) {
69
- fail(e);
70
- }
33
+ const s = await refreshIndex({ force: args.force === true });
34
+ if (args.json) console.log(JSON.stringify(s, null, 2));
35
+ else console.log(syncLine(s));
36
+ process.exitCode = s.pairs === 0 ? 1 : 0;
71
37
  },
72
38
  });
73
39
 
@@ -80,29 +46,19 @@ export function formatRefreshed(r: Refreshed, plain = false): string {
80
46
  ].join("\n");
81
47
  }
82
48
 
83
- /**
84
- * One model: its rungs scored and roles, then (spec 1.2 §4.1, §5.3, §8) its price and speed facts, and each
85
- * rung's values with their confidence and source, and the runs catherd has made on it.
86
- */
49
+ /** One model: its indexed pairs and roles, then each pair's intelligence, price, speed and the runs catherd has made on it. */
87
50
  export function formatModel(m: CatalogModel): string {
88
- const scored = m.rungs.filter((r) => r.enabled).length;
89
51
  const listed = m.listed === false ? " not offered by this account" : "";
90
52
  const lines = [
91
- `${m.backend}:${m.model} ${scored}/${m.rungs.length} rungs scored roles ${m.roles.join(",") || "none"}${listed}`,
92
- ];
93
- const facts = [
94
- ...(m.price ? [`$${m.price.input}/$${m.price.output} per M tokens in/out`] : []),
95
- ...Object.entries(m.speed).map(([k, v]) => `${k} ${Number(v.toPrecision(4))}`),
53
+ `${m.backend}:${m.model} ${m.rungs.length} ${m.rungs.length === 1 ? "pair" : "pairs"} roles ${m.roles.join(",") || "none"}${listed}`,
96
54
  ];
97
- if (facts.length) lines.push(` ${facts.join(" · ")}`);
98
55
  for (const r of m.rungs) {
99
- // spec 1.2 §5.3: each value with its confidence and source, as `route` reports it; a guess names its rung
100
- const values = Object.entries(r.scores).map(
101
- ([dim, v]) =>
102
- `${dim} ${Number(v.value.toPrecision(4))} (${v.from ? `inferred from ${v.from}` : v.confidence}, ${v.source})`,
103
- );
104
- if (values.length === 0 && !r.evidence) continue;
105
- lines.push(` #${r.rung.slice(r.rung.lastIndexOf("#") + 1)} ${values.join(" · ") || "unscored"}`);
56
+ const parts = [
57
+ `intelligence ${r.intelligence.toFixed(1)}`,
58
+ ...(r.price ? [`$${r.price.input}/$${r.price.output} per M`] : []),
59
+ ...(r.speed !== null ? [`${Number(r.speed.toPrecision(3))} tok/s`] : []),
60
+ ];
61
+ lines.push(` #${r.rung.slice(r.rung.lastIndexOf("#") + 1)} ${parts.join(" · ")}`);
106
62
  if (r.evidence) lines.push(` runs: ${r.evidence}`);
107
63
  }
108
64
  return lines.join("\n");
@@ -129,12 +85,11 @@ const refresh = defineCommand({
129
85
  });
130
86
 
131
87
  const list = defineCommand({
132
- meta: { name: "list", description: "The models catherd can place, with their scored rungs" },
88
+ meta: { name: "list", description: "The models catherd can place, with their indexed pairs" },
133
89
  args: {
134
90
  backend: { type: "string", description: "only this backend or billing key" },
135
91
  role: { type: "string", description: `only models for this role (${ROLES.join(", ")})` },
136
92
  text: { type: "string", description: "only ids containing this" },
137
- scored: { type: "boolean", description: "only models with a scored rung" },
138
93
  ...JSON_ARG,
139
94
  },
140
95
  async run({ args }) {
@@ -149,7 +104,6 @@ const list = defineCommand({
149
104
  backend: args.backend,
150
105
  role: args.role as Role | undefined,
151
106
  text: args.text,
152
- scoredOnly: args.scored === true,
153
107
  limit: 10_000,
154
108
  repo: await hereRepo(),
155
109
  });
@@ -161,103 +115,8 @@ const list = defineCommand({
161
115
  },
162
116
  });
163
117
 
164
- /** Spec 1.2 §6.4: the profile rungs a removal leaves on an inferred stand-in or unscored, one line each. */
165
- export function leftLines(left: LeftOnStandIn[], plain = false): string[] {
166
- return left.map(
167
- (l) =>
168
- `${mark("warn", plain)} ${l.profile}: ${l.rung} is left ${l.unscored ? "unscored: routing skips it" : `on an inferred stand-in for ${l.dims.join(", ")}`}`,
169
- );
170
- }
171
-
172
- /** Spec 1.2 §6.4 `--suggest`: the three nearest stand-ins, each with its distance and the features it rests on. */
173
- export function suggestLines(r: ReturnType<typeof suggestFor>, rung: string): string[] {
174
- const head = r.lacking.length
175
- ? `${r.canonical} has no ${r.lacking.join(", ")} value of its own; the nearest stand-ins:`
176
- : `${r.canonical} has every value the bars use; the nearest scored rungs:`;
177
- if (r.suggestions.length === 0)
178
- return [
179
- `${r.canonical}: no scored rung shares 3 features with it; map it by hand: catherd catalog treat-like ${rung} <rung>`,
180
- ];
181
- return [
182
- head,
183
- ...r.suggestions.map(
184
- (s, i) =>
185
- ` ${i + 1}. ${s.like} distance ${s.distance.toFixed(2)} lends ${s.lends.join(", ")} on ${s.features.join(", ")}`,
186
- ),
187
- `map one: catherd catalog treat-like ${rung} <rung>`,
188
- ];
189
- }
190
-
191
- const treatLike = defineCommand({
192
- meta: {
193
- name: "treat-like",
194
- description:
195
- "Score an unscored rung as a scored one; --suggest ranks stand-ins, --clear and --reset remove your mappings",
196
- },
197
- args: {
198
- ...HOST_ARG,
199
- rung: {
200
- type: "positional",
201
- required: false,
202
- description: "the unscored rung, backend:model#effort or model#effort",
203
- },
204
- like: { type: "positional", required: false, description: "the scored rung whose scores it borrows" },
205
- suggest: { type: "string", description: "list the 3 nearest stand-ins for this rung" },
206
- clear: { type: "string", description: "remove your treat-like for this rung" },
207
- reset: { type: "boolean", description: "remove every treat-like of yours" },
208
- ...JSON_ARG,
209
- },
210
- async run({ args }) {
211
- try {
212
- const modes = [
213
- args.suggest !== undefined,
214
- args.clear !== undefined,
215
- args.reset === true,
216
- args.rung !== undefined,
217
- ];
218
- if (modes.filter(Boolean).length !== 1 || (args.rung !== undefined && args.like === undefined))
219
- throw new CatherdError(
220
- "E_INPUT_INVALID",
221
- "treat-like takes <rung> <like>, or one of --suggest, --clear, --reset",
222
- {
223
- fix: "catherd catalog treat-like <rung> <like> | --suggest <rung> | --clear <rung> | --reset",
224
- },
225
- );
226
- if (args.suggest !== undefined) {
227
- const r = suggestFor(args.suggest);
228
- if (args.json) console.log(JSON.stringify(r, null, 2));
229
- else for (const l of suggestLines(r, args.suggest)) console.log(l);
230
- return;
231
- }
232
- if (args.clear !== undefined) {
233
- const r = await clearTreatLike(args.clear, terminalHost(args.host).host);
234
- if (args.json) return console.log(JSON.stringify(r, null, 2));
235
- for (const l of leftLines(r.left)) console.log(l);
236
- console.log(`${mark("ok")} ${r.rung} is no longer treated like ${r.like}`);
237
- return;
238
- }
239
- if (args.reset) {
240
- const r = await resetTreatLikes(terminalHost(args.host).host);
241
- if (args.json) return console.log(JSON.stringify(r, null, 2));
242
- for (const l of leftLines(r.left)) console.log(l);
243
- console.log(
244
- r.removed.length
245
- ? `${mark("ok")} removed ${r.removed.length} treat-like${r.removed.length === 1 ? "" : "s"}: ${r.removed.map(([a, b]) => `${a} → ${b}`).join(", ")}`
246
- : "no treat-like of yours to remove",
247
- );
248
- return;
249
- }
250
- const r = await saveTreatLike(args.rung as string, args.like as string);
251
- if (args.json) return console.log(JSON.stringify(r, null, 2));
252
- console.log(`${mark("ok")} ${r.rung} is treated like ${r.like}`);
253
- } catch (e) {
254
- fail(e);
255
- }
256
- },
257
- });
258
-
259
- /** Spec §8 and 1.2 §9: `catherd catalog refresh|list|sync|treat-like <rung> <like> | --suggest|--clear|--reset`. */
118
+ /** Spec §8: `catherd catalog refresh|list|sync`. */
260
119
  export const catalogCommand = defineCommand({
261
120
  meta: { name: "catalog", description: "The model catalog" },
262
- subCommands: { refresh, list, sync, "treat-like": treatLike },
121
+ subCommands: { refresh, list, sync },
263
122
  });
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bun
2
+ // The detached check worker's entry: it imports only what running one milestone check needs, never src/cli.ts.
3
+ import { errorMessage } from "../domain/errors.ts";
4
+ import { checkWorker } from "../services/milestone-check.ts";
5
+
6
+ const flight = process.argv[2];
7
+ if (!flight) {
8
+ console.error("usage: check-bin.ts <flight file>");
9
+ process.exit(2);
10
+ }
11
+ try {
12
+ await checkWorker(flight);
13
+ } catch (e) {
14
+ console.error(`catherd check: ${errorMessage(e)}`);
15
+ process.exit(1);
16
+ }
17
+ process.exit(0);
@@ -1,4 +1,5 @@
1
- import { type CatherdError, isCatherdError } from "../domain/errors.ts";
1
+ import { CatherdError, isCatherdError } from "../domain/errors.ts";
2
+ import { roleScopeFromEnv } from "../domain/role-scope.ts";
2
3
  import { RUN_NOT_FOUND_FIX } from "../services/run-store.ts";
3
4
  import { glyph, type State } from "./glyphs.ts";
4
5
 
@@ -24,13 +25,28 @@ export function printError(e: Pick<CatherdError, "code" | "message" | "fix">): v
24
25
  export const exitCodeOf = (e: unknown): number =>
25
26
  isCatherdError(e) && e.code === "E_INPUT_INVALID" ? EXIT.usage : EXIT.error;
26
27
 
28
+ /**
29
+ * Spec 1.5 plan 21: a role's shell (CATHERD_ROLE, or its scratch TMPDIR) never runs a command that writes the
30
+ * orchestrator's state (pause, runs cancel, profile set, …): E_ROLE_SCOPE, as `catherd run-file` and `catherd gate`
31
+ * refuse an operation the role has not. `command` is the command as typed, like `catherd pause`.
32
+ */
33
+ export function refuseInRole(env: Record<string, string | undefined>, command: string): void {
34
+ const scope = roleScopeFromEnv(env);
35
+ if (!scope) return;
36
+ throw new CatherdError(
37
+ "E_ROLE_SCOPE",
38
+ `${command} is the orchestrator's: this process runs ${scope.name} of run ${scope.run}`,
39
+ { fix: "report it in your reply; the orchestrator does it" },
40
+ );
41
+ }
42
+
27
43
  export const printJson = (v: unknown): void => console.log(JSON.stringify(v, null, 2));
28
44
 
29
45
  /** The `--json` flag every read command takes. */
30
46
  export const JSON_ARG = { json: { type: "boolean", description: "print JSON" } } as const;
31
47
 
32
48
  /**
33
- * The glyph for a state, as in `✓ ready`: the dashboard's own (spec §9.3), ASCII with `--plain` only;
49
+ * The glyph for a state, as in `✓ ready`: catherd's own (spec §9.3), ASCII with `--plain` only;
34
50
  * NO_COLOR drops colour, never glyphs. The caller adds the word.
35
51
  */
36
52
  export const mark = (state: State, plain = false): string => glyph(state, plain);
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bun
2
+ // The detached run daemon's entry: like the supervisor's it imports only what driving a run needs, never
3
+ // src/cli.ts.
4
+ import { errorMessage } from "../domain/errors.ts";
5
+ import { runDaemon } from "../services/daemon.ts";
6
+ import { defaultDeps } from "./deps.ts";
7
+
8
+ const runId = process.argv[2];
9
+ if (!runId) {
10
+ console.error("usage: daemon-bin.ts <run id>");
11
+ process.exit(2);
12
+ }
13
+ try {
14
+ // nobody's role and no Claude Code session: the daemon answers to the run's events
15
+ const result = await runDaemon({ ...defaultDeps(), session: null, role: null }, runId);
16
+ if (result === "lost-lock") console.error(`catherd daemon: another daemon holds run ${runId}`);
17
+ } catch (e) {
18
+ console.error(`catherd daemon: ${errorMessage(e)}`);
19
+ process.exit(1);
20
+ }
21
+ // the lock is released and the run's events are written: a hook's call left hanging must not keep it alive
22
+ process.exit(0);
package/src/entry/deps.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { HostContext } from "../domain/host.ts";
2
+ import { roleScopeFromEnv } from "../domain/role-scope.ts";
2
3
  import { readSessionEnv } from "../infra/claude-session.ts";
3
4
  import { VERSION } from "../infra/version.ts";
4
5
  import type { Deps } from "../services/ports.ts";
@@ -15,6 +16,7 @@ export function defaultDeps(host: HostContext = { host: "unknown", session: null
15
16
  pollMs: 250,
16
17
  session: readSessionEnv(process.env),
17
18
  now: Date.now,
19
+ role: roleScopeFromEnv(process.env),
18
20
  };
19
21
  return deps;
20
22
  }
@@ -1,11 +1,13 @@
1
1
  import { gitToplevel } from "../infra/git.ts";
2
- import { HOST_ARG, terminalHost } from "./host-arg.ts";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { HOST_ARG, terminalHost, withThread } from "./host-arg.ts";
3
4
  import { defineCommand } from "citty";
4
5
  import { VERSION } from "../infra/version.ts";
5
6
  import { type DoctorReport, doctor } from "../services/doctor.ts";
6
7
  import type { Check } from "../services/doctor-checks.ts";
7
8
  import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
8
9
  import { mcpHandshake } from "./mcp/handshake.ts";
10
+ import { probeRoleServer } from "../infra/role-mcp.ts";
9
11
 
10
12
  /**
11
13
  * One row per check: `✓ ready Bun — 1.4.2`, then the full fix on its own line; a fix of several lines
@@ -42,16 +44,32 @@ export const doctorCommand = defineCommand({
42
44
  description:
43
45
  "send one labeled smoke to the validated original session (receipt does not prove processing)",
44
46
  },
47
+ thread: {
48
+ type: "string",
49
+ description:
50
+ "with --test-push: the Codex thread to send the smoke to (Codex does not export it to the commands it runs)",
51
+ },
52
+ docker: {
53
+ type: "boolean",
54
+ description:
55
+ "also probe Docker as a gate sees it: two compose services reaching each other by name, and the free disk (pulls busybox)",
56
+ },
45
57
  plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
46
58
  },
47
59
  async run({ args }) {
60
+ if (args.thread !== undefined && args["test-push"] !== true)
61
+ throw new CatherdError("E_INPUT_INVALID", "--thread only names where --test-push sends its smoke", {
62
+ fix: `catherd doctor --test-push --thread ${args.thread}`,
63
+ });
48
64
  const r = await doctor({
49
- host: terminalHost(args.host),
65
+ host: withThread(terminalHost(args.host), args.thread),
50
66
  repo: await gitToplevel(process.cwd()),
51
67
  bunVersion: Bun.version,
52
68
  version: VERSION,
53
69
  handshake: () => mcpHandshake(),
70
+ roleServerStart: () => probeRoleServer(),
54
71
  testPush: args["test-push"] === true,
72
+ docker: args.docker === true,
55
73
  });
56
74
  if (args.json) printJson(r);
57
75
  else for (const l of formatReport(r, args.plain === true)) console.log(l);
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Spec §9.3's glyphs, each with its ASCII twin for `--plain`: the dashboard and every CLI command draw
3
- * state with these. Free of OpenTUI, so `mcp`, `lock` and `_supervise` can load it (spec §3.1).
2
+ * Spec §9.3's glyphs, each with its ASCII twin for `--plain`: every CLI command draws state with these, so
3
+ * `mcp`, `lock` and `_supervise` can load them (spec §3.1).
4
4
  */
5
5
  const GLYPHS = {
6
6
  ok: ["✓", "+"],
@@ -32,3 +32,16 @@ export const glyph = (g: Glyph, plain: boolean): string => GLYPHS[g][plain ? 1 :
32
32
 
33
33
  /** The words state always travels with (spec §9.3: green/amber/red only for state, always with a word). */
34
34
  export type State = "ok" | "warn" | "fail" | "skip" | "info";
35
+
36
+ export type Mood = "good" | "working" | "waiting" | "failed";
37
+ export const FACES: Record<Mood, string> = {
38
+ good: "=^.^=",
39
+ working: "=o.o=",
40
+ waiting: "=-.-=",
41
+ failed: "=x.x=",
42
+ };
43
+
44
+ /** The conductor, for the init welcome only (spec §9.3). ASCII already. */
45
+ export function mascot(mood: Mood): [string, string, string] {
46
+ return [" /\\_/\\ .", `(${FACES[mood]})/`, ' (")(")'].map((l) => l.padEnd(9)) as [string, string, string];
47
+ }
@@ -10,6 +10,34 @@ export const HOST_ARG = {
10
10
  },
11
11
  };
12
12
 
13
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+
15
+ /**
16
+ * Plan 22, `doctor --test-push --thread <uuid>`: Codex does not export its thread id to the commands it runs, so a
17
+ * shell names the thread the smoke goes to. It makes the host that Codex thread; a Claude Code session refuses it.
18
+ */
19
+ export function withThread(host: HostContext, thread: string | undefined): HostContext {
20
+ if (thread === undefined) return host;
21
+ if (!UUID.test(thread))
22
+ throw new CatherdError("E_INPUT_INVALID", `--thread ${thread} is not a Codex thread id`, {
23
+ fix: "pass the thread's UUID, as the first line of a catherd message names it (thread: <uuid>)",
24
+ });
25
+ if (host.host === "claude-code")
26
+ throw new CatherdError(
27
+ "E_INPUT_INVALID",
28
+ "--thread names a Codex thread, and this is a Claude Code session",
29
+ {
30
+ fix: "run catherd doctor --test-push without --thread from Claude Code",
31
+ },
32
+ );
33
+ const sessionId = thread.toLowerCase();
34
+ return {
35
+ host: "codex",
36
+ session: { host: "codex", sessionId, hostSessionId: null, name: null },
37
+ conflict: null,
38
+ };
39
+ }
40
+
13
41
  export function terminalHost(value: string | undefined, env = process.env): HostContext {
14
42
  if (value === undefined || value === "auto") return resolveHost({ env });
15
43
  if (value !== "codex" && value !== "claude-code")