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,174 @@
1
+ import { closeSync, existsSync, fstatSync, ftruncateSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CatherdError, UPGRADE } from "../domain/errors.ts";
4
+ import { EVENTS_SCHEMA, RunEventSchema, type RunEvent, type RunEventInput } from "../domain/run-events.ts";
5
+ import { type RunState, replay } from "../domain/run-state.ts";
6
+ import { withFileLock } from "../infra/filelock.ts";
7
+ import { log } from "../infra/log.ts";
8
+ import { appendJsonlBatch } from "../infra/store.ts";
9
+ import type { Run } from "./run-store.ts";
10
+
11
+ /** `<run>/events.jsonl`: one fact per line, dense `seq`, the only state a 2.0 run keeps. */
12
+ export const eventsFile = (run: Run): string => join(run.dir, "events.jsonl");
13
+
14
+ /** A 2.0 run has an events.jsonl; a run without one is a 1.5 run, and the daemon never drives it. */
15
+ export const isDaemonRun = (run: Run): boolean => existsSync(eventsFile(run));
16
+
17
+ const corrupt = (run: Run, after: number, why: string): CatherdError =>
18
+ new CatherdError("E_RUN_CORRUPT", `${eventsFile(run)} is corrupt after seq ${after}: ${why}`, {
19
+ fix: `restore events.jsonl from a backup, or finish run ${run.id} with catherd 1.x`,
20
+ });
21
+
22
+ const tooOld = (run: Run, v: unknown): CatherdError =>
23
+ new CatherdError(
24
+ "E_RUNTIME_TOO_OLD",
25
+ `${eventsFile(run)} holds events of version ${String(v)}, newer than this catherd (${EVENTS_SCHEMA})`,
26
+ { fix: `upgrade catherd: ${UPGRADE}` },
27
+ );
28
+
29
+ /** One line as an event: a newer `v` is too old a catherd, anything else invalid is corrupt (null here). */
30
+ function decode(run: Run, line: string): RunEvent | null {
31
+ let raw: unknown;
32
+ try {
33
+ raw = JSON.parse(line);
34
+ } catch {
35
+ return null;
36
+ }
37
+ const v = (raw as { v?: unknown } | null)?.v;
38
+ if (typeof v === "number" && v > EVENTS_SCHEMA) throw tooOld(run, v);
39
+ const r = RunEventSchema.safeParse(raw);
40
+ return r.success ? r.data : null;
41
+ }
42
+
43
+ /**
44
+ * The run's events from `fromSeq` (1 reads them all and checks every line). A last line without its newline
45
+ * that does not parse is a torn append: skipped and logged. Any other line that does not parse, or a seq that
46
+ * is not the line's number, refuses the read (E_RUN_CORRUPT, naming the seq before it); a newer `v`, too.
47
+ * Lines before `fromSeq` are skipped unread: the caller replayed them already.
48
+ */
49
+ export function readEvents(run: Run, fromSeq = 1): RunEvent[] {
50
+ let text: string;
51
+ try {
52
+ text = readFileSync(eventsFile(run), "utf8");
53
+ } catch {
54
+ return [];
55
+ }
56
+ const lines = text.split("\n");
57
+ const tail = lines.pop() ?? "";
58
+ if (tail) lines.push(tail);
59
+ const out: RunEvent[] = [];
60
+ for (const [i, line] of lines.entries()) {
61
+ if (i + 1 < fromSeq || !line.trim()) continue;
62
+ const e = decode(run, line);
63
+ if (e === null) {
64
+ if (i === lines.length - 1 && line === tail) {
65
+ log("warn", "events", { run: run.id, torn: i + 1 });
66
+ break;
67
+ }
68
+ throw corrupt(run, i, "a line is not an event");
69
+ }
70
+ if (e.seq !== i + 1) throw corrupt(run, i, `seq ${e.seq} stands where ${i + 1} belongs`);
71
+ out.push(e);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export const replayRun = (run: Run): RunState => replay(readEvents(run), run.id);
77
+
78
+ /** The last seq of the file; a torn tail is cut off first, so the next line starts clean. Under the file's lock. */
79
+ function lastSeq(run: Run): number {
80
+ const file = eventsFile(run);
81
+ if (!existsSync(file)) return 0;
82
+ const fd = openSync(file, "r+");
83
+ try {
84
+ const size = fstatSync(fd).size;
85
+ let chunk = 64 * 1024;
86
+ for (;;) {
87
+ const start = Math.max(0, size - chunk);
88
+ const buf = Buffer.alloc(size - start);
89
+ readSync(fd, buf, 0, buf.length, start);
90
+ const text = buf.toString("utf8");
91
+ const cut = text.lastIndexOf("\n");
92
+ const tail = text.slice(cut + 1);
93
+ const body = text.slice(0, cut + 1).trimEnd();
94
+ // the whole last complete line is in the chunk, or the chunk is the whole file
95
+ if (start > 0 && !body.includes("\n")) {
96
+ chunk *= 4;
97
+ continue;
98
+ }
99
+ if (tail && decode(run, tail) === null) {
100
+ // a torn append: not an event, never will be one
101
+ ftruncateSync(fd, size - Buffer.byteLength(tail));
102
+ log("warn", "events", { run: run.id, tornTail: Buffer.byteLength(tail) });
103
+ }
104
+ const line = tail && decode(run, tail) !== null ? tail : body.slice(body.lastIndexOf("\n") + 1);
105
+ if (!line) return 0;
106
+ const last = decode(run, line);
107
+ if (!last) throw corrupt(run, 0, "its last line is not an event");
108
+ return last.seq;
109
+ }
110
+ } finally {
111
+ closeSync(fd);
112
+ }
113
+ }
114
+
115
+ /** Runs `fn` under the events file's lock, handing it an append that does not take the lock again. */
116
+ export function withEventsLock<T>(
117
+ run: Run,
118
+ fn: (add: (e: RunEventInput, now?: Date) => RunEvent) => T | Promise<T>,
119
+ ): Promise<T> {
120
+ return withFileLock(eventsFile(run), () => fn((e, now) => writeEvents(run, [e], now)[0] as RunEvent));
121
+ }
122
+
123
+ /** The events with the next dense seqs, validated all before the one write that appends them. */
124
+ function writeEvents(run: Run, es: RunEventInput[], now: Date = new Date()): RunEvent[] {
125
+ const first = lastSeq(run) + 1;
126
+ const out = es.map((e, i) =>
127
+ RunEventSchema.parse({ v: EVENTS_SCHEMA, seq: first + i, at: now.toISOString(), ...e }),
128
+ );
129
+ appendJsonlBatch(eventsFile(run), out);
130
+ return out;
131
+ }
132
+
133
+ /** Appends one event under the file's lock, with the next dense `seq`, and returns it as written. */
134
+ export async function appendEvent(run: Run, e: RunEventInput, now: Date = new Date()): Promise<RunEvent> {
135
+ return withEventsLock(run, (add) => add(e, now));
136
+ }
137
+
138
+ /** Appends events that belong together in one write under the file's lock: all of them or, on a crash, a torn tail. */
139
+ export function appendEvents(run: Run, es: RunEventInput[], now: Date = new Date()): Promise<RunEvent[]> {
140
+ return withFileLock(eventsFile(run), () => writeEvents(run, es, now));
141
+ }
142
+
143
+ /**
144
+ * Calls `onEvent` with each event after `fromSeq - 1`, in order, as they are appended, until the returned stop
145
+ * is called. A poll that finds the file unreadable waits for the next one.
146
+ */
147
+ export function tailEvents(
148
+ run: Run,
149
+ fromSeq: number,
150
+ onEvent: (e: RunEvent) => void,
151
+ o: { pollMs?: number } = {},
152
+ ): () => void {
153
+ let next = fromSeq;
154
+ let stopped = false;
155
+ let timer: ReturnType<typeof setTimeout> | null = null;
156
+ const poll = (): void => {
157
+ if (stopped) return;
158
+ try {
159
+ for (const e of readEvents(run, next)) {
160
+ if (stopped) return;
161
+ next = e.seq + 1;
162
+ onEvent(e);
163
+ }
164
+ } catch (err) {
165
+ log("warn", "events", { run: run.id, tail: err instanceof Error ? err.message : String(err) });
166
+ }
167
+ if (!stopped) timer = setTimeout(poll, o.pollMs ?? 500);
168
+ };
169
+ poll();
170
+ return () => {
171
+ stopped = true;
172
+ if (timer) clearTimeout(timer);
173
+ };
174
+ }
@@ -17,7 +17,7 @@ export interface Evidence {
17
17
  fails: number;
18
18
  }
19
19
 
20
- /** `<canonical rung>|<kind or *>` → its evidence. Shown by route, catalog list and the TUI; never routes. */
20
+ /** `<canonical rung>|<kind or *>` → its evidence. Shown by route and catalog list; never routes. */
21
21
  export type EvidenceTable = Record<string, Evidence>;
22
22
 
23
23
  const VERDICT_FAIL = /^VERDICT: FAIL\b/;
@@ -0,0 +1,221 @@
1
+ import { z } from "zod";
2
+ import { errorMessage } from "../domain/errors.ts";
3
+ import { PARK_REASONS, describeEvent } from "../domain/run-events.ts";
4
+ import { type RunState, liveDispatches } from "../domain/run-state.ts";
5
+ import { log } from "../infra/log.ts";
6
+ import { daemonAlive, ensureDaemon, readHeartbeat } from "./daemon.ts";
7
+ import { liveDispatches as liveDispatchesOf } from "./dispatches.ts";
8
+ import { landedMilestones } from "./milestones.ts";
9
+ import type { Deps } from "./ports.ts";
10
+ import { openQuestions } from "./questions.ts";
11
+ import { isDaemonRun, readEvents, replayRun, tailEvents } from "./run-events-store.ts";
12
+ import { findRun, listRuns, type Run } from "./run-store.ts";
13
+
14
+ /**
15
+ * The read side of a run, for the panel (plan 31) and the session's `status`: each shape has a zod
16
+ * schema, which is its contract. A 1.5 run reads as `engine: "1.5"`, with what its folder says.
17
+ */
18
+ export const ParkedViewSchema = z.object({
19
+ run: z.string(),
20
+ runTitle: z.string(),
21
+ unit: z.string(),
22
+ question: z.string(),
23
+ reason: z.enum(PARK_REASONS),
24
+ at: z.string(),
25
+ /** the command that answers it */
26
+ command: z.string(),
27
+ });
28
+ export type ParkedView = z.infer<typeof ParkedViewSchema>;
29
+
30
+ export const TimelineEntrySchema = z.object({
31
+ seq: z.number().int(),
32
+ at: z.string(),
33
+ type: z.string(),
34
+ text: z.string(),
35
+ });
36
+ export type TimelineEntry = z.infer<typeof TimelineEntrySchema>;
37
+
38
+ export const RunViewSchema = z.object({
39
+ id: z.string(),
40
+ title: z.string(),
41
+ repo: z.string(),
42
+ engine: z.enum(["2.0", "1.5"]),
43
+ phase: z.enum(["planning", "approval", "running", "ended"]).nullable(),
44
+ outcome: z.enum(["done", "cancelled", "failed"]).nullable(),
45
+ daemon: z.object({
46
+ alive: z.boolean(),
47
+ pid: z.number().int().nullable(),
48
+ beatAgeSec: z.number().nullable(),
49
+ }),
50
+ milestones: z.array(
51
+ z.object({
52
+ id: z.string(),
53
+ title: z.string(),
54
+ status: z.string(),
55
+ round: z.number().int(),
56
+ commit: z.string().nullable(),
57
+ }),
58
+ ),
59
+ live: z.array(
60
+ z.object({ name: z.string(), role: z.string(), rung: z.string(), unit: z.string(), since: z.string() }),
61
+ ),
62
+ parked: z.array(ParkedViewSchema),
63
+ holds: z.array(z.object({ quota: z.string(), until: z.string() })),
64
+ /** the latest event, in one line */
65
+ last: z.string().nullable(),
66
+ startedAt: z.string(),
67
+ updatedAt: z.string(),
68
+ });
69
+ export type RunView = z.infer<typeof RunViewSchema>;
70
+
71
+ export { tailEvents };
72
+
73
+ const answerCommand = (run: string, unit: string): string => `catherd answer ${run} ${unit} "<answer>"`;
74
+
75
+ function parkedOf(run: Run, s: RunState): ParkedView[] {
76
+ return Object.values(s.questions).map((q) => ({
77
+ run: run.id,
78
+ runTitle: run.meta.title,
79
+ unit: q.unit,
80
+ question: q.question,
81
+ reason: q.reason,
82
+ at: q.at,
83
+ command: answerCommand(run.id, q.unit),
84
+ }));
85
+ }
86
+
87
+ /** A daemon of an unfinished 2.0 run is started again when it is dead; a failure to start never fails a read. */
88
+ function revive(run: Run): void {
89
+ try {
90
+ ensureDaemon(run);
91
+ } catch (e) {
92
+ log("warn", "daemon", { run: run.id, revive: errorMessage(e) });
93
+ }
94
+ }
95
+
96
+ function view15(deps: Deps, run: Run): RunView {
97
+ const now = deps.now();
98
+ return {
99
+ id: run.id,
100
+ title: run.meta.title,
101
+ repo: run.meta.repo,
102
+ engine: "1.5",
103
+ phase: null,
104
+ outcome: null,
105
+ daemon: { alive: false, pid: null, beatAgeSec: null },
106
+ milestones: landedMilestones(run).map((id) => ({
107
+ id,
108
+ title: id,
109
+ status: "landed",
110
+ round: 0,
111
+ commit: null,
112
+ })),
113
+ live: liveDispatchesOf(run, now).map((d) => ({
114
+ name: d.admit.name,
115
+ role: d.admit.role,
116
+ rung: d.admit.rung,
117
+ unit: d.admit.lane ?? d.admit.name,
118
+ since: d.admit.admittedAt,
119
+ })),
120
+ parked: openQuestions(run).map((q) => ({
121
+ run: run.id,
122
+ runTitle: run.meta.title,
123
+ unit: q.milestone,
124
+ question: q.question,
125
+ reason: "needs_user" as const,
126
+ at: q.at,
127
+ command: `answer(run: "${run.id}", milestone: "${q.milestone}", answer: "<answer>")`,
128
+ })),
129
+ holds: [],
130
+ last: null,
131
+ startedAt: run.meta.createdAt,
132
+ updatedAt: run.meta.createdAt,
133
+ };
134
+ }
135
+
136
+ /** One run at a glance. Reading an unfinished 2.0 run starts its daemon again if it died. */
137
+ export function runView(deps: Deps, runId: string): RunView {
138
+ const run = findRun(runId);
139
+ if (!isDaemonRun(run)) return view15(deps, run);
140
+ revive(run);
141
+ const s = replayRun(run);
142
+ const beat = readHeartbeat(run);
143
+ const events = readEvents(run);
144
+ const last = events.at(-1);
145
+ return {
146
+ id: run.id,
147
+ title: run.meta.title,
148
+ repo: run.meta.repo,
149
+ engine: "2.0",
150
+ phase: s.phase,
151
+ outcome: s.outcome,
152
+ daemon: {
153
+ alive: daemonAlive(run),
154
+ pid: beat?.pid ?? null,
155
+ beatAgeSec: beat ? Math.max(0, Math.round((deps.now() - Date.parse(beat.beatAt)) / 1000)) : null,
156
+ },
157
+ milestones: s.milestones.map((m) => ({
158
+ id: m.id,
159
+ title: m.title,
160
+ status: m.status,
161
+ round: m.round,
162
+ commit: m.commit,
163
+ })),
164
+ live: liveDispatches(s).map((d) => ({
165
+ name: d.name,
166
+ role: d.role,
167
+ rung: d.rung,
168
+ unit: d.unit,
169
+ since: d.startedAt,
170
+ })),
171
+ parked: parkedOf(run, s),
172
+ holds: Object.entries(s.holds).map(([quota, until]) => ({ quota, until })),
173
+ last: last ? describeEvent(last, (id) => s.dispatches[id]?.rung) : null,
174
+ startedAt: run.meta.createdAt,
175
+ updatedAt: last?.at ?? run.meta.createdAt,
176
+ };
177
+ }
178
+
179
+ /** Every run, newest first (one repo's with `repo`), at most `limit` (default 50). */
180
+ export function listRunViews(deps: Deps, o: { repo?: string; limit?: number } = {}): RunView[] {
181
+ const runs = listRuns(o.repo ? [o.repo] : undefined).runs.slice(0, o.limit ?? 50);
182
+ const out: RunView[] = [];
183
+ for (const r of runs) {
184
+ try {
185
+ out.push(runView(deps, r.id));
186
+ } catch (e) {
187
+ log("warn", "read", { run: r.id, error: errorMessage(e) });
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** The run's events from `fromSeq`, one line each (never a reply's body), at most `limit` (default 200). */
194
+ export function runTimeline(runId: string, fromSeq = 1, limit = 200): TimelineEntry[] {
195
+ const run = findRun(runId);
196
+ if (!isDaemonRun(run)) return [];
197
+ const s = replayRun(run);
198
+ return readEvents(run, fromSeq)
199
+ .slice(0, limit)
200
+ .map((e) => ({
201
+ seq: e.seq,
202
+ at: e.at,
203
+ type: e.type,
204
+ text: describeEvent(e, (id) => s.dispatches[id]?.rung),
205
+ }));
206
+ }
207
+
208
+ /** Every unfinished 2.0 run's open questions (one run's with `run`), each with the command that answers it. */
209
+ export function parkedQuestions(o: { run?: string } = {}): ParkedView[] {
210
+ const runs = o.run ? [findRun(o.run)] : listRuns().runs;
211
+ return runs.flatMap((run) => {
212
+ try {
213
+ if (!isDaemonRun(run)) return [];
214
+ const s = replayRun(run);
215
+ return s.phase === "ended" ? [] : parkedOf(run, s);
216
+ } catch (e) {
217
+ log("warn", "read", { run: run.id, error: errorMessage(e) });
218
+ return [];
219
+ }
220
+ });
221
+ }
@@ -0,0 +1,176 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { JudgeDecision, RuleChange } from "../domain/judge.ts";
4
+ import { type Finding, issueLine, type RunEvent } from "../domain/run-events.ts";
5
+ import { findingKey, type RunState } from "../domain/run-state.ts";
6
+ import { writeTextAtomic } from "../infra/store.ts";
7
+ import { readChecks } from "./milestone-check.ts";
8
+ import { readRecords, type Run } from "./run-store.ts";
9
+
10
+ const read = (file: string): string => {
11
+ try {
12
+ return readFileSync(file, "utf8").trim();
13
+ } catch {
14
+ return "";
15
+ }
16
+ };
17
+
18
+ /** What a judge decided, in one line: the verb's own payload. */
19
+ function said(d: JudgeDecision): string {
20
+ switch (d.verb) {
21
+ case "route":
22
+ return d.routes.map((r) => `${r.finding} -> ${r.to}`).join(", ");
23
+ case "answer":
24
+ return d.answer;
25
+ case "ask_owner":
26
+ case "park":
27
+ return d.question;
28
+ case "rules":
29
+ return `${d.changes.length} change(s) proposed`;
30
+ default:
31
+ return d.why;
32
+ }
33
+ }
34
+
35
+ const quote = (text: string): string => `'${text.replace(/'/g, "'\\''")}'`;
36
+
37
+ /**
38
+ * The `## Rules review` section (plan 34): what the finished run's judge proposed, each change followed by the
39
+ * command that applies it. The run applies none; null when the run had no review (cancelled, failed).
40
+ */
41
+ function rulesSection(failed: boolean, proposals: RuleChange[] | undefined): string[] | null {
42
+ if (failed) return ["## Rules review", "", "No rules review: the judge gave no readable answer.", ""];
43
+ if (!proposals) return null;
44
+ if (proposals.length === 0)
45
+ return ["## Rules review", "", "The judge proposed no change to the standing rules.", ""];
46
+ return [
47
+ "## Rules review",
48
+ "",
49
+ "Standing rules to review: apply any of these with the command under it.",
50
+ ...proposals.flatMap((c, i) => [
51
+ `${i + 1}. ${c.op === "add" ? "ADD" : "REMOVE"} ${c.text}${c.why ? ` — ${c.why}` : ""}`,
52
+ ` \`catherd knowledge ${c.op === "add" ? "add" : "rm"} ${quote(c.text)}\``,
53
+ ]),
54
+ "",
55
+ ];
56
+ }
57
+
58
+ /** The "fixed" part of a milestone's `Rounds:` line shows at most this many findings. */
59
+ export const FIXED_LINES = 15;
60
+
61
+ /**
62
+ * The findings of each review round of milestone `m`: a `commit.made` opens a round, and every ok reviewer end
63
+ * (`reviewer`, `ui-reviewer`, the partial pass `reviewer-<M>-2`) adds to it. A round with no reviewer end is left out.
64
+ */
65
+ export function reviewRounds(events: RunEvent[], m: string): Finding[][] {
66
+ const reviewers = new Set<string>();
67
+ const rounds: Finding[][] = [];
68
+ let open: Finding[] | null = null;
69
+ for (const e of events) {
70
+ if (e.type === "dispatch.started") {
71
+ if ((e.role === "reviewer" || e.role === "ui-reviewer") && e.unit === m) reviewers.add(e.dispatchId);
72
+ } else if (e.type === "commit.made" && e.milestone === m) open = null;
73
+ else if (
74
+ e.type === "dispatch.ended" &&
75
+ reviewers.has(e.dispatchId) &&
76
+ e.status === "ok" &&
77
+ e.replyStatus !== "blocked" &&
78
+ e.replyStatus !== "refused"
79
+ ) {
80
+ if (!open) rounds.push((open = []));
81
+ open.push(...e.findings);
82
+ }
83
+ }
84
+ return rounds;
85
+ }
86
+
87
+ /** `Rounds: 3 review round(s) · fixed: <the BLOCKER and BUG lines an earlier round named and the last did not repeat>`. */
88
+ export function roundsLine(events: RunEvent[], m: string): string {
89
+ const rounds = reviewRounds(events, m);
90
+ const last = new Set((rounds.at(-1) ?? []).map((f) => findingKey(issueLine(f))));
91
+ const seen = new Set<string>();
92
+ const fixed: string[] = [];
93
+ for (const f of rounds.slice(0, -1).flat()) {
94
+ const key = findingKey(issueLine(f));
95
+ if (f.label === "NIT" || last.has(key) || seen.has(key)) continue;
96
+ seen.add(key);
97
+ fixed.push(issueLine(f));
98
+ }
99
+ const head = `Rounds: ${rounds.length} review round(s)`;
100
+ return fixed.length ? `${head} · fixed: ${fixed.slice(0, FIXED_LINES).join(" · ")}` : head;
101
+ }
102
+
103
+ /** Per command, the latest check record of this run and milestone that is no baseline; "Gate: not recorded" when none. */
104
+ export function gateLines(run: Run, m: string): string[] {
105
+ const latest = new Map<string, ReturnType<typeof readChecks>[number]>();
106
+ try {
107
+ for (const r of readChecks(run.meta.repo))
108
+ if (r.run === run.id && r.milestone === m && !r.baseline) latest.set(r.command, r);
109
+ } catch {
110
+ // a check store that cannot be read records nothing
111
+ }
112
+ return latest.size
113
+ ? [...latest.values()].map(
114
+ (r) =>
115
+ `Gate: \`${r.command}\` exit ${r.exit} on ${r.head.slice(0, 12)} · ${Math.round(r.durationMs / 1000)} s · secret scan: ${r.secrets ?? "not run"}`,
116
+ )
117
+ : ["Gate: not recorded"];
118
+ }
119
+
120
+ const oneLine = (s: string): string => s.replace(/\s*\n\s*/g, " ").trim();
121
+
122
+ /**
123
+ * `<run>/report.md`, the body of the merge request: the run's title, each landed milestone's digest, the known
124
+ * issues verbatim by milestone, and the audit: every judge decision, and what each milestone's check passed on
125
+ * only through the base or a rerun (`audits`). Returns its path.
126
+ */
127
+ export function writeRunReport(
128
+ run: Run,
129
+ s: RunState,
130
+ events: RunEvent[],
131
+ audits: Record<string, string[]> = {},
132
+ proposals?: RuleChange[],
133
+ ): string {
134
+ const landed = events.filter((e) => e.type === "milestone.landed");
135
+ const decided = events.filter((e) => e.type === "judge.decided");
136
+ const lines = [`# ${s.title || run.meta.title}`, ""];
137
+ for (const e of landed) {
138
+ const title = s.milestones.find((m) => m.id === e.milestone)?.title;
139
+ const digest = read(e.digestPath.startsWith("/") ? e.digestPath : join(run.dir, e.digestPath));
140
+ lines.push(
141
+ `## ${e.milestone}${title && title !== e.milestone ? `: ${title}` : ""}`,
142
+ "",
143
+ digest || `Landed at ${e.commit.slice(0, 12)}.`,
144
+ "",
145
+ roundsLine(events, e.milestone),
146
+ ...gateLines(run, e.milestone),
147
+ "",
148
+ );
149
+ }
150
+ if (landed.length === 0) lines.push("No milestone landed.", "");
151
+ lines.push("## Known issues", "");
152
+ const issues = landed.flatMap((e) => e.knownIssues.map((k) => `- ${e.milestone}: ${k}`));
153
+ lines.push(...(issues.length ? issues : ["None."]), "");
154
+ // plan 34: the latest lint of each milestone's preflight
155
+ const lint = [
156
+ ...new Set(events.flatMap((e) => (e.type === "preflight.done" ? [e.milestone] : []))),
157
+ ].flatMap((m) => {
158
+ const latest = events.findLast((e) => e.type === "preflight.done" && e.milestone === m);
159
+ return latest?.type === "preflight.done" ? latest.lint.map((l) => `- ${m}: ${l}`) : [];
160
+ });
161
+ if (lint.length) lines.push("## Plan lint", "", ...lint, "");
162
+ lines.push("## Audit", "");
163
+ const audit = [
164
+ ...decided.map((e) => `- ${e.unit} (${e.kind}): ${e.decision.verb}: ${oneLine(said(e.decision))}`),
165
+ ...Object.entries(audits).flatMap(([m, lines]) => lines.map((l) => `- ${m} (check): ${oneLine(l)}`)),
166
+ // a worker's conflict with its plan task, in record order
167
+ ...readRecords(run).records.flatMap((r) =>
168
+ (r.design ?? []).map((d) => `- ${r.name} DESIGN: ${oneLine(d)}`),
169
+ ),
170
+ ];
171
+ lines.push(...(audit.length ? audit : ["No judge decision."]), "");
172
+ lines.push(...(rulesSection(s.rulesReview?.failed === true, proposals) ?? []));
173
+ const file = join(run.dir, "report.md");
174
+ writeTextAtomic(file, lines.join("\n"));
175
+ return file;
176
+ }