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,181 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { errorMessage } from "../domain/errors.ts";
5
+ import { launchDaemon } from "../infra/launch.ts";
6
+ import type { SuperviseClock } from "../infra/supervisor.ts";
7
+ import { lockHeld, tryLock } from "../infra/filelock.ts";
8
+ import { log } from "../infra/log.ts";
9
+ import { processStartTime } from "../infra/proc.ts";
10
+ import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
11
+ import type { Deps } from "./ports.ts";
12
+ import type { RunEvent } from "../domain/run-events.ts";
13
+ import { type Action, actionKey, confirms, failKey, next } from "../domain/run-machine.ts";
14
+ import { reduce, replay, type RunState } from "../domain/run-state.ts";
15
+ import { type Effects, realEffects } from "./run-effects.ts";
16
+ import { appendEvent, eventsFile, isDaemonRun, readEvents } from "./run-events-store.ts";
17
+ import { findRun, type Run } from "./run-store.ts";
18
+
19
+ /** The daemon holds this lock for its whole life: a second holder gets none, a dead one's is reclaimed. */
20
+ export const daemonLockTarget = (run: Run): string => join(run.dir, "daemon");
21
+ export const heartbeatFile = (run: Run): string => join(run.dir, "daemon.json");
22
+
23
+ /** `beatMs`: how often the heartbeat is written; `pollMs`: how often the loop reads the events. Tests shorten them. */
24
+ export const daemonLimits = { beatMs: 5_000, pollMs: 500 };
25
+
26
+ export interface Heartbeat {
27
+ schema: 1;
28
+ pid: number;
29
+ startTime: string | null;
30
+ beatAt: string;
31
+ seq: number;
32
+ version: string;
33
+ }
34
+
35
+ const HeartbeatSchema = z.looseObject({
36
+ schema: z.literal(1),
37
+ pid: z.number().int(),
38
+ startTime: z.string().nullable(),
39
+ beatAt: z.string(),
40
+ seq: z.number().int(),
41
+ version: z.string(),
42
+ });
43
+
44
+ /** The daemon's last beat, or null when it never wrote one or the file cannot be read. */
45
+ export function readHeartbeat(run: Run): Heartbeat | null {
46
+ try {
47
+ return readVersioned(heartbeatFile(run), HeartbeatSchema, 1);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Whether a live process holds the run's daemon lock: its pid and start time, never the heartbeat. */
54
+ export const daemonAlive = (run: Run): boolean => lockHeld(daemonLockTarget(run));
55
+
56
+ /** How the daemon is started; tests replace it so no process is spawned. */
57
+ export const daemonLauncher = { launch: launchDaemon };
58
+
59
+ /** Whether the run's events say it ended (`run.ended`). A `"type":"run.ended"` never occurs inside a JSON string. */
60
+ function hasEnded(run: Run): boolean {
61
+ try {
62
+ return readFileSync(eventsFile(run), "utf8").includes('"type":"run.ended"');
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Makes sure a 2.0 run that has not ended has a daemon: one that is alive is left alone, a dead one is
70
+ * started again (two callers at once start two processes, and the lock lets one run). A 1.5 run has none.
71
+ */
72
+ export function ensureDaemon(run: Run): "alive" | "revived" | "ended" | "1.5" {
73
+ if (!isDaemonRun(run)) return "1.5";
74
+ if (hasEnded(run)) return "ended";
75
+ if (daemonAlive(run)) return "alive";
76
+ daemonLauncher.launch(run.id, run.dir);
77
+ return "revived";
78
+ }
79
+
80
+ /** Writes the heartbeat: who holds the lock, since when, and how far it has read. */
81
+ export function beat(run: Run, deps: Deps, seq: number): void {
82
+ const pid = process.pid;
83
+ writeJsonAtomic(heartbeatFile(run), {
84
+ schema: 1,
85
+ pid,
86
+ startTime: processStartTime(pid),
87
+ beatAt: new Date(deps.now()).toISOString(),
88
+ seq,
89
+ version: deps.version,
90
+ } satisfies Heartbeat);
91
+ }
92
+
93
+ export interface DaemonOptions {
94
+ /** what carries the machine's actions out; the real services when absent */
95
+ effects?: Effects;
96
+ /** the loop's time; the real clock when absent */
97
+ clock?: SuperviseClock;
98
+ /** ends the loop early (tests) */
99
+ until?: () => boolean;
100
+ }
101
+
102
+ const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
103
+
104
+ /**
105
+ * Drives one run: takes its lock (`"lost-lock"` without it), returns `"ended"` at once for a run that ended,
106
+ * else replays the events, announces itself, and then feeds each new event to the machine: it clears the keys
107
+ * the event confirms, runs each action of `next` it has not issued (not awaited), appends one `tick` when a
108
+ * hold or wait has passed and beats. It leaves, releasing the lock, once the run has ended and every effect
109
+ * is done. The effects append the events, so a crash loses nothing: a new daemon replays and issues again
110
+ * what never got its event.
111
+ */
112
+ export async function runDaemon(
113
+ deps: Deps,
114
+ runId: string,
115
+ o: DaemonOptions = {},
116
+ ): Promise<"ended" | "lost-lock"> {
117
+ const run = findRun(runId);
118
+ const release = tryLock(daemonLockTarget(run));
119
+ if (!release) return "lost-lock";
120
+ try {
121
+ if (!isDaemonRun(run) || hasEnded(run)) return "ended";
122
+ const clock = o.clock ?? REAL_CLOCK;
123
+ const effects = o.effects ?? realEffects(deps, run);
124
+ let state: RunState = replay(readEvents(run), run.id);
125
+ const hello = await appendEvent(
126
+ run,
127
+ { type: "daemon.started", pid: process.pid, version: deps.version },
128
+ new Date(clock.now()),
129
+ );
130
+ log("info", "daemon", { run: run.id, pid: process.pid, seq: hello.seq });
131
+ beat(run, deps, hello.seq);
132
+ /** action key → the key `effect.failed` frees it by */
133
+ const issued = new Map<string, string>();
134
+ const inFlight = new Set<Promise<void>>();
135
+ const issue = (a: Action): void => {
136
+ const key = actionKey(a);
137
+ if (issued.has(key)) return;
138
+ issued.set(key, failKey(a, state));
139
+ const p: Promise<void> = effects
140
+ .run(a, state)
141
+ .catch((e: unknown) =>
142
+ log("error", "daemon", { run: run.id, action: a.type, error: errorMessage(e) }),
143
+ )
144
+ .finally(() => {
145
+ inFlight.delete(p);
146
+ // a notice, and a merge check that found nothing, leave no event behind: nothing else would free their key
147
+ if (a.type === "notify" || a.type === "checkMerged") issued.delete(key);
148
+ });
149
+ inFlight.add(p);
150
+ };
151
+ const take = (e: RunEvent): void => {
152
+ state = reduce(state, e);
153
+ for (const k of confirms(e))
154
+ for (const [key, fk] of issued) if (key === k || fk === k) issued.delete(key);
155
+ for (const a of next(state, e)) issue(a);
156
+ };
157
+ let beatAt = clock.now();
158
+ while (!o.until?.()) {
159
+ const fresh = readEvents(run, state.seq + 1);
160
+ for (const e of fresh) take(e);
161
+ if (fresh.length > 0) continue;
162
+ if (state.wakeAt !== null && Date.parse(state.wakeAt) <= clock.now()) {
163
+ await appendEvent(run, { type: "tick" }, new Date(clock.now()));
164
+ continue;
165
+ }
166
+ if (clock.now() - beatAt >= daemonLimits.beatMs) {
167
+ beat(run, deps, state.seq);
168
+ beatAt = clock.now();
169
+ }
170
+ if (state.phase === "ended" && inFlight.size === 0) break;
171
+ await clock.sleep(daemonLimits.pollMs);
172
+ }
173
+ beat(run, deps, Math.max(state.seq, hello.seq));
174
+ return "ended";
175
+ } catch (e) {
176
+ log("error", "daemon", { run: runId, error: errorMessage(e) });
177
+ throw e;
178
+ } finally {
179
+ release();
180
+ }
181
+ }
@@ -35,6 +35,7 @@ import {
35
35
  } from "./dispatches.ts";
36
36
  import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize.ts";
37
37
  import type { Deps } from "./ports.ts";
38
+ import { isDaemonRun } from "./run-events-store.ts";
38
39
  import { route } from "./lane-service.ts";
39
40
  import { currentRoute } from "../domain/route.ts";
40
41
  import { appendRoute, findRun, readRecords, readRoleRoutes, readRoutes, type Run } from "./run-store.ts";
@@ -223,6 +224,8 @@ function limitIsMine(deps: Deps, run: Run, origin: SessionRef | null): boolean {
223
224
  * to their new owner (a second settle of a dispatch is harmless: finalize and failover run once).
224
225
  */
225
226
  export function adopt(deps: Deps, run: Run): void {
227
+ // a 2.0 run's daemon watches its roles and fails them over itself
228
+ if (isDaemonRun(run)) return;
226
229
  for (const d of liveDispatches(run, deps.now())) if (!watching.has(d.admit.dispatchId)) watch(deps, run, d);
227
230
  }
228
231
 
@@ -252,6 +255,7 @@ export async function claim(deps: Deps, run: Run, o: { background?: boolean } =
252
255
  * away has no live process to watch and no record to scan).
253
256
  */
254
257
  export async function recoverOwned(deps: Deps, run: Run, o: { background?: boolean } = {}): Promise<void> {
258
+ if (isDaemonRun(run)) return;
255
259
  adopt(deps, run);
256
260
  let r = recovering.get(run.id);
257
261
  if (!r) {
@@ -786,7 +790,7 @@ async function stopOrphan(deps: Deps, d: Dispatch): Promise<void> {
786
790
  /**
787
791
  * Spec §4.7: stop a live dispatch (interrupt, SIGTERM, SIGKILL) and record it as cancelled. With `read` (the MCP
788
792
  * `cancel` tool, whose caller holds the record it returns) the record is marked read, so `peek` no longer lists
789
- * it. Without it (the dashboard, `catherd runs cancel`) the record stays unread and catherd announces it to the
793
+ * it. Without it (the panel, `catherd runs cancel`) the record stays unread and catherd announces it to the
790
794
  * run's owner at `later` (spec §3.6).
791
795
  */
792
796
  export async function cancel(
@@ -151,7 +151,7 @@ export async function backendChecks(
151
151
  }
152
152
  : { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
153
153
  );
154
- // spec 1.3 §6.6: asked only of a logged-in CLI (a logged-out agy -p would open a browser)
154
+ // spec 1.3 §6.6: asked only of a logged-in CLI (a logged-out CLI may open a browser)
155
155
  const quota = probe.loggedIn && a.quota ? await a.quota().catch(() => null) : null;
156
156
  if (quota)
157
157
  checks.push({ id: `quota:${id}`, label: `${id} quota`, state: "info", word: "quota", detail: quota });
@@ -7,6 +7,7 @@ import { claudeHome, locksDir } from "../infra/paths.ts";
7
7
  import { ensurePrivateDir, PRIVATE_FILE } from "../infra/store.ts";
8
8
  import { agentLinkState } from "./agent-links.ts";
9
9
  import type { Handshake } from "./doctor.ts";
10
+ import { type IndexStatus, indexStatus } from "./catalog-service.ts";
10
11
  import { activeName } from "./profile-store.ts";
11
12
 
12
13
  // The rows of `catherd doctor` and the checks that stand alone; doctor.ts assembles the report.
@@ -40,6 +41,52 @@ export function guarded(id: string, label: string, fallbackFix: string, check: (
40
41
  }
41
42
  }
42
43
 
44
+ const HOUR = 3_600_000;
45
+ const DAY = 24 * HOUR;
46
+
47
+ /** An age as doctor prints it: minutes under an hour, hours under two days, else days. */
48
+ export function ageText(ms: number): string {
49
+ if (ms < HOUR) return `${Math.max(1, Math.round(ms / 60_000))} min`;
50
+ if (ms < 2 * DAY) return `${Math.round(ms / HOUR)} h`;
51
+ return `${Math.round(ms / DAY)} d`;
52
+ }
53
+
54
+ /**
55
+ * The `catalog` row: the Slopalytics index's age and the last fetch's error. `info` before the first fetch;
56
+ * a warning when the last fetch failed, or the index has not been checked for a day, since the hourly refresh
57
+ * would have. It reads the cache file only: no request.
58
+ */
59
+ export function indexCheck(now = Date.now(), s: IndexStatus = indexStatus()): Check {
60
+ const base = { id: "catalog", label: "catalog" };
61
+ if (s.checkedAt === null || (s.fetchedAt === null && s.error === null))
62
+ return {
63
+ ...base,
64
+ state: "info",
65
+ word: "not fetched",
66
+ detail: "no model is indexed yet",
67
+ fix: "catherd catalog sync",
68
+ };
69
+ const age = ageText(now - Date.parse(s.fetchedAt ?? s.checkedAt));
70
+ if (s.error !== null)
71
+ return {
72
+ ...base,
73
+ state: "warn",
74
+ word: "failing",
75
+ detail:
76
+ s.fetchedAt === null ? `${s.error}; no index` : `${s.error}; keeps the index fetched ${age} ago`,
77
+ fix: "catherd catalog sync --force",
78
+ };
79
+ if (now - Date.parse(s.checkedAt) > DAY)
80
+ return {
81
+ ...base,
82
+ state: "warn",
83
+ word: "stale",
84
+ detail: `${s.pairs} pairs, fetched ${age} ago`,
85
+ fix: "catherd catalog sync --force",
86
+ };
87
+ return { ...base, state: "ok", word: "fresh", detail: `${s.pairs} pairs, fetched ${age} ago` };
88
+ }
89
+
43
90
  export function pluginCheck(version: string): Check {
44
91
  const base = { id: "plugin", label: "Claude Code plugin" };
45
92
  const file = join(claudeHome(), "plugins", "installed_plugins.json");
@@ -12,12 +12,12 @@ import { linkedProfiles } from "./agent-links.ts";
12
12
  import { accessChecks } from "./doctor-access.ts";
13
13
  import { backendChecks, usedBackends } from "./doctor-backends.ts";
14
14
  import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
15
- import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
16
15
  import { cachesCheck, dockerChecks } from "./doctor-docker.ts";
17
16
  import { roleMcpChecks } from "./doctor-role-mcp.ts";
18
17
  import {
19
18
  agentsCheck,
20
19
  hostFlag,
20
+ indexCheck,
21
21
  type Check,
22
22
  errText,
23
23
  fixOf,
@@ -176,7 +176,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
176
176
  const named = (fix: string) =>
177
177
  fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name}${onHost} `);
178
178
  const row = guarded(id, `profile ${name}`, validate, () => {
179
- const v = validateNamed(name, d.repo, d.host.host);
179
+ const v = validateNamed(name, d.repo, d.host.host, { unindexed: "warning" });
180
180
  const first = v.errors[0] ?? v.warnings[0];
181
181
  return {
182
182
  id,
@@ -242,14 +242,9 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
242
242
  }
243
243
  }
244
244
 
245
- // spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
246
- // profiles' stand-ins to confirm
247
245
  const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
248
- checks.push(
249
- guarded("sources", "sources", "catherd catalog sync --force", () =>
250
- sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
251
- ),
252
- );
246
+ // the Slopalytics index: its age and the last fetch's error
247
+ checks.push(guarded("catalog", "catalog", "catherd catalog sync", () => indexCheck()));
253
248
 
254
249
  const native = used.get("claude");
255
250
  const plugin = pluginCheck(d.version);
@@ -0,0 +1,109 @@
1
+ import {
2
+ catalogPairs,
3
+ claudeBilled,
4
+ downgrade,
5
+ intelligenceOf,
6
+ quotaOf,
7
+ rankStandIns,
8
+ } from "../domain/failover.ts";
9
+ import { pairOf } from "../domain/catalog.ts";
10
+ import { tryParseRung } from "../domain/ids.ts";
11
+ import type { Role } from "../domain/roles.ts";
12
+ import { effortAllowed } from "../domain/select.ts";
13
+ import { standInFor } from "./backends.ts";
14
+ import { loadCatalog } from "./catalog-service.ts";
15
+ import type { Deps, ProfileView } from "./ports.ts";
16
+ import { listProfiles, readProfileDoc } from "./profile-store.ts";
17
+ import { runProfile } from "./run-pin.ts";
18
+ import type { Run } from "./run-store.ts";
19
+
20
+ /** The headless twin of a native Claude rung is what the daemon runs: one backend name for both. */
21
+ const backendOf = (rung: string): string | null => {
22
+ const b = tryParseRung(rung)?.backend ?? null;
23
+ return b === "claude" ? "claude-code" : b;
24
+ };
25
+
26
+ /**
27
+ * Every backend the user configured (spec 2.0 decision 6): those of every rung in every saved profile, and of
28
+ * every stand-in a profile's `failover` names, not only the profiles a run uses.
29
+ */
30
+ export function configuredBackends(): string[] {
31
+ const out = new Set<string>();
32
+ for (const name of listProfiles()) {
33
+ let doc: ReturnType<typeof readProfileDoc>;
34
+ try {
35
+ doc = readProfileDoc(name);
36
+ } catch {
37
+ continue;
38
+ }
39
+ const rungs = [
40
+ ...Object.values(doc.roles ?? {}).flatMap((r) => r?.rungs ?? []),
41
+ ...Object.values(doc.failover ?? {}),
42
+ ];
43
+ for (const r of rungs) {
44
+ const b = backendOf(r);
45
+ if (b) out.add(b);
46
+ }
47
+ }
48
+ return [...out].sort();
49
+ }
50
+
51
+ /** The backends a limit may move work to: every configured one, and those of the run's own profile. */
52
+ export function failoverBackends(profile: ProfileView): Set<string> {
53
+ const backends = new Set(configuredBackends());
54
+ for (const r of [
55
+ ...Object.values(profile.roles).flatMap((x) => x?.rungs ?? []),
56
+ ...Object.values(profile.failover),
57
+ ]) {
58
+ const b = backendOf(r);
59
+ if (b) backends.add(b);
60
+ }
61
+ return backends;
62
+ }
63
+
64
+ const pairsOf = (c: ReturnType<typeof loadCatalog>): { rung: string; intelligence: number }[] =>
65
+ catalogPairs(c).map((rung) => ({
66
+ rung,
67
+ intelligence: (pairOf(c, rung) as { intelligence: number }).intelligence,
68
+ }));
69
+
70
+ /** Plan 29's index through one adapter, so plan 33 may rank on something else: every indexed rung and its index. */
71
+ export function indexedPairs(
72
+ _deps: Deps,
73
+ o: { repo?: string; runDir?: string } = {},
74
+ ): { rung: string; intelligence: number }[] {
75
+ return pairsOf(loadCatalog(o));
76
+ }
77
+
78
+ /**
79
+ * Where a unit goes when `rung`'s quota hit a limit (spec 2.0 decision 6): the profile's stand-in when its quota
80
+ * is free and it is not weaker; else the nearest indexed rung of any configured backend on a free quota that is
81
+ * not weaker, a Claude rung last. Never a step down: a rung with no index fails over only to its stand-in, and
82
+ * null means every candidate is held or weaker, so the caller waits for the hold. Plan 33: a stand-in above
83
+ * its model's `maxEffort`, or below medium for a coding worker, is never taken, whatever its quota or cost.
84
+ */
85
+ export function failoverRung(deps: Deps, run: Run, role: Role, rung: string, held: string[]): string | null {
86
+ const from = tryParseRung(rung);
87
+ if (!from) return null;
88
+ const profile = runProfile(deps, run);
89
+ const c = loadCatalog({ repo: run.meta.repo, runDir: run.dir });
90
+ const free = (r: string): boolean => {
91
+ const p = tryParseRung(r);
92
+ return (
93
+ p !== null &&
94
+ quotaOf(p) !== quotaOf(from) &&
95
+ !held.includes(quotaOf(p)) &&
96
+ effortAllowed(profile.maxEffort ?? {}, role, r)
97
+ );
98
+ };
99
+ const stand = standInFor(profile.failover, rung, run.meta.repo);
100
+ const explicit = stand !== null && free(stand) && !downgrade(c, rung, stand) ? stand : null;
101
+ if (explicit && !claudeBilled(explicit)) return explicit;
102
+ if (intelligenceOf(c, rung) === null) return explicit;
103
+ const backends = failoverBackends(profile);
104
+ const pool = pairsOf(c)
105
+ .map((p) => p.rung)
106
+ .filter((r) => backends.has(backendOf(r) ?? ""));
107
+ const ranked = rankStandIns(c, profile.billing, rung, pool).filter(free);
108
+ return ranked.find((r) => !claudeBilled(r)) ?? explicit ?? ranked[0] ?? null;
109
+ }
@@ -8,7 +8,11 @@ import { isCatherdError } from "../domain/errors.ts";
8
8
  import { parseRung } from "../domain/ids.ts";
9
9
  import {
10
10
  type ExitInfo,
11
+ parseReplyDesign,
11
12
  parseReplyEnvironment,
13
+ parseReplyExtras,
14
+ parseReplyHead,
15
+ parseReplyRejections,
12
16
  parseReplyStatus,
13
17
  type RunRecord,
14
18
  THREAD_HEAVY_INPUT,
@@ -202,6 +206,10 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
202
206
  : { changedOwned: [], violations: [] };
203
207
  const reported = parseReplyStatus(replyText);
204
208
  const environment = parseReplyEnvironment(replyText);
209
+ const head = parseReplyHead(replyText);
210
+ const extras = parseReplyExtras(replyText);
211
+ const rejected = parseReplyRejections(replyText);
212
+ const design = parseReplyDesign(replyText);
205
213
  return {
206
214
  schema: 1,
207
215
  runId: run.id,
@@ -229,6 +237,10 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
229
237
  replyStatus: reported.status,
230
238
  replyWhy: reported.why,
231
239
  ...(environment ? { environment } : {}),
240
+ ...(head ? { head } : {}),
241
+ ...(extras.length ? { extras } : {}),
242
+ ...(rejected.length ? { rejected } : {}),
243
+ ...(design.length ? { design } : {}),
232
244
  threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
233
245
  access: a.access,
234
246
  isolated: a.isolated,
@@ -61,7 +61,7 @@ const SECRET_NAME = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
61
61
  /**
62
62
  * The names catherd sets in a dispatch itself: the role's identity, dispatch id, testcontainers session, scratch
63
63
  * and working dir, the process basics, catherd's own dirs, and the homes an isolated CLI is moved to (codex,
64
- * grok, cursor, agy, opencode, claude). A gate env entry under one would take the role's identity or isolation.
64
+ * cursor, opencode, claude). A gate env entry under one would take the role's identity or isolation.
65
65
  */
66
66
  const RESERVED = new Set([
67
67
  ROLE_ENV,
@@ -75,7 +75,6 @@ const RESERVED = new Set([
75
75
  "CATHERD_CONFIG_DIR",
76
76
  "CATHERD_DATA_DIR",
77
77
  "CODEX_HOME",
78
- "GROK_HOME",
79
78
  "CLAUDE_CONFIG_DIR",
80
79
  "CURSOR_CONFIG_DIR",
81
80
  "CURSOR_DATA_DIR",
@@ -9,6 +9,7 @@ import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
9
9
  import { gateEnvLines, readGateEnv } from "./gate-env.ts";
10
10
  import type { Deps } from "./ports.ts";
11
11
  import { findRun, type Run } from "./run-store.ts";
12
+ import { treeOf } from "./tree-path.ts";
12
13
 
13
14
  // Spec 1.1 §7: the verifier's gate ledger. A gate item that passed on the same content, with the same
14
15
  // command, is carried over instead of run again; every check is a verifier step `peek` and `status` show.
@@ -316,8 +317,13 @@ const lastSteps = (steps: VerifierStep[]): VerifierStep[] =>
316
317
  );
317
318
 
318
319
  /** The pass gate_check carries for `command` on `paths` as the tree is now, if any. */
319
- async function carryingPass(repo: string, command: string, paths: string[]): Promise<GatePass | undefined> {
320
- const { hash } = await contentHash(repo, paths);
320
+ async function carryingPass(
321
+ repo: string,
322
+ command: string,
323
+ paths: string[],
324
+ cwd: string = repo,
325
+ ): Promise<GatePass | undefined> {
326
+ const { hash } = await contentHash(cwd, paths);
321
327
  return readPasses(repo).findLast((p) => p.command === command && p.hash === hash);
322
328
  }
323
329
 
@@ -340,7 +346,7 @@ export async function recordedItems(run: Run, m: string): Promise<RecordedItem[]
340
346
  let passed = false;
341
347
  if (command !== null && paths !== undefined) {
342
348
  try {
343
- passed = (await carryingPass(repo, command, paths)) !== undefined;
349
+ passed = (await carryingPass(repo, command, paths, treeOf(run, m))) !== undefined;
344
350
  } catch (e) {
345
351
  // a path gone from the tree carries no pass
346
352
  if (!isCatherdError(e)) throw e;
@@ -379,7 +385,11 @@ export async function gateCheck(
379
385
  > {
380
386
  const run = findRun(i.run);
381
387
  const paths = cleanPaths(i.paths);
382
- const pass = await carryingPass(run.meta.repo, i.command, paths);
388
+ if (run.meta.base && !i.milestone)
389
+ throw new CatherdError("E_INPUT_INVALID", "a base run's gate_check names its milestone", {
390
+ fix: "pass milestone",
391
+ });
392
+ const pass = await carryingPass(run.meta.repo, i.command, paths, treeOf(run, i.milestone ?? null));
383
393
  recordStep(run, {
384
394
  at: new Date(deps.now()).toISOString(),
385
395
  item: i.item,
@@ -440,8 +450,13 @@ export async function gatePass(
440
450
  ): Promise<{ recorded: true; hash: string; commit: string }> {
441
451
  const run = findRun(i.run);
442
452
  const paths = cleanPaths(i.paths);
443
- const { hash, dirty } = await contentHash(run.meta.repo, paths);
444
- const head = (await gitHead(run.meta.repo)) ?? "none";
453
+ // a base run's milestone has its own tree: the one of the latest step that checked this item with this command
454
+ const step = readJsonl<VerifierStep>(stepsFile(run)).rows.findLast(
455
+ (s) => s?.item === i.item && s.command === i.command,
456
+ );
457
+ const cwd = treeOf(run, step?.milestone ?? null);
458
+ const { hash, dirty } = await contentHash(cwd, paths);
459
+ const head = (await gitHead(cwd)) ?? "none";
445
460
  const commit = dirty ? `${head}+uncommitted` : head;
446
461
  const file = gatesFile(run.meta.repo);
447
462
  ensureJsonlHeader(file, "gates");
@@ -16,7 +16,7 @@ import { assetPath } from "../infra/assets.ts";
16
16
  import { type JevTransport, jevRequest } from "../infra/jev-client.ts";
17
17
  import { addSecret, log } from "../infra/log.ts";
18
18
  import { appendJsonl, ensureJsonlHeader, readJsonl, readVersioned } from "../infra/store.ts";
19
- import { aaKey, credentialsPath, saveCredential, savedCredential } from "./credentials.ts";
19
+ import { credentialsPath, saveCredential, savedCredential } from "./credentials.ts";
20
20
 
21
21
  export { credentialsPath };
22
22
 
@@ -26,12 +26,10 @@ export const jevQuestions = (): JevFile =>
26
26
  (file ??= readVersioned(assetPath("catalog/jev.json"), JevFileSchema, 1));
27
27
 
28
28
  /**
29
- * Registers the saved keys (Jev's and Artificial Analysis's) with the redactor, for a process that reads
30
- * logs without ever using them.
29
+ * Registers the saved key (Jev's) with the redactor, for a process that reads logs without ever using it.
31
30
  */
32
31
  export function registerSavedSecrets(): void {
33
32
  addSecret(jevKey());
34
- addSecret(aaKey());
35
33
  }
36
34
 
37
35
  /** The key saved in credentials.json, or why that file cannot be read (unparsable, newer schema). */
@@ -40,6 +40,7 @@ import {
40
40
  milestoneCommits,
41
41
  milestoneVerifier,
42
42
  } from "./milestones.ts";
43
+ import { machineGate } from "./milestone-check.ts";
43
44
  import type { Deps, Verdict } from "./ports.ts";
44
45
  import {
45
46
  appendKnowledge,
@@ -339,7 +340,7 @@ export type LandSkip = (typeof LAND_SKIPS)[number];
339
340
  * Spec 1.1 §6: a milestone lands only with a reviewer record and a verifier verdict since its lanes
340
341
  * started, or with a `skip` the commit range bears out. Throws E_LAND_GATE naming what is missing.
341
342
  */
342
- async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefined): Promise<void> {
343
+ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefined): Promise<string[]> {
343
344
  // spec 1.1 §8: a parked milestone waits on the owner, whatever else it has
344
345
  const question = openQuestions(run).find((q) => q.milestone === m);
345
346
  if (question)
@@ -374,7 +375,7 @@ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefi
374
375
  }
375
376
  const against =
376
377
  skip === "docs-only" ? files.filter((f) => !isDocPath(f)) : files.filter((f) => isSourcePath(f));
377
- if (against.length === 0) return;
378
+ if (against.length === 0) return [];
378
379
  const shown = `${against.slice(0, 5).join(", ")}${against.length > 5 ? `, and ${against.length - 5} more` : ""}`;
379
380
  throw new CatherdError(
380
381
  "E_LAND_GATE",
@@ -429,6 +430,35 @@ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefi
429
430
  fix: `${marked}run reviewer-${m} (a dispatch, or a Claude subagent recorded with record_agent_run(name: "reviewer-${m}")) and the verifier on ${m}, recording it with record_agent_run(name: "verifier-${m}") (a FAIL with status "failed"), then land again; a docs-only milestone passes skip: "docs-only"`,
430
431
  },
431
432
  );
433
+ // plan 30b: the verdict must be about the commit being landed, and catherd's own check must have run on it
434
+ const landed = await fullCommit(run, commit);
435
+ const checked = verdict?.head ?? null;
436
+ if (verdict && checked === null)
437
+ throw new CatherdError(
438
+ "E_LAND_GATE",
439
+ `land ${m}: ${verdict.name}'s PASS names no HEAD, so it cannot be told to cover ${landed.slice(0, 12)}`,
440
+ {
441
+ fix: `a headless verifier's reply ends with HEAD: <sha>; run a fresh verifier-${m} on the committed milestone (${landed.slice(0, 7)})`,
442
+ },
443
+ );
444
+ if (verdict && checked !== null && !landed.startsWith(checked))
445
+ throw new CatherdError(
446
+ "E_LAND_GATE",
447
+ `land ${m}: ${verdict.name} checked ${checked.slice(0, 12)}, and the commit being landed is ${landed.slice(0, 12)}: a PASS from before a later commit does not count`,
448
+ {
449
+ fix: `run a fresh ${verdict.name} on ${landed.slice(0, 7)}: what passed is carried over by gate_check`,
450
+ },
451
+ );
452
+ const machine = await machineGate(run, m, landed);
453
+ if (machine.missing.length)
454
+ throw new CatherdError(
455
+ "E_LAND_GATE",
456
+ `land ${m}: the machine gate on ${landed.slice(0, 12)}: ${machine.missing.join("; ")}`,
457
+ {
458
+ fix: `run the full check with gate_run(run, "${m}", <command>) on ${landed.slice(0, 7)}; fix what fails, commit, and verify again`,
459
+ },
460
+ );
461
+ return machine.audit;
432
462
  }
433
463
 
434
464
  /**
@@ -508,7 +538,7 @@ async function landRun(
508
538
  throw new CatherdError("E_RUN_COMMIT", `no commit ${i.commit} in ${run.meta.repo}`, {
509
539
  fix: "commit the milestone first, then pass its hash",
510
540
  });
511
- await gate(run, i.milestone, i.commit, i.skip);
541
+ const audit = await gate(run, i.milestone, i.commit, i.skip);
512
542
  // read before the ledger row: the range runs from the previous landing to this commit
513
543
  const commits = await milestoneCommits(run, i.commit);
514
544
  const now = new Date(deps.now());
@@ -530,6 +560,7 @@ async function landRun(
530
560
  // the notes reach state.json first, with no git: the next landing counts its minutes from this one
531
561
  await critical(() => patchNotes(run, landRow));
532
562
  const { hints } = await refreshState(run);
563
+ hints.push(...audit);
533
564
  // spec §5.6: every routed lane of the milestone lands with it
534
565
  // under the routes lock, so a racing climb cannot slip between the read and the rows
535
566
  let routed: string[] = [];