catherd-cli 0.2.1 → 1.1.0

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 (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,239 @@
1
+ import {
2
+ type Catalog,
3
+ capableFor,
4
+ effortOffered,
5
+ ROLE_NEEDS,
6
+ type RungInfo,
7
+ rungInfo,
8
+ scoresOf,
9
+ } from "./catalog.ts";
10
+ import {
11
+ catalogRungs,
12
+ claudeBilled,
13
+ downgradeDims,
14
+ ladderDropDims,
15
+ quotaOf,
16
+ rankStandIns,
17
+ } from "./failover.ts";
18
+ import { tryParseRung } from "./ids.ts";
19
+ import { DIFFICULTIES, KINDS } from "./lane.ts";
20
+ import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
21
+ import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
22
+ import { candidates, clearsBar, type RoutingProfile } from "./select.ts";
23
+
24
+ export { quotaOf };
25
+
26
+ /** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
27
+ export interface Issue {
28
+ path: string;
29
+ message: string;
30
+ fix?: string;
31
+ }
32
+
33
+ export interface Validation {
34
+ errors: Issue[];
35
+ warnings: Issue[];
36
+ }
37
+
38
+ const TREAT_LIKE_FIX = (rung: string) =>
39
+ `map it with: catherd catalog treat-like ${rung} <a scored rung>; catalog_query lists them`;
40
+
41
+ export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
42
+ objective: p.objective,
43
+ billing: p.billing,
44
+ role: p.roles[role],
45
+ });
46
+
47
+ /** Whether a rung's scores are catherd's guess: borrowed through a treat-like, or only `inferred` ones. */
48
+ export function inferredScores(c: Catalog, info: RungInfo): { inferred: boolean; via: string | null } {
49
+ const s = scoresOf(c, info.canonical);
50
+ if (!s) return { inferred: false, via: null };
51
+ const records = Object.values(s.records);
52
+ return { inferred: s.via !== null || records.every((r) => r.confidence === "inferred"), via: s.via };
53
+ }
54
+
55
+ /**
56
+ * Spec §7.1. Errors: the worker disabled; an enabled role with no usable rung; an unscored rung without a
57
+ * treat-like; a failover stand-in unscored or on the same quota; a rung whose backend catherd cannot run
58
+ * (`backends` lists those it can, `claude` included). Everything else worth knowing is a warning: an access
59
+ * mode other than the role's default, an effort or model the last listing does not offer, a stand-in
60
+ * that never runs, and (spec 1.1 §11) a stand-in that downgrades its rung, one that spends Claude quota
61
+ * while another plan could stand in, and a ladder that goes down. With the stored `doc` `p` came from, a value this catherd does not know is also a
62
+ * warning, one that says how the value is read.
63
+ */
64
+ export function validateProfile(
65
+ p: Profile,
66
+ c: Catalog,
67
+ backends: readonly string[],
68
+ doc?: ProfileDoc,
69
+ ): Validation {
70
+ const errors: Issue[] = [];
71
+ const warnings: Issue[] = [];
72
+ for (const u of doc ? unknownValues(doc) : [])
73
+ warnings.push({
74
+ path: u.path,
75
+ message: `"${u.value}" is not a value this catherd knows (a newer one wrote it?); it is read as ${u.readAs}`,
76
+ fix: "upgrade catherd (bun add -g catherd-cli@latest), or set a value this version knows",
77
+ });
78
+ if (!p.roles.worker.enabled)
79
+ errors.push({
80
+ path: "roles.worker.enabled",
81
+ message: "the worker cannot be disabled",
82
+ fix: "catherd profile set roles.worker.enabled true",
83
+ });
84
+
85
+ for (const role of ROLES) {
86
+ const rc = p.roles[role];
87
+ const at = `roles.${role}`;
88
+ if (rc.access !== DEFAULT_ACCESS[role])
89
+ warnings.push({
90
+ path: `${at}.access`,
91
+ message: `${role} runs ${rc.access}; catherd's default for it is ${DEFAULT_ACCESS[role]}`,
92
+ });
93
+ if (!rc.enabled) continue;
94
+ for (const rung of rc.rungs) {
95
+ const r = tryParseRung(rung);
96
+ if (!r) {
97
+ errors.push({
98
+ path: `${at}.rungs`,
99
+ message: `"${rung}" is not a rung`,
100
+ fix: "write it as <backend>:<model>#<effort>",
101
+ });
102
+ continue;
103
+ }
104
+ if (!backends.includes(r.backend)) {
105
+ errors.push({
106
+ path: `${at}.rungs`,
107
+ message: `${rung}: catherd cannot run ${r.backend} yet`,
108
+ fix: `use a rung on ${backends.join(", ")}`,
109
+ });
110
+ continue;
111
+ }
112
+ const info = rungInfo(c, rung);
113
+ if (!capableFor(c, info, role))
114
+ errors.push({
115
+ path: `${at}.rungs`,
116
+ message: `${rung} cannot fill the ${role} role (it needs ${Object.keys(ROLE_NEEDS[role]).join(" + ")})`,
117
+ fix: `catalog_query({ role: "${role}" }) lists the models that can`,
118
+ });
119
+ if (info.efforts.length > 0 && !effortOffered(info))
120
+ errors.push({
121
+ path: `${at}.rungs`,
122
+ message: `${r.model} has no effort "${r.effort}" on ${r.backend} (it has ${info.efforts.join(", ")})`,
123
+ });
124
+ else if (info.efforts.length === 0 && r.effort !== "default")
125
+ warnings.push({
126
+ path: `${at}.rungs`,
127
+ message: `${rung}: catherd cannot check its effort until ${r.backend} lists its models`,
128
+ fix: "catherd catalog refresh",
129
+ });
130
+ if (info.listed === false)
131
+ warnings.push({
132
+ path: `${at}.rungs`,
133
+ message: `${r.backend}'s last listing does not offer ${r.model}; routing skips it`,
134
+ });
135
+ if (!scoresOf(c, info.canonical))
136
+ errors.push({ path: `${at}.rungs`, message: `${rung} is unscored`, fix: TREAT_LIKE_FIX(rung) });
137
+ }
138
+ rc.rungs.forEach((rung, i) => {
139
+ const prev = rc.rungs[i - 1];
140
+ const down = prev === undefined ? [] : ladderDropDims(c, prev, rung);
141
+ if (down.length)
142
+ warnings.push({
143
+ path: `${at}.rungs`,
144
+ message: `the ladder goes down at ${rung}: it scores below ${prev} on ${down.join(", ")}`,
145
+ fix: `order ${at}.rungs weakest first`,
146
+ });
147
+ });
148
+ if (rc.defaultRung !== undefined && !rc.rungs.includes(rc.defaultRung))
149
+ errors.push({
150
+ path: `${at}.defaultRung`,
151
+ message: `${rc.defaultRung} is not one of the ${role}'s rungs`,
152
+ fix: `catherd profile set ${at}.defaultRung null, or add it to ${at}.rungs`,
153
+ });
154
+ const usable = candidates(c, routingProfileOf(p, role), role);
155
+ if (usable.length === 0)
156
+ errors.push({
157
+ path: `${at}.rungs`,
158
+ message: `the ${role} role has no usable rung`,
159
+ fix: `enable a scored rung its backend offers, or turn the role off with: catherd profile set ${at}.enabled false`,
160
+ });
161
+ if (usable.length > 1)
162
+ // Claude rungs have no honesty score, so they clear no shipped bar: select() starts on one only as the
163
+ // default rung (when nothing clears) and never climbs onto it
164
+ for (const x of usable)
165
+ if (
166
+ quotaOf(x.info.parsed) === "claude-code" &&
167
+ !KINDS.some((k) => DIFFICULTIES.some((d) => clearsBar(c, x, k, d)))
168
+ )
169
+ warnings.push({
170
+ path: `${at}.rungs`,
171
+ message: `${x.rung} clears no routing bar, so a lane starts on it only as the role's default rung and never climbs onto it`,
172
+ });
173
+ if (role === "worker" && usable.length > 1) {
174
+ const missed = KINDS.flatMap((k) =>
175
+ DIFFICULTIES.filter((d) => !usable.some((x) => clearsBar(c, x, k, d))).map((d) => `${k}/${d}`),
176
+ );
177
+ if (missed.length)
178
+ warnings.push({
179
+ path: `${at}.rungs`,
180
+ message: `no worker rung clears the bar for ${missed.join(", ")}; those lanes start at the default rung`,
181
+ });
182
+ }
183
+ }
184
+
185
+ const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
186
+ // the stand-ins catherd could run here, for the fixes below
187
+ const pool = catalogRungs(c).filter((x) => {
188
+ const r = tryParseRung(x);
189
+ return r !== null && backends.includes(r.backend);
190
+ });
191
+ for (const [from, to] of Object.entries(p.failover)) {
192
+ const at = `failover.${from}`;
193
+ const a = tryParseRung(from);
194
+ const b = tryParseRung(to);
195
+ if (!a || !b) {
196
+ errors.push({
197
+ path: at,
198
+ message: `"${!a ? from : to}" is not a rung`,
199
+ fix: "write it as <backend>:<model>#<effort>",
200
+ });
201
+ continue;
202
+ }
203
+ if (!backends.includes(b.backend)) {
204
+ errors.push({ path: at, message: `stand-in ${to}: catherd cannot run ${b.backend} yet` });
205
+ continue;
206
+ }
207
+ if (!scoresOf(c, rungInfo(c, to).canonical))
208
+ errors.push({ path: at, message: `stand-in ${to} is unscored`, fix: TREAT_LIKE_FIX(to) });
209
+ if (quotaOf(a) === quotaOf(b))
210
+ errors.push({
211
+ path: at,
212
+ message: `stand-in ${to} draws on the same quota as ${from}, which is out when ${from} hits its limit`,
213
+ fix: "name a stand-in on another backend or plan",
214
+ });
215
+ const ranked = rankStandIns(c, p.billing, from, pool);
216
+ const down = downgradeDims(c, from, to);
217
+ if (down.length)
218
+ warnings.push({
219
+ path: at,
220
+ message: `downgrade: ${to} stands in for ${from}, scoring below it on ${down.join(", ")}`,
221
+ fix: `catherd profile set failover.${from} ${ranked[0] ?? "null"}`,
222
+ });
223
+ const other = ranked.find((x) => !claudeBilled(x));
224
+ if (claudeBilled(to) && other)
225
+ warnings.push({
226
+ path: at,
227
+ message: `stand-in ${to} spends Claude quota, while ${other} could stand in on another plan`,
228
+ fix: `catherd profile set failover.${from} ${other}`,
229
+ });
230
+ if (b.backend === "claude")
231
+ warnings.push({
232
+ path: at,
233
+ message: `stand-in ${to} is a native subagent: the orchestrator must start it, dispatch cannot`,
234
+ });
235
+ if (!ladders.has(from))
236
+ warnings.push({ path: at, message: `${from} is on no enabled role's ladder, so this never runs` });
237
+ }
238
+ return { errors, warnings };
239
+ }
@@ -0,0 +1,389 @@
1
+ import { z } from "zod";
2
+ import type { Budget } from "./budget.ts";
3
+ import { BILLING_KEYS, type BillingKey } from "./catalog.ts";
4
+ import { BILLING_MODES, type BillingMode, DEFAULT_BILLING } from "./cost.ts";
5
+ import { CatherdError } from "./errors.ts";
6
+ import { ADAPTER_IDS, parseRung, tryParseRung } from "./ids.ts";
7
+ import { ACCESS, type Access } from "./record.ts";
8
+ import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
9
+ import { isPlain, slug } from "./util.ts";
10
+
11
+ /** Spec §3.4: every file carries its schema version; a profile is schema 1. */
12
+ const PROFILE_SCHEMA = 1;
13
+
14
+ /** Profile names end up in agent names (`catherd-<profile>-…`), which Claude Code wants lowercase. */
15
+ export const PROFILE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
16
+
17
+ export function assertProfileName(name: string): string {
18
+ if (!PROFILE_NAME.test(name))
19
+ throw new CatherdError("E_INPUT_INVALID", `bad profile name "${name}"`, {
20
+ fix: "use lowercase letters, digits and '-', at most 32, starting with a letter or digit",
21
+ });
22
+ return name;
23
+ }
24
+
25
+ export const NOTIFY = ["milestone", "finish", "blocked"] as const;
26
+ export type NotifyMoment = (typeof NOTIFY)[number];
27
+
28
+ /** The harnesses a profile can isolate (spec §7.1 `harness`); the native `claude` path has none. */
29
+ export const HARNESS_KEYS = ADAPTER_IDS;
30
+
31
+ const isRung = (s: string): boolean => tryParseRung(s) !== null;
32
+ /** A `<backend>:<model>#<effort>` rung, for input schemas; stored profiles keep any string and validate. */
33
+ const RungSchema = z.string().refine(isRung, "a rung is <backend>:<model>#<effort>");
34
+
35
+ const positive = z.number().positive();
36
+
37
+ // Stored files: every level is loose, so fields a newer catherd wrote survive a rewrite (spec §3.4), and
38
+ // rung strings are checked by validateProfile rather than making the whole file unreadable. The same holds
39
+ // for values: a stored enum takes any string, resolveProfile reads one it does not know as STORED_FALLBACK
40
+ // says, validateProfile warns, and the file keeps it.
41
+ const RoleDocSchema = z.looseObject({
42
+ enabled: z.boolean().optional(),
43
+ access: z.string().optional(),
44
+ rungs: z.array(z.string()).optional(),
45
+ defaultRung: z.string().optional(),
46
+ network: z.boolean().optional(),
47
+ });
48
+
49
+ export const ProfileDocSchema = z.looseObject({
50
+ schema: z.literal(PROFILE_SCHEMA),
51
+ name: z.string().optional(),
52
+ objective: z.string().optional(),
53
+ jev: z.looseObject({ use: z.string().optional() }).optional(),
54
+ billing: z.record(z.string(), z.string()).optional(),
55
+ roles: z.record(z.string(), RoleDocSchema).optional(),
56
+ harness: z.record(z.string(), z.looseObject({ isolated: z.boolean().optional() })).optional(),
57
+ failover: z.record(z.string(), z.string()).optional(),
58
+ budget: z
59
+ .looseObject({ minutes: positive.optional(), tokens: positive.optional(), usd: positive.optional() })
60
+ .optional(),
61
+ timeouts: z.looseObject({ idleMin: positive.optional(), wallMin: positive.optional() }).optional(),
62
+ preflight: z.looseObject({ confirm: z.boolean().optional() }).optional(),
63
+ lock: z
64
+ .looseObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]).optional() })
65
+ .optional(),
66
+ notify: z.array(z.string()).optional(),
67
+ });
68
+ export type ProfileDoc = z.infer<typeof ProfileDocSchema>;
69
+
70
+ const OBJECTIVES = ["cost", "speed"] as const;
71
+ const JEV_USES = ["auto", "off"] as const;
72
+ const known = <T extends string>(values: readonly T[], v: string | undefined): v is T =>
73
+ v !== undefined && (values as readonly string[]).includes(v);
74
+
75
+ /**
76
+ * How a stored value this catherd does not know (a newer one wrote it) is read, always the cautious way:
77
+ * access as read-only, billing as the key's default mode, Jev as off, so nothing is sent or written that
78
+ * the value may not allow; an unknown notify moment is skipped.
79
+ */
80
+ const STORED_FALLBACK = {
81
+ objective: "cost",
82
+ jevUse: "off",
83
+ access: "read-only",
84
+ billing: (key: string): BillingMode => DEFAULT_BILLING[key as BillingKey] ?? "metered",
85
+ } as const;
86
+
87
+ /** A stored value this catherd does not know, with what it is read as. */
88
+ export interface UnknownValue {
89
+ path: string;
90
+ value: string;
91
+ readAs: string;
92
+ }
93
+
94
+ /** Every stored enum value in `doc` this catherd does not know (spec §3.4: newer files stay readable). */
95
+ export function unknownValues(doc: ProfileDoc): UnknownValue[] {
96
+ const out: UnknownValue[] = [];
97
+ const check = (path: string, values: readonly string[], v: string | undefined, readAs: string) => {
98
+ if (v !== undefined && !values.includes(v)) out.push({ path, value: v, readAs });
99
+ };
100
+ check("objective", OBJECTIVES, doc.objective, STORED_FALLBACK.objective);
101
+ check("jev.use", JEV_USES, doc.jev?.use, STORED_FALLBACK.jevUse);
102
+ for (const [k, v] of Object.entries(doc.billing ?? {}))
103
+ check(`billing.${k}`, BILLING_MODES, v, STORED_FALLBACK.billing(k));
104
+ for (const role of ROLES)
105
+ check(`roles.${role}.access`, ACCESS, doc.roles?.[role]?.access, STORED_FALLBACK.access);
106
+ for (const n of doc.notify ?? []) check("notify", NOTIFY, n, "skipped");
107
+ return out;
108
+ }
109
+
110
+ // a type, not an interface, so the built-in roles can be written into a loose (indexed) document
111
+ export type RoleConfig = {
112
+ enabled: boolean;
113
+ access: Access;
114
+ /** in ladder order, each `backend:model#effort`; `claude:` runs as a native subagent, `claude-code:` headless */
115
+ rungs: string[];
116
+ defaultRung?: string;
117
+ /** spec §5: false drops a workspace-write role's network, loopback and Docker grants; absent means granted */
118
+ network?: false;
119
+ };
120
+
121
+ /** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
122
+ export interface Profile {
123
+ name: string;
124
+ objective: "cost" | "speed";
125
+ jev: { use: "auto" | "off" };
126
+ billing: Record<string, BillingMode>;
127
+ roles: Record<Role, RoleConfig>;
128
+ harness: Record<string, { isolated: boolean }>;
129
+ failover: Record<string, string>;
130
+ budget: Budget;
131
+ timeouts: { idleMin: number; wallMin: number };
132
+ preflight: { confirm: boolean };
133
+ lock: { heavy: number | "cpus/2" };
134
+ notify: NotifyMoment[];
135
+ }
136
+
137
+ const LUNA_HIGH = "codex:gpt-6-luna#high";
138
+ const SOL = (effort: string) => `codex:gpt-6-sol#${effort}`;
139
+
140
+ /**
141
+ * Spec §7.2, the owner's setup (Codex on a ChatGPT plan, a Claude plan, OpenCode Go). A role missing
142
+ * from a profile takes its entry here. Architect and verifier run as native Claude subagents (D3).
143
+ */
144
+ export const BUILTIN_ROLES: Record<Role, RoleConfig> = {
145
+ architect: { enabled: true, access: DEFAULT_ACCESS.architect, rungs: ["claude:claude-opus-5-5#high"] },
146
+ verifier: { enabled: true, access: DEFAULT_ACCESS.verifier, rungs: ["claude:claude-opus-5-5#low"] },
147
+ worker: {
148
+ enabled: true,
149
+ access: DEFAULT_ACCESS.worker,
150
+ rungs: [LUNA_HIGH, SOL("medium"), SOL("high"), SOL("xhigh")],
151
+ defaultRung: SOL("medium"),
152
+ },
153
+ reviewer: { enabled: true, access: DEFAULT_ACCESS.reviewer, rungs: [SOL("high")] },
154
+ "ui-reviewer": { enabled: true, access: DEFAULT_ACCESS["ui-reviewer"], rungs: [SOL("medium")] },
155
+ artist: { enabled: true, access: DEFAULT_ACCESS.artist, rungs: [SOL("medium")] },
156
+ writer: { enabled: true, access: DEFAULT_ACCESS.writer, rungs: [LUNA_HIGH] },
157
+ researcher: { enabled: true, access: DEFAULT_ACCESS.researcher, rungs: [LUNA_HIGH] },
158
+ };
159
+
160
+ /**
161
+ * Spec 1.1 §11: each shipped worker rung fails over to the best stand-in the shipped catalog offers
162
+ * (`rankStandIns` over `catalogRungs`, pinned by test/domain/failover.test.ts): on another quota, paid
163
+ * from a plan, and no downgrade on the dims the rung's bars use. Go serves GPT-6 Luna itself; Kimi K3
164
+ * borrows Sol medium's scores (a shipped treat-like). Sol high and xhigh have no such stand-in on the
165
+ * owner's plans, so they have none: a limit on them pauses the lane instead of dropping it a tier.
166
+ */
167
+ export const DEFAULT_FAILOVER: Record<string, string> = {
168
+ [LUNA_HIGH]: "opencode:opencode-go/gpt-6-luna#high",
169
+ [SOL("medium")]: "opencode:opencode-go/kimi-k3#max",
170
+ };
171
+
172
+ /** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
173
+ const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
174
+
175
+ /** The full default profile document, every field written out: what `init` and `profile new` save. */
176
+ export function defaultProfileDoc(name = "default"): ProfileDoc {
177
+ return {
178
+ schema: PROFILE_SCHEMA,
179
+ name,
180
+ objective: "cost",
181
+ jev: { use: "auto" },
182
+ billing: Object.fromEntries(WRITTEN_BILLING.map((k) => [k, DEFAULT_BILLING[k]])),
183
+ roles: structuredClone(BUILTIN_ROLES),
184
+ harness: {
185
+ codex: { isolated: false },
186
+ "claude-code": { isolated: false },
187
+ opencode: { isolated: false },
188
+ },
189
+ failover: { ...DEFAULT_FAILOVER },
190
+ budget: {},
191
+ timeouts: { idleMin: 15, wallMin: 90 },
192
+ preflight: { confirm: false },
193
+ lock: { heavy: "cpus/2" },
194
+ notify: [...NOTIFY],
195
+ };
196
+ }
197
+
198
+ /** Spec §7.1: every field a document leaves out takes its default; a missing role takes the built-in one. */
199
+ export function resolveProfile(doc: ProfileDoc, name: string): Profile {
200
+ const roles = {} as Record<Role, RoleConfig>;
201
+ for (const role of ROLES) {
202
+ const d = doc.roles?.[role];
203
+ const b = BUILTIN_ROLES[role];
204
+ // a role that lists its own rungs never inherits the built-in default rung, which may not be among them
205
+ const defaultRung = d?.defaultRung ?? (d?.rungs === undefined ? b.defaultRung : undefined);
206
+ roles[role] = {
207
+ enabled: d?.enabled ?? b.enabled,
208
+ access:
209
+ d?.access === undefined
210
+ ? DEFAULT_ACCESS[role]
211
+ : known(ACCESS, d.access)
212
+ ? d.access
213
+ : STORED_FALLBACK.access,
214
+ rungs: [...(d?.rungs ?? b.rungs)],
215
+ ...(defaultRung ? { defaultRung } : {}),
216
+ ...(d?.network === false ? { network: false as const } : {}),
217
+ };
218
+ }
219
+ const harness: Profile["harness"] = {};
220
+ for (const k of new Set([...HARNESS_KEYS, ...Object.keys(doc.harness ?? {})]))
221
+ harness[k] = { isolated: doc.harness?.[k]?.isolated ?? false };
222
+ const budget: Budget = {};
223
+ for (const k of ["minutes", "tokens", "usd"] as const) {
224
+ const v = doc.budget?.[k];
225
+ if (v !== undefined) budget[k] = v;
226
+ }
227
+ const billing: Record<string, BillingMode> = { ...DEFAULT_BILLING };
228
+ for (const [k, v] of Object.entries(doc.billing ?? {}))
229
+ billing[k] = known(BILLING_MODES, v) ? v : STORED_FALLBACK.billing(k);
230
+ const use = doc.jev?.use;
231
+ return {
232
+ name,
233
+ objective: known(OBJECTIVES, doc.objective) ? doc.objective : STORED_FALLBACK.objective,
234
+ jev: { use: use === undefined ? "auto" : known(JEV_USES, use) ? use : STORED_FALLBACK.jevUse },
235
+ billing,
236
+ roles,
237
+ harness,
238
+ failover: { ...doc.failover },
239
+ budget,
240
+ timeouts: { idleMin: doc.timeouts?.idleMin ?? 15, wallMin: doc.timeouts?.wallMin ?? 90 },
241
+ preflight: { confirm: doc.preflight?.confirm ?? false },
242
+ lock: { heavy: doc.lock?.heavy ?? "cpus/2" },
243
+ notify: doc.notify === undefined ? [...NOTIFY] : doc.notify.filter((n) => known(NOTIFY, n)),
244
+ };
245
+ }
246
+
247
+ /** Spec §7.3: `catherd-<profile>-<role>-<model slug>-<effort>`, the native subagent for a `claude:` rung. */
248
+ export function agentName(profile: string, role: Role, rung: string): string {
249
+ const r = parseRung(rung);
250
+ return `catherd-${profile}-${role}-${slug(r.model)}-${slug(r.effort)}`;
251
+ }
252
+
253
+ // Patches: what profile_set and `catherd profile set` accept. Strict at every level, so a misspelt key is
254
+ // refused with E_INPUT_INVALID instead of dropped (plan-2 review m9). `null` removes a map entry or a nullable
255
+ // field (a role's `network` among them: absent means granted).
256
+ const RolePatchSchema = z
257
+ .strictObject({
258
+ enabled: z.boolean(),
259
+ access: z.enum(ACCESS),
260
+ rungs: z.array(RungSchema),
261
+ defaultRung: RungSchema.nullable(),
262
+ network: z.boolean().nullable(),
263
+ })
264
+ .partial();
265
+
266
+ export const ProfilePatchSchema = z.strictObject({
267
+ objective: z.enum(["cost", "speed"]).optional(),
268
+ jev: z.strictObject({ use: z.enum(["auto", "off"]) }).optional(),
269
+ billing: z.partialRecord(z.enum(BILLING_KEYS), z.enum(BILLING_MODES).nullable()).optional(),
270
+ roles: z.partialRecord(z.enum(ROLES), RolePatchSchema).optional(),
271
+ harness: z.partialRecord(z.enum(HARNESS_KEYS), z.strictObject({ isolated: z.boolean() })).optional(),
272
+ failover: z.record(RungSchema, RungSchema.nullable()).optional(),
273
+ budget: z
274
+ .strictObject({ minutes: positive.nullable(), tokens: positive.nullable(), usd: positive.nullable() })
275
+ .partial()
276
+ .optional(),
277
+ timeouts: z.strictObject({ idleMin: positive, wallMin: positive }).partial().optional(),
278
+ preflight: z.strictObject({ confirm: z.boolean() }).optional(),
279
+ lock: z.strictObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]) }).optional(),
280
+ notify: z.array(z.enum(NOTIFY)).optional(),
281
+ });
282
+ export type ProfilePatch = z.infer<typeof ProfilePatchSchema>;
283
+
284
+ /** RFC 7396 merge: objects merge key by key, `null` deletes, anything else (arrays too) replaces. */
285
+ function mergePatch(target: unknown, patch: unknown): unknown {
286
+ if (!isPlain(patch)) return patch;
287
+ const out: Record<string, unknown> = isPlain(target) ? { ...target } : {};
288
+ for (const [k, v] of Object.entries(patch)) {
289
+ if (v === null) delete out[k];
290
+ else out[k] = mergePatch(out[k], v);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /** The document with `patch` applied; every field the patch does not name, known or not, is kept. */
296
+ export function applyPatch(doc: ProfileDoc, patch: ProfilePatch): ProfileDoc {
297
+ return ProfileDocSchema.parse(mergePatch(doc, patch));
298
+ }
299
+
300
+ /** Map fields whose keys are rungs or billing keys, which may hold dots: the rest of the path is one key. */
301
+ const MAP_FIELDS = new Set(["failover", "billing"]);
302
+ const LIST_FIELDS = (keys: string[]) =>
303
+ (keys[0] === "roles" && keys[2] === "rungs") || (keys[0] === "notify" && keys.length === 1);
304
+
305
+ /**
306
+ * `catherd profile set <path> <value>` as a patch. The value is JSON when it parses (numbers, booleans,
307
+ * `null`, arrays), else the plain word; a rung list may also be comma-separated.
308
+ */
309
+ export function patchAt(path: string, raw: string): ProfilePatch {
310
+ const segs = path.split(".");
311
+ const keys =
312
+ MAP_FIELDS.has(segs[0] ?? "") && segs.length > 1 ? [segs[0] as string, segs.slice(1).join(".")] : segs;
313
+ let value: unknown;
314
+ try {
315
+ value = JSON.parse(raw);
316
+ } catch {
317
+ value = raw;
318
+ }
319
+ if (LIST_FIELDS(keys) && typeof value === "string")
320
+ value = value
321
+ .split(",")
322
+ .map((s) => s.trim())
323
+ .filter(Boolean);
324
+ const patch = keys.reduceRight<unknown>((inner, k) => ({ [k]: inner }), value);
325
+ const r = keys.some((k) => !k) ? null : ProfilePatchSchema.safeParse(patch);
326
+ if (!r?.success)
327
+ throw new CatherdError(
328
+ "E_INPUT_INVALID",
329
+ `cannot set ${path} to ${raw}${r ? `: ${z.prettifyError(r.error).replace(/\n\s*/g, " ")}` : ""}`,
330
+ {
331
+ fix: "catherd profile show --json lists the fields; e.g. catherd profile set roles.worker.access read-only",
332
+ },
333
+ );
334
+ return r.data;
335
+ }
336
+
337
+ export interface Change {
338
+ path: string;
339
+ before: unknown;
340
+ after: unknown;
341
+ }
342
+
343
+ /** Leaves by dotted path; an empty object has none, so `budget: {}` → `budget.usd: 5` is one change. */
344
+ function flatten(v: unknown, prefix: string, out: Map<string, unknown>): void {
345
+ if (isPlain(v)) for (const [k, x] of Object.entries(v)) flatten(x, prefix ? `${prefix}.${k}` : k, out);
346
+ else if (v !== undefined) out.set(prefix, v);
347
+ }
348
+
349
+ /** Every leaf that differs between two profiles; lists compare whole. */
350
+ export function diffProfiles(a: Profile, b: Profile): Change[] {
351
+ const fa = new Map<string, unknown>();
352
+ const fb = new Map<string, unknown>();
353
+ flatten({ ...a, name: undefined }, "", fa);
354
+ flatten({ ...b, name: undefined }, "", fb);
355
+ const changes: Change[] = [];
356
+ for (const path of [...new Set([...fa.keys(), ...fb.keys()])].sort()) {
357
+ const before = fa.get(path) ?? null;
358
+ const after = fb.get(path) ?? null;
359
+ if (JSON.stringify(before) !== JSON.stringify(after)) changes.push({ path, before, after });
360
+ }
361
+ return changes;
362
+ }
363
+
364
+ /**
365
+ * The patch that turns `a` into `b`: the leaves that differ, lists whole, a removed key as `null`. Fields
366
+ * both documents share unchanged, known or not, are left out, so applying it over a newer copy of `a`
367
+ * keeps whatever else changed there. Throws E_INPUT_INVALID when `b` holds what no patch may set.
368
+ */
369
+ export function patchBetween(a: ProfileDoc, b: ProfileDoc): ProfilePatch {
370
+ const diff = (x: unknown, y: unknown): unknown => {
371
+ if (!isPlain(x) || !isPlain(y)) return y;
372
+ const out: Record<string, unknown> = {};
373
+ for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
374
+ if (!(k in y)) out[k] = null;
375
+ else if (JSON.stringify(x[k]) !== JSON.stringify(y[k])) out[k] = diff(x[k], y[k]);
376
+ }
377
+ return out;
378
+ };
379
+ const r = ProfilePatchSchema.safeParse(diff(a, b));
380
+ if (!r.success)
381
+ throw new CatherdError(
382
+ "E_INPUT_INVALID",
383
+ `these changes cannot be saved: ${z.prettifyError(r.error).replace(/\n\s*/g, " ")}`,
384
+ {
385
+ fix: "undo the last change, or edit the field with catherd profile set",
386
+ },
387
+ );
388
+ return r.data;
389
+ }