catherd-cli 1.5.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 (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  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 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -0,0 +1,410 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
+ import type { ServerWebSocket } from "bun";
5
+ import { z } from "zod";
6
+ import { CatherdError, errorMessage, isCatherdError } from "../../domain/errors.ts";
7
+ import { describeEvent } from "../../domain/run-events.ts";
8
+ import { ROLES, type Role } from "../../domain/roles.ts";
9
+ import { openUrl, writeRedirectPage } from "../../infra/open-url.ts";
10
+ import { VERSION } from "../../infra/version.ts";
11
+ import { setRoleRungs, setupView } from "../../services/panel-setup.ts";
12
+ import type { Deps } from "../../services/ports.ts";
13
+ import { answerUnit, cancelRun } from "../../services/run-client.ts";
14
+ import { listRunViews, parkedQuestions, runTimeline, runView, tailEvents } from "../../services/run-read.ts";
15
+ import { readEvents } from "../../services/run-events-store.ts";
16
+ import { findRun } from "../../services/run-store.ts";
17
+ import {
18
+ AnswerBodySchema,
19
+ ErrorBodySchema,
20
+ ListRunsQuerySchema,
21
+ LoginBodySchema,
22
+ OPERATIONS,
23
+ type Operation,
24
+ type OperationId,
25
+ QuestionsQuerySchema,
26
+ RungsBodySchema,
27
+ } from "./openapi.ts";
28
+
29
+ export const PANEL_COOKIE = "catherd_panel";
30
+
31
+ export interface PanelOptions {
32
+ deps: Deps;
33
+ repo: string | null;
34
+ token: string;
35
+ port: number;
36
+ distDir: string;
37
+ open: boolean;
38
+ /** What opens the sign-in page: a test replaces it. */
39
+ opener?: (target: string) => void;
40
+ pollMs?: number;
41
+ }
42
+ export interface Panel {
43
+ url: string;
44
+ port: number;
45
+ stop(): Promise<void>;
46
+ }
47
+
48
+ /** 32 random bytes, base64url: the token a `catherd panel` run accepts on its login page. */
49
+ export const newToken = (): string => randomBytes(32).toString("base64url");
50
+
51
+ const SECURITY_HEADERS = {
52
+ "Content-Security-Policy":
53
+ "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
54
+ "X-Frame-Options": "DENY",
55
+ "Referrer-Policy": "no-referrer",
56
+ };
57
+
58
+ const STATUS: Record<string, number> = {
59
+ E_INPUT_INVALID: 400,
60
+ unauthorized: 401,
61
+ forbidden: 403,
62
+ E_RUN_NOT_FOUND: 404,
63
+ not_found: 404,
64
+ E_RUN_NOT_LIVE: 409,
65
+ };
66
+
67
+ const errorResponse = (code: string, message: string, fix?: string): Response =>
68
+ Response.json(
69
+ ErrorBodySchema.parse({
70
+ error: {
71
+ message,
72
+ type: code,
73
+ code,
74
+ ...(fix === undefined ? {} : { fix }),
75
+ },
76
+ }),
77
+ { status: STATUS[code] ?? 500 },
78
+ );
79
+
80
+ const digest = (s: string): Buffer => createHash("sha256").update(s).digest();
81
+ const sameToken = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
82
+
83
+ const cookieOf = (req: Request): string | undefined =>
84
+ req.headers
85
+ .get("cookie")
86
+ ?.split(";")
87
+ .map((c) => c.trim().split("="))
88
+ .find(([k]) => k === PANEL_COOKIE)?.[1];
89
+
90
+ /** The first problem of a request, as the 400's message: the field's path, then what is wrong with it. */
91
+ const issueText = (e: z.ZodError): string => {
92
+ const i = e.issues[0];
93
+ return i ? `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}` : "invalid request";
94
+ };
95
+
96
+ interface Call {
97
+ deps: Deps;
98
+ repo: string | null;
99
+ params: Record<string, string>;
100
+ query: unknown;
101
+ body: unknown;
102
+ }
103
+
104
+ const page = <T>(all: T[], q: { limit: number; offset: number }) => ({
105
+ items: all.slice(q.offset, q.offset + q.limit),
106
+ total: all.length,
107
+ });
108
+ const has = (hay: string[], needle: string | undefined): boolean =>
109
+ needle === undefined || hay.some((h) => h.toLowerCase().includes(needle.toLowerCase()));
110
+
111
+ const HANDLERS: Record<OperationId, (c: Call) => unknown> = {
112
+ login: () => null,
113
+ logout: () => null,
114
+ getSession: () => ({ authenticated: true }),
115
+ getVersion: () => ({ catherd: VERSION }),
116
+ getSetup: (c) => setupView(c.deps, c.repo),
117
+ setRoleRungs: (c) => {
118
+ const role = c.params.role as Role;
119
+ if (!ROLES.includes(role))
120
+ throw new CatherdError("E_INPUT_INVALID", `no role named "${role}"`, {
121
+ fix: `one of ${ROLES.join(", ")}`,
122
+ });
123
+ return setRoleRungs(c.deps, c.repo, role, (c.body as z.infer<typeof RungsBodySchema>).rungs);
124
+ },
125
+ // shortcut: reads every run's view per list call, upgrade when a home holds thousands of runs
126
+ listRuns: (c) => {
127
+ const q = c.query as z.infer<typeof ListRunsQuerySchema>;
128
+ const all = listRunViews(c.deps, { limit: 100_000 }).filter(
129
+ (r) => (q.phase === undefined || r.phase === q.phase) && has([r.title, r.id, r.repo], q.q),
130
+ );
131
+ return page(all, q);
132
+ },
133
+ getRun: (c) => runView(c.deps, c.params.id as string),
134
+ // shortcut: reads every event per page, upgrade past ~50k events
135
+ getRunTimeline: (c) => {
136
+ const id = c.params.id as string;
137
+ if (runView(c.deps, id).engine === "1.5") return { items: [], total: 0 };
138
+ return page(
139
+ runTimeline(id, 1, Number.MAX_SAFE_INTEGER).toReversed(),
140
+ c.query as { limit: number; offset: number },
141
+ );
142
+ },
143
+ cancelRun: (c) => cancelRun(c.deps, { run: c.params.id as string }),
144
+ answerUnit: (c) =>
145
+ answerUnit(c.deps, {
146
+ run: c.params.id as string,
147
+ ...(c.body as z.infer<typeof AnswerBodySchema>),
148
+ }),
149
+ listQuestions: (c) => {
150
+ const q = c.query as z.infer<typeof QuestionsQuerySchema>;
151
+ return page(
152
+ parkedQuestions({ run: q.run }).filter((p) => has([p.question, p.unit, p.runTitle], q.q)),
153
+ q,
154
+ );
155
+ },
156
+ };
157
+
158
+ const compile = (op: Operation): RegExp => new RegExp(`^${op.path.replace(/\{(\w+)\}/g, "(?<$1>[^/]+)")}$`);
159
+ const ROUTES = OPERATIONS.map((op) => ({ op, re: compile(op) }));
160
+ const LIVE = /^\/api\/runs\/([^/]+)\/live$/;
161
+
162
+ /** The Host this panel answers to, or null. A browser omits the default port, so port 80 also takes the bare name. */
163
+ export const panelHost = (header: string | null, port: number): string | null => {
164
+ const names = ["127.0.0.1", "localhost"];
165
+ const ok = names.flatMap((n) => (port === 80 ? [n, `${n}:80`] : [`${n}:${port}`]));
166
+ return header !== null && ok.includes(header) ? header : null;
167
+ };
168
+ /** A request without an Origin passes; one with it must come from the page this Host serves. */
169
+ export const panelOriginOk = (origin: string | null, host: string): boolean =>
170
+ origin === null || origin === `http://${host}`;
171
+
172
+ interface SocketData {
173
+ run: string;
174
+ from: number;
175
+ session: string;
176
+ stop?: () => void;
177
+ }
178
+
179
+ /** One Bun process on 127.0.0.1: the built files, the JSON API and a socket per run tailing its events. */
180
+ export function servePanel(o: PanelOptions): Panel {
181
+ const sessions = new Set<string>();
182
+ const sockets = new Set<ServerWebSocket<SocketData>>();
183
+ /** Ends a session and the run sockets it opened: a socket outlives its cookie otherwise. */
184
+ const revoke = (session: string): void => {
185
+ sessions.delete(session);
186
+ for (const ws of sockets) if (ws.data.session === session) ws.close(4401, "signed out");
187
+ };
188
+ const dist = resolve(o.distDir);
189
+ let origin = "";
190
+ let signInPage: ReturnType<typeof writeRedirectPage> | undefined;
191
+ const dropSignInPage = (): void => {
192
+ signInPage?.remove();
193
+ signInPage = undefined;
194
+ };
195
+
196
+ const hostOk = (req: Request): string | null => panelHost(req.headers.get("host"), server.port as number);
197
+ const originOk = (req: Request, host: string): boolean => panelOriginOk(req.headers.get("origin"), host);
198
+ const signedIn = (req: Request): boolean => {
199
+ const c = cookieOf(req);
200
+ return c !== undefined && sessions.has(c);
201
+ };
202
+
203
+ const staticFile = (req: Request, rawPath: string): Response => {
204
+ let path: string;
205
+ try {
206
+ path = decodeURIComponent(rawPath);
207
+ } catch {
208
+ return errorResponse("not_found", "not found");
209
+ }
210
+ const file = resolve(dist, `.${path}`);
211
+ if (file !== dist && !file.startsWith(dist + sep)) return errorResponse("not_found", "not found");
212
+ const served = existsSync(file) && statSync(file).isFile() ? file : join(dist, "index.html");
213
+ const accepts = req.headers.get("accept-encoding") ?? "";
214
+ for (const [enc, ext] of [
215
+ ["br", ".br"],
216
+ ["gzip", ".gz"],
217
+ ] as const)
218
+ if (new RegExp(`\\b${enc}\\b`).test(accepts) && existsSync(served + ext))
219
+ return new Response(Bun.file(served + ext), {
220
+ headers: {
221
+ ...cache(served),
222
+ "Content-Type": Bun.file(served).type,
223
+ "Content-Encoding": enc,
224
+ Vary: "Accept-Encoding",
225
+ },
226
+ });
227
+ return new Response(Bun.file(served), {
228
+ headers: { ...cache(served), Vary: "Accept-Encoding" },
229
+ });
230
+ };
231
+ const cache = (file: string): Record<string, string> => ({
232
+ "Cache-Control": file.startsWith(join(dist, "assets") + sep)
233
+ ? "public, max-age=31536000, immutable"
234
+ : "no-cache",
235
+ });
236
+
237
+ const api = async (req: Request, url: URL, host: string): Promise<Response> => {
238
+ const hit = ROUTES.map((r) => ({ ...r, m: r.re.exec(url.pathname) })).find(
239
+ (r) => r.m && r.op.method === req.method,
240
+ );
241
+ if (!hit?.m) return errorResponse("not_found", "not found");
242
+ const { op } = hit;
243
+ if (!op.public && !signedIn(req)) return errorResponse("unauthorized", "sign in first");
244
+ if (op.method !== "GET" && !originOk(req, host))
245
+ return errorResponse("forbidden", "cross-origin request refused");
246
+ let params: Record<string, string>;
247
+ try {
248
+ params = Object.fromEntries(
249
+ Object.entries(hit.m.groups ?? {}).map(([k, v]) => [k, decodeURIComponent(v)]),
250
+ );
251
+ } catch {
252
+ return errorResponse("E_INPUT_INVALID", "the request path has a malformed escape");
253
+ }
254
+ let query: unknown;
255
+ let body: unknown;
256
+ if (op.query) {
257
+ const r = op.query.safeParse(Object.fromEntries(url.searchParams));
258
+ if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
259
+ query = r.data;
260
+ }
261
+ if (op.body) {
262
+ let raw: unknown;
263
+ try {
264
+ raw = await req.json();
265
+ } catch {
266
+ return errorResponse("E_INPUT_INVALID", "the request body is not valid JSON");
267
+ }
268
+ const r = op.body.safeParse(raw);
269
+ if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
270
+ body = r.data;
271
+ }
272
+ try {
273
+ if (op.id === "login") {
274
+ if (!sameToken((body as z.infer<typeof LoginBodySchema>).token, o.token))
275
+ return errorResponse("unauthorized", "wrong token");
276
+ const session = randomBytes(32).toString("base64url");
277
+ sessions.add(session);
278
+ dropSignInPage();
279
+ return new Response(null, {
280
+ status: 204,
281
+ headers: {
282
+ "Set-Cookie": `${PANEL_COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`,
283
+ },
284
+ });
285
+ }
286
+ if (op.id === "logout") {
287
+ const c = cookieOf(req);
288
+ if (c) revoke(c);
289
+ return new Response(null, {
290
+ status: 204,
291
+ headers: {
292
+ "Set-Cookie": `${PANEL_COOKIE}=; Max-Age=0; HttpOnly; SameSite=Strict; Path=/`,
293
+ },
294
+ });
295
+ }
296
+ const out = await HANDLERS[op.id]({
297
+ deps: o.deps,
298
+ repo: o.repo,
299
+ params,
300
+ query,
301
+ body,
302
+ });
303
+ return op.response === null
304
+ ? new Response(null, { status: 204 })
305
+ : Response.json(op.response.parse(out));
306
+ } catch (e) {
307
+ if (isCatherdError(e)) return errorResponse(e.code, e.message, e.fix);
308
+ return errorResponse("internal", errorMessage(e));
309
+ }
310
+ };
311
+
312
+ const fetch = async (req: Request, srv: Bun.Server<SocketData>): Promise<Response | undefined> => {
313
+ const host = hostOk(req);
314
+ if (host === null) return withHeaders(errorResponse("forbidden", "this panel answers on 127.0.0.1 only"));
315
+ // the raw target, not URL's normalised one: a `%2e%2e` segment must reach the path check
316
+ const target = req.url.replace(/^[a-z]+:\/\/[^/]+/i, "");
317
+ const [rawPath = "/"] = target.split("?");
318
+ const url = new URL(req.url);
319
+ const live = LIVE.exec(url.pathname);
320
+ if (live && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
321
+ const session = cookieOf(req);
322
+ if (session === undefined || !sessions.has(session))
323
+ return withHeaders(errorResponse("unauthorized", "sign in first"));
324
+ if (!originOk(req, host)) return withHeaders(errorResponse("forbidden", "cross-origin socket refused"));
325
+ let id: string;
326
+ try {
327
+ id = decodeURIComponent(live[1] as string);
328
+ } catch {
329
+ return withHeaders(errorResponse("E_INPUT_INVALID", "the request path has a malformed escape"));
330
+ }
331
+ try {
332
+ findRun(id);
333
+ } catch (e) {
334
+ return withHeaders(
335
+ isCatherdError(e)
336
+ ? errorResponse(e.code, e.message, e.fix)
337
+ : errorResponse("internal", errorMessage(e)),
338
+ );
339
+ }
340
+ // no `from`: only what is appended from now on (the page loads the history over HTTP)
341
+ const asked = url.searchParams.get("from");
342
+ const from =
343
+ asked === null
344
+ ? (readEvents(findRun(id)).at(-1)?.seq ?? 0) + 1
345
+ : Math.max(1, Number.parseInt(asked, 10) || 1);
346
+ if (srv.upgrade(req, { data: { run: id, from, session } })) return undefined;
347
+ return withHeaders(errorResponse("E_INPUT_INVALID", "not a websocket upgrade"));
348
+ }
349
+ if (url.pathname.startsWith("/api/")) return withHeaders(await api(req, url, host));
350
+ return withHeaders(staticFile(req, rawPath));
351
+ };
352
+
353
+ const withHeaders = (r: Response): Response => {
354
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) r.headers.set(k, v);
355
+ return r;
356
+ };
357
+
358
+ let server: Bun.Server<SocketData>;
359
+ try {
360
+ server = Bun.serve<SocketData>({
361
+ hostname: "127.0.0.1",
362
+ port: o.port,
363
+ fetch,
364
+ websocket: {
365
+ open(ws: ServerWebSocket<SocketData>) {
366
+ sockets.add(ws);
367
+ ws.data.stop = tailEvents(
368
+ findRun(ws.data.run),
369
+ ws.data.from,
370
+ (e) =>
371
+ ws.send(
372
+ JSON.stringify({
373
+ seq: e.seq,
374
+ at: e.at,
375
+ type: e.type,
376
+ text: describeEvent(e),
377
+ }),
378
+ ),
379
+ { pollMs: o.pollMs },
380
+ );
381
+ },
382
+ close(ws: ServerWebSocket<SocketData>) {
383
+ sockets.delete(ws);
384
+ ws.data.stop?.();
385
+ },
386
+ // a `ping` frame keeps the tab's socket warm and means nothing
387
+ message() {},
388
+ },
389
+ });
390
+ } catch (e) {
391
+ if (/in use|EADDRINUSE/i.test(errorMessage(e)))
392
+ throw new CatherdError("E_INPUT_INVALID", `port ${o.port} is in use`, {
393
+ fix: "catherd panel --port 0 (any free port)",
394
+ });
395
+ throw e;
396
+ }
397
+ origin = `http://127.0.0.1:${server.port}`;
398
+ if (o.open) {
399
+ signInPage = writeRedirectPage(`${origin}/login#token=${o.token}`);
400
+ (o.opener ?? openUrl)(signInPage.path);
401
+ }
402
+ return {
403
+ url: origin,
404
+ port: server.port as number,
405
+ stop: async () => {
406
+ dropSignInPage();
407
+ await server.stop(true);
408
+ },
409
+ };
410
+ }
@@ -0,0 +1,68 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { defineCommand } from "citty";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { gitToplevel } from "../infra/git.ts";
6
+ import { defaultDeps } from "./deps.ts";
7
+ import { refuseInRole } from "./cli-kit.ts";
8
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
9
+ import { type Panel, newToken, servePanel } from "./panel/server.ts";
10
+
11
+ /** The seams a test replaces: the server, and what keeps `catherd panel` alive until Ctrl-C. */
12
+ export const panelServer = {
13
+ serve: servePanel as (o: Parameters<typeof servePanel>[0]) => Panel,
14
+ untilStopped: (): Promise<void> =>
15
+ new Promise((done) => {
16
+ for (const sig of ["SIGINT", "SIGTERM"] as const) process.once(sig, () => done());
17
+ }),
18
+ };
19
+
20
+ /** The built panel: `CATHERD_PANEL_DIST`, else the `web/dist` the package ships. */
21
+ export const panelDist = (): string =>
22
+ process.env.CATHERD_PANEL_DIST ?? join(import.meta.dir, "../../web/dist");
23
+
24
+ /** `catherd panel`'s run, and a bare `catherd`'s: serves the panel until Ctrl-C. */
25
+ export async function runPanel(o: { port: number; open: boolean; host?: string }): Promise<void> {
26
+ refuseInRole(process.env, "catherd panel");
27
+ const distDir = panelDist();
28
+ if (!existsSync(join(distDir, "index.html")))
29
+ throw new CatherdError("E_CONFIG_INVALID", "the panel is not built", { fix: "bun run panel:build" });
30
+ const token = newToken();
31
+ const panel = panelServer.serve({
32
+ deps: defaultDeps(terminalHost(o.host)),
33
+ repo: await gitToplevel(process.cwd()),
34
+ token,
35
+ port: o.port,
36
+ distDir,
37
+ open: o.open,
38
+ });
39
+ console.log(`catherd panel: ${panel.url}/login#token=${token}`);
40
+ console.log("Ctrl-C stops it.");
41
+ await panelServer.untilStopped();
42
+ await panel.stop();
43
+ }
44
+
45
+ /** `catherd panel [--port <n>] [--no-open]`: Setup, Runs and Questions in your browser, on 127.0.0.1 only. */
46
+ export const panelCommand = defineCommand({
47
+ meta: {
48
+ name: "panel",
49
+ description: "Serve the panel on 127.0.0.1 and open it in your browser (a bare catherd does the same)",
50
+ },
51
+ args: {
52
+ port: { type: "string", default: "4317", description: "the port (0 picks a free one)" },
53
+ open: {
54
+ type: "boolean",
55
+ default: true,
56
+ description: "open the browser (--no-open prints the link only)",
57
+ },
58
+ ...HOST_ARG,
59
+ },
60
+ async run({ args }) {
61
+ const port = Number(args.port);
62
+ if (!/^\d+$/.test(args.port) || port > 65535)
63
+ throw new CatherdError("E_INPUT_INVALID", `--port takes a number from 0 to 65535, not "${args.port}"`, {
64
+ fix: "catherd panel --port 4317",
65
+ });
66
+ await runPanel({ port, open: args.open, host: args.host });
67
+ },
68
+ });
@@ -3,11 +3,11 @@ import { HOST_ARG, terminalHost } from "./host-arg.ts";
3
3
  import { assertProfileName } from "../domain/profile.ts";
4
4
  import { defineCommand } from "citty";
5
5
  import type { Catalog } from "../domain/catalog.ts";
6
- import { rungInfo } from "../domain/catalog.ts";
6
+ import { pairOf } from "../domain/catalog.ts";
7
7
  import { CatherdError } from "../domain/errors.ts";
8
8
  import type { OrchestrationHost } from "../domain/host.ts";
9
9
  import { ProfileDocSchema, type Change, patchAt, type Profile } from "../domain/profile.ts";
10
- import { inferredScores, type Issue } from "../domain/profile-rules.ts";
10
+ import type { Issue } from "../domain/profile-rules.ts";
11
11
  import { ROLES, type Role } from "../domain/roles.ts";
12
12
  import { gitToplevel } from "../infra/git.ts";
13
13
  import { loadCatalog } from "../services/catalog-service.ts";
@@ -41,21 +41,17 @@ const json = JSON_ARG;
41
41
  export interface StandIn {
42
42
  from: string;
43
43
  to: string;
44
- inferred: boolean;
45
- via: string | null;
46
- /** what the treat-like lends, as `profile show` says it */
44
+ /** `no Slopalytics index` when the stand-in is not indexed, so routing skips it */
47
45
  note: string | null;
48
46
  }
49
47
 
50
- /** Spec §7.2: each stand-in; `via` names the rung whose scores it borrows (spec 1.1 §11). */
48
+ /** Spec §7.2: each stand-in, noting one that has no Slopalytics index. */
51
49
  export function standIns(p: Profile, c: Catalog): StandIn[] {
52
- return Object.entries(p.failover).map(([from, to]) => {
53
- try {
54
- return { from, to, ...inferredScores(c, rungInfo(c, to)) };
55
- } catch {
56
- return { from, to, inferred: false, via: null, note: null };
57
- }
58
- });
50
+ return Object.entries(p.failover).map(([from, to]) => ({
51
+ from,
52
+ to,
53
+ note: pairOf(c, to) === null ? "no Slopalytics index" : null,
54
+ }));
59
55
  }
60
56
 
61
57
  const value = (v: unknown) => (v === null ? "none" : typeof v === "string" ? v : JSON.stringify(v));
@@ -198,7 +194,7 @@ const show = defineCommand({
198
194
  const o = {
199
195
  active: name === here,
200
196
  enforcement: roleEnforcement(p),
201
- standIns: standIns(p, loadCatalog({ timings: false })),
197
+ standIns: standIns(p, loadCatalog()),
202
198
  };
203
199
  if (args.json) return printJson({ profile: p, ...o });
204
200
  for (const l of formatProfile(p, { ...o, backends: runnableBackends() })) console.log(l);
@@ -3,6 +3,8 @@ import { CatherdError } from "../domain/errors.ts";
3
3
  import { ID_PATTERN } from "../domain/ids.ts";
4
4
  import { roleScopeFromEnv } from "../domain/role-scope.ts";
5
5
  import { gateCheckOrList, gatePass } from "../services/gate-service.ts";
6
+ import { DISPATCH_ID_ENV, activityReporter } from "../infra/lock-activity.ts";
7
+ import { runMilestoneCheck } from "../services/milestone-check.ts";
6
8
  import { assertRoleMay } from "../services/role-access.ts";
7
9
  import { readRunFile, writeRunFile } from "../services/run-service.ts";
8
10
  import { printJson } from "./cli-kit.ts";
@@ -10,7 +12,7 @@ import { defaultDeps } from "./deps.ts";
10
12
 
11
13
  /**
12
14
  * Spec 1.5 plan 21: the role server's operations as commands any role can run from its shell, on a backend
13
- * whose harness has no role server (opencode, Cursor, Grok Build, agy) or when no MCP tool is listed. In a role
15
+ * whose harness has no role server (opencode, Cursor) or when no MCP tool is listed. In a role
14
16
  * (CATHERD_ROLE, or its scratch TMPDIR) they are bound to its own run and its role's operations.
15
17
  */
16
18
  const scope = () => roleScopeFromEnv(process.env);
@@ -109,7 +111,49 @@ const pass = defineCommand({
109
111
  },
110
112
  });
111
113
 
114
+ const runCheck = defineCommand({
115
+ meta: {
116
+ name: "run",
117
+ description: "Run the milestone's full check on the committed HEAD, detached (gate_run)",
118
+ },
119
+ args: {
120
+ run: GATE.run,
121
+ milestone: { type: "string", required: true, description: "the milestone you verify, like M1" },
122
+ command: GATE.command,
123
+ at: { type: "string", description: "a checkout of the run's base, to record the base's own failures" },
124
+ force: { type: "boolean", description: "run it again although a record of this HEAD exists" },
125
+ },
126
+ async run({ args }) {
127
+ assertRoleMay(scope(), args.run, "gate_run");
128
+ if (!ID_PATTERN.test(args.milestone))
129
+ throw new CatherdError("E_INPUT_INVALID", `bad milestone "${args.milestone}"`, {
130
+ fix: "pass --milestone M1",
131
+ });
132
+ // a shell tool call may be cut off; the check goes on, and this ticks the dispatch's activity while it waits
133
+ const reporter = activityReporter(process.env[DISPATCH_ID_ENV]);
134
+ try {
135
+ const r = await runMilestoneCheck(
136
+ {
137
+ run: args.run,
138
+ milestone: args.milestone,
139
+ command: args.command,
140
+ ...(args.at ? { at: args.at } : {}),
141
+ ...(args.force ? { force: true } : {}),
142
+ },
143
+ reporter ? { onWait: reporter.tick } : {},
144
+ );
145
+ printJson(r);
146
+ if (!r.running && r.missing.length > 0) process.exitCode = 1;
147
+ } finally {
148
+ reporter?.done();
149
+ }
150
+ },
151
+ });
152
+
112
153
  export const gateCommand = defineCommand({
113
- meta: { name: "gate", description: "A verifier's gate evidence: check an item, record a pass" },
114
- subCommands: { check, pass },
154
+ meta: {
155
+ name: "gate",
156
+ description: "A verifier's gate evidence: check an item, record a pass, run the check",
157
+ },
158
+ subCommands: { check, pass, run: runCheck },
115
159
  });
@@ -149,36 +149,20 @@ export function redrawMs(interval: string | undefined): number {
149
149
  return Math.max(1, secs) * 1000;
150
150
  }
151
151
 
152
- /** Spec §8 `catherd watch [--once]`: the TUI's Runs tab on a terminal, else a plain redraw. */
152
+ /** Spec §8 `catherd watch [--once]`: the live runs redrawn as text. */
153
153
  export const watchCommand = defineCommand({
154
154
  meta: {
155
155
  name: "watch",
156
- description:
157
- "The live runs: the dashboard's Runs tab on a terminal, else redrawn as text (--once: print once)",
156
+ description: "The live runs, redrawn as text (--once: print once); catherd panel shows them in a browser",
158
157
  },
159
158
  args: {
160
159
  once: { type: "boolean", description: "print one snapshot and exit" },
161
- interval: { type: "string", description: "seconds between text redraws when piped (default 2)" },
162
- plain: {
163
- type: "boolean",
164
- description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
165
- },
166
- "reduced-motion": { type: "boolean", description: "the dashboard without animation" },
160
+ interval: { type: "string", description: "seconds between text redraws (default 2)" },
167
161
  ...json,
168
162
  },
169
163
  async run({ args }) {
170
164
  const every = redrawMs(args.interval);
171
165
  if (args.once || args.json) return printStatus(undefined, args.json === true);
172
- // on a terminal, watch is the dashboard's Runs tab (plan 6 Ruling 10); piped output keeps the plain view
173
- if (process.stdin.isTTY && process.stdout.isTTY) {
174
- const { openTui } = await import("./tui/run.tsx");
175
- const rawArgs = [
176
- ...(args.plain ? ["--plain"] : []),
177
- ...(args["reduced-motion"] ? ["--reduced-motion"] : []),
178
- ];
179
- process.exitCode = await openTui({ rawArgs, tab: "runs", tty: true });
180
- return;
181
- }
182
166
  for (;;) {
183
167
  if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
184
168
  await printStatus(undefined, false, true);