catherd-cli 1.4.0 → 2.0.0-beta.20261022113309

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -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,6 +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,
12
+ parseReplyEnvironment,
13
+ parseReplyExtras,
14
+ parseReplyHead,
15
+ parseReplyRejections,
11
16
  parseReplyStatus,
12
17
  type RunRecord,
13
18
  THREAD_HEAVY_INPUT,
@@ -25,6 +30,7 @@ import {
25
30
  writeTextAtomic,
26
31
  } from "../infra/store.ts";
27
32
  import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
33
+ import { currentOwns } from "./lane-edit.ts";
28
34
  import { tail } from "./run-debug.ts";
29
35
  import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
30
36
 
@@ -75,7 +81,7 @@ function othersOwns(run: Run, self: Dispatch, start: number, end: number): strin
75
81
  const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
76
82
  return from <= end && to >= start;
77
83
  })
78
- .flatMap((d) => d.admit.owns);
84
+ .flatMap((d) => [...d.admit.owns, ...(d.admit.ownsImplicit ?? [])]);
79
85
  }
80
86
 
81
87
  /** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
@@ -186,13 +192,24 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
186
192
  const { changedOwned, violations } = after
187
193
  ? splitChanges(
188
194
  changedPaths(a.before, after),
189
- a.owns,
195
+ // spec 1.5 owns_add: paths the lane gained while it ran are its own, not violations; a laneless
196
+ // writer is held to its implicit docs lane (plan 21)
197
+ a.lane !== null
198
+ ? [...new Set([...a.owns, ...currentOwns(run, a.lane)])]
199
+ : a.owns.length
200
+ ? a.owns
201
+ : (a.ownsImplicit ?? []),
190
202
  // from admission, when the before-snapshot was taken, not from the worker's start
191
203
  othersOwns(run, d, Date.parse(a.admittedAt), end),
192
204
  a.lane !== null || a.access === "read-only",
193
205
  )
194
206
  : { changedOwned: [], violations: [] };
195
207
  const reported = parseReplyStatus(replyText);
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);
196
213
  return {
197
214
  schema: 1,
198
215
  runId: run.id,
@@ -219,6 +236,11 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
219
236
  ...(after ? {} : { gitUnavailable: true }),
220
237
  replyStatus: reported.status,
221
238
  replyWhy: reported.why,
239
+ ...(environment ? { environment } : {}),
240
+ ...(head ? { head } : {}),
241
+ ...(extras.length ? { extras } : {}),
242
+ ...(rejected.length ? { rejected } : {}),
243
+ ...(design.length ? { design } : {}),
222
244
  threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
223
245
  access: a.access,
224
246
  isolated: a.isolated,
@@ -0,0 +1,217 @@
1
+ import { existsSync, renameSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { ROLE_ENV } from "../domain/role-scope.ts";
6
+ import { isCatherdSecret } from "../infra/env.ts";
7
+ import { withFileLock } from "../infra/filelock.ts";
8
+ import { gitToplevel } from "../infra/git.ts";
9
+ import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
10
+ import { repoDir } from "../infra/paths.ts";
11
+ import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
12
+ import { knowledgeFor } from "./run-store.ts";
13
+
14
+ // Plan 23: the gate environment a repo's verifier and preflight need (DOCKER_HOST, HTTPS_PROXY, a
15
+ // TESTCONTAINERS_* setting), kept beside the repo's knowledge.md, so no verifier sets it up by hand. A value is
16
+ // stored as is; a secret is stored by reference only: the name of the env var that holds it, read when a
17
+ // role or a check starts, never written to disk.
18
+
19
+ const GATE_ENV_SCHEMA = 1;
20
+
21
+ const EntrySchema = z.union([z.strictObject({ value: z.string() }), z.strictObject({ from: z.string() })]);
22
+ export type GateEnvEntry = z.infer<typeof EntrySchema>;
23
+
24
+ const GateEnvSchema = z.looseObject({
25
+ schema: z.literal(GATE_ENV_SCHEMA),
26
+ vars: z.record(z.string(), EntrySchema),
27
+ });
28
+ type GateEnvFile = z.infer<typeof GateEnvSchema>;
29
+
30
+ /** `<data>/repos/<repo key>/gate-env.json`: where a toplevel keyed it (before 1.5, and with no origin). */
31
+ export const gateEnvFile = (toplevel: string): string => join(repoDir(toplevel), "gate-env.json");
32
+
33
+ /**
34
+ * Spec 1.5 "Knowledge keyed by git origin": the gate-env.json beside the repo's knowledge.md, which every
35
+ * worktree and clone of one repository shares. A toplevel-keyed file this worktree left before is migrated on
36
+ * read: its variables join the shared file (the shared file's own win), and it is renamed `.migrated`.
37
+ */
38
+ export async function gateEnvFor(toplevel: string): Promise<string> {
39
+ const file = join(dirname(await knowledgeFor(toplevel)), "gate-env.json");
40
+ const legacy = gateEnvFile(toplevel);
41
+ if (file === legacy || !existsSync(legacy)) return file;
42
+ ensurePrivateDir(dirname(file));
43
+ await withFileLock(file, () => {
44
+ if (!existsSync(legacy)) return;
45
+ const vars = { ...readVarsAt(legacy), ...readVarsAt(file) };
46
+ writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
47
+ renameSync(legacy, `${legacy}.migrated`);
48
+ });
49
+ return file;
50
+ }
51
+
52
+ function readVarsAt(file: string): Record<string, GateEnvEntry> {
53
+ if (!existsSync(file)) return {};
54
+ return readVersioned<GateEnvFile>(file, GateEnvSchema, GATE_ENV_SCHEMA).vars;
55
+ }
56
+
57
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
58
+ /** A name a secret goes by: such a variable is set by reference (--from), never by value. */
59
+ const SECRET_NAME = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
60
+
61
+ /**
62
+ * The names catherd sets in a dispatch itself: the role's identity, dispatch id, testcontainers session, scratch
63
+ * and working dir, the process basics, catherd's own dirs, and the homes an isolated CLI is moved to (codex,
64
+ * cursor, opencode, claude). A gate env entry under one would take the role's identity or isolation.
65
+ */
66
+ const RESERVED = new Set([
67
+ ROLE_ENV,
68
+ DISPATCH_ID_ENV,
69
+ "TESTCONTAINERS_SESSION_ID",
70
+ "TMPDIR",
71
+ "PWD",
72
+ "PATH",
73
+ "HOME",
74
+ "CATHERD_HOME",
75
+ "CATHERD_CONFIG_DIR",
76
+ "CATHERD_DATA_DIR",
77
+ "CODEX_HOME",
78
+ "CLAUDE_CONFIG_DIR",
79
+ "CURSOR_CONFIG_DIR",
80
+ "CURSOR_DATA_DIR",
81
+ "XDG_CONFIG_HOME",
82
+ "XDG_DATA_HOME",
83
+ ]);
84
+
85
+ /** The repo's gate environment, by name; empty when none is set. */
86
+ export async function readGateEnv(toplevel: string): Promise<Record<string, GateEnvEntry>> {
87
+ return readVarsAt(await gateEnvFor(toplevel));
88
+ }
89
+
90
+ async function top(repo: string): Promise<string> {
91
+ const t = await gitToplevel(repo);
92
+ if (!t)
93
+ throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
94
+ fix: "pass the path of the repository",
95
+ });
96
+ return t;
97
+ }
98
+
99
+ function assertName(name: string): void {
100
+ if (!NAME.test(name))
101
+ throw new CatherdError("E_INPUT_INVALID", `"${name}" is not an env var name`, {
102
+ fix: "use letters, digits and _, not starting with a digit, e.g. DOCKER_HOST",
103
+ });
104
+ }
105
+
106
+ async function update(
107
+ repo: string,
108
+ change: (vars: Record<string, GateEnvEntry>) => void,
109
+ ): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
110
+ const t = await top(repo);
111
+ const file = await gateEnvFor(t);
112
+ ensurePrivateDir(dirname(file));
113
+ return withFileLock(file, () => {
114
+ const vars = { ...readVarsAt(file) };
115
+ change(vars);
116
+ writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
117
+ return { repo: t, vars };
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Sets one variable of the repo's gate environment: a value, or `from`, the name of the env var that holds a
123
+ * secret. A secret-looking name (…KEY, …TOKEN, …PASSWORD) takes `from` only, so no secret lands on disk.
124
+ */
125
+ export async function setGateEnv(
126
+ repo: string,
127
+ name: string,
128
+ entry: GateEnvEntry,
129
+ ): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
130
+ assertName(name);
131
+ if (RESERVED.has(name))
132
+ throw new CatherdError("E_INPUT_INVALID", `${name} is set by catherd for every role`, {
133
+ fix: "the gate environment holds what a gate needs (DOCKER_HOST, a proxy, a TESTCONTAINERS_* setting), not a role's identity, scratch or home",
134
+ });
135
+ if ("from" in entry) {
136
+ assertName(entry.from);
137
+ if (isCatherdSecret(entry.from))
138
+ throw new CatherdError("E_INPUT_INVALID", `${entry.from} is catherd's own secret: no role gets it`, {
139
+ fix: "reference the env var that holds the gate's own secret",
140
+ });
141
+ } else if (SECRET_NAME.test(name))
142
+ throw new CatherdError(
143
+ "E_INPUT_INVALID",
144
+ `${name} looks like a secret: catherd stores it by reference only`,
145
+ {
146
+ fix: `export it in your shell, then: catherd knowledge env set ${name} --from <the env var that holds it>`,
147
+ },
148
+ );
149
+ return update(repo, (vars) => {
150
+ vars[name] = entry;
151
+ });
152
+ }
153
+
154
+ /** Removes one variable; refused when it is not set. */
155
+ export function removeGateEnv(
156
+ repo: string,
157
+ name: string,
158
+ ): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
159
+ return update(repo, (vars) => {
160
+ if (!(name in vars))
161
+ throw new CatherdError("E_INPUT_INVALID", `${name} is not in the gate environment`, {
162
+ fix: "catherd knowledge env list shows what is set",
163
+ });
164
+ delete vars[name];
165
+ });
166
+ }
167
+
168
+ /** The variables as briefs show them: `NAME=value`, a reference as `NAME=$FROM`. */
169
+ export const gateEnvLines = (vars: Record<string, GateEnvEntry>): string[] =>
170
+ Object.entries(vars)
171
+ .sort(([a], [b]) => a.localeCompare(b))
172
+ .map(([k, e]) => ("from" in e ? `${k}=$${e.from}` : `${k}=${e.value}`));
173
+
174
+ /** The literal values, for a spec written to disk; references stay names (`refs`), resolved at spawn. */
175
+ export function gateEnvParts(vars: Record<string, GateEnvEntry>): {
176
+ values: Record<string, string>;
177
+ refs: Record<string, string>;
178
+ } {
179
+ const values: Record<string, string> = {};
180
+ const refs: Record<string, string> = {};
181
+ for (const [k, e] of Object.entries(vars)) {
182
+ if ("from" in e) refs[k] = e.from;
183
+ else values[k] = e.value;
184
+ }
185
+ return { values, refs };
186
+ }
187
+
188
+ /**
189
+ * The env the gate environment adds to `base`: its values, and each reference read from `base`, else from
190
+ * `fallback` (the login env); a reference neither holds is left out, and named in `missing`.
191
+ */
192
+ export function resolveGateEnv(
193
+ vars: Record<string, GateEnvEntry>,
194
+ base: Record<string, string | undefined>,
195
+ fallback: () => Record<string, string | undefined> = () => ({}),
196
+ ): { env: Record<string, string>; missing: string[] } {
197
+ const { values, refs } = gateEnvParts(vars);
198
+ return resolveRefs(values, refs, base, fallback);
199
+ }
200
+
201
+ /** `values` plus each of `refs` (name → the env var holding it) read from `base`, else `fallback`. */
202
+ export function resolveRefs(
203
+ values: Record<string, string>,
204
+ refs: Record<string, string>,
205
+ base: Record<string, string | undefined>,
206
+ fallback: () => Record<string, string | undefined> = () => ({}),
207
+ ): { env: Record<string, string>; missing: string[] } {
208
+ const env = { ...values };
209
+ const missing: string[] = [];
210
+ let login: Record<string, string | undefined> | null = null;
211
+ for (const [k, from] of Object.entries(refs)) {
212
+ const v = base[from] ?? (login ??= fallback())[from];
213
+ if (v === undefined) missing.push(k);
214
+ else env[k] = v;
215
+ }
216
+ return { env, missing };
217
+ }