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,262 @@
1
+ import { z } from "zod";
2
+ import { type Rung, parseRung } from "./ids.ts";
3
+ import { DIFFICULTIES, type Difficulty, KINDS, type Kind } from "./lane.ts";
4
+ import type { Role } from "./roles.ts";
5
+
6
+ /** Spec §5.2: the scored dimensions, each on one named benchmark. */
7
+ export const DIMS = ["repo_code", "terminal", "honesty"] as const;
8
+ export type Dim = (typeof DIMS)[number];
9
+
10
+ /**
11
+ * Spec §7.1 `billing` keys: a rung's backend, except that opencode's Go models (`opencode-go/…`) are billed
12
+ * apart from its Zen models (`opencode/…`).
13
+ */
14
+ export const BILLING_KEYS = [
15
+ "codex",
16
+ "claude",
17
+ "claude-code",
18
+ "opencode-go",
19
+ "opencode",
20
+ "cursor",
21
+ "grok",
22
+ ] as const;
23
+ export type BillingKey = (typeof BILLING_KEYS)[number];
24
+
25
+ /** The keys a family's `on` map uses; the native `claude` pseudo-backend runs claude-code's model ids. */
26
+ const MODEL_KEYS = ["codex", "claude-code", "opencode-go", "opencode", "cursor", "grok"] as const;
27
+ type ModelKey = (typeof MODEL_KEYS)[number];
28
+
29
+ export function billingKeyOf(r: Rung): BillingKey {
30
+ if (r.backend === "opencode" && r.model.startsWith("opencode-go/")) return "opencode-go";
31
+ return r.backend;
32
+ }
33
+
34
+ const modelKeyOf = (k: BillingKey): ModelKey => (k === "claude" ? "claude-code" : k);
35
+
36
+ const BackendModelSchema = z.object({
37
+ id: z.string().min(1),
38
+ efforts: z.array(z.string().min(1)),
39
+ context: z.number().int().positive(),
40
+ });
41
+
42
+ /** `vendor` and `status` (current or legacy) stay in the data as documentation; nothing reads them. */
43
+ const FamilySchema = z.looseObject({
44
+ id: z.string().min(1),
45
+ name: z.string(),
46
+ capabilities: z.object({ toolUse: z.boolean(), imageIn: z.boolean(), reasoning: z.boolean() }),
47
+ /** API list price, dollars per million tokens */
48
+ price: z.object({ input: z.number(), cached: z.number(), output: z.number() }),
49
+ /** chatgpt-plan: messages per 5 hours relative to GPT-6 Luna (spec §5.3) */
50
+ planWeight: z.number().positive().optional(),
51
+ /** claude-plan: billed as usage credits, not from the plan's limits */
52
+ meteredOnPlan: z.boolean().default(false),
53
+ on: z.partialRecord(z.enum(MODEL_KEYS), BackendModelSchema),
54
+ notes: z.record(z.string(), z.string()).default({}),
55
+ });
56
+ export type Family = z.infer<typeof FamilySchema>;
57
+
58
+ /** `sources` (where the data came from) stays in the file as documentation; nothing reads it. */
59
+ export const ModelsFileSchema = z.looseObject({
60
+ schema: z.literal(1),
61
+ version: z.string(),
62
+ backends: z.record(
63
+ z.string(),
64
+ z.looseObject({
65
+ imageGen: z.object({ tool: z.string(), requires: z.string(), source: z.string() }).optional(),
66
+ }),
67
+ ),
68
+ families: z.array(FamilySchema),
69
+ });
70
+ export type ModelsFile = z.infer<typeof ModelsFileSchema>;
71
+
72
+ /** A canonical rung: `<canonical model id>#<effort>`, the key scores and treat-likes use. */
73
+ export const CanonicalRung = z.string().regex(/^[^:#\s]+#[^#\s]+$/, "a canonical rung is model#effort");
74
+
75
+ const ScoreSchema = z.object({
76
+ rung: CanonicalRung,
77
+ dim: z.enum(DIMS),
78
+ value: z.number(),
79
+ benchmark: z.string(),
80
+ version: z.string(),
81
+ url: z.url(),
82
+ date: z.iso.date(),
83
+ confidence: z.enum(["verified", "secondary", "inferred"]),
84
+ note: z.string().optional(),
85
+ });
86
+ export type Score = z.infer<typeof ScoreSchema>;
87
+
88
+ const BarSchema = z.partialRecord(z.enum(DIMS), z.number());
89
+ type Bars = Record<Kind, Record<Difficulty, Partial<Record<Dim, number>>>>;
90
+ const BarsSchema = z.record(z.enum(KINDS), z.record(z.enum(DIFFICULTIES), BarSchema));
91
+
92
+ /** `barsWhy` (the reasoning behind the bars) stays in the file as documentation; nothing reads it. */
93
+ export const ScoresFileSchema = z.looseObject({
94
+ schema: z.literal(1),
95
+ version: z.string(),
96
+ benchmarks: z.record(z.enum(DIMS), z.object({ benchmark: z.string(), version: z.string() })),
97
+ scores: z.array(ScoreSchema),
98
+ treatLike: z.record(CanonicalRung, z.object({ like: CanonicalRung, note: z.string() })),
99
+ bars: BarsSchema,
100
+ });
101
+ export type ScoresFile = z.infer<typeof ScoresFileSchema>;
102
+
103
+ /** `<config>/catalog.override.json` (spec §3.5): the user's treat-likes, scores and bars. */
104
+ export const OverrideSchema = z.looseObject({
105
+ schema: z.literal(1).default(1),
106
+ treatLike: z.record(CanonicalRung, CanonicalRung).default({}),
107
+ scores: z.array(ScoreSchema).default([]),
108
+ bars: z.partialRecord(z.enum(KINDS), z.partialRecord(z.enum(DIFFICULTIES), BarSchema)).default({}),
109
+ });
110
+ export type Override = z.infer<typeof OverrideSchema>;
111
+
112
+ interface Listed {
113
+ id: string;
114
+ efforts: string[];
115
+ context: number | null;
116
+ imageIn: boolean;
117
+ }
118
+
119
+ /** Everything routing reads, merged from the three layers plus the user's override and own timings. */
120
+ export interface Catalog {
121
+ families: Family[];
122
+ backends: ModelsFile["backends"];
123
+ /** canonical rung → its best-sourced value per dimension (the override's win) */
124
+ scores: Record<string, Partial<Record<Dim, Score>>>;
125
+ treatLike: Record<string, { like: string; source: "shipped" | "user" }>;
126
+ bars: Bars;
127
+ /** per backend id, the last `listModels()`; absent when never listed */
128
+ listed: Record<string, { fetchedAt: string; models: Listed[] }>;
129
+ /** `<canonical rung>|<kind or *>` → median seconds, only with ≥ 5 samples (spec §5.2) */
130
+ secs: Record<string, number>;
131
+ }
132
+
133
+ const RANK: Record<Score["confidence"], number> = { verified: 0, secondary: 1, inferred: 2 };
134
+
135
+ export function buildCatalog(o: {
136
+ models: ModelsFile;
137
+ scores: ScoresFile;
138
+ override?: Override;
139
+ listed?: Catalog["listed"];
140
+ secs?: Catalog["secs"];
141
+ }): Catalog {
142
+ const scores: Catalog["scores"] = {};
143
+ const put = (s: Score, force: boolean) => {
144
+ const cur = (scores[s.rung] ??= {});
145
+ const had = cur[s.dim];
146
+ if (force || !had || RANK[s.confidence] < RANK[had.confidence]) cur[s.dim] = s;
147
+ };
148
+ for (const s of o.scores.scores) put(s, false);
149
+ for (const s of o.override?.scores ?? []) put(s, true);
150
+ const treatLike: Catalog["treatLike"] = {};
151
+ for (const [rung, t] of Object.entries(o.scores.treatLike))
152
+ treatLike[rung] = { like: t.like, source: "shipped" };
153
+ for (const [rung, like] of Object.entries(o.override?.treatLike ?? {}))
154
+ treatLike[rung] = { like, source: "user" };
155
+ const bars = structuredClone(o.scores.bars) as Bars;
156
+ for (const kind of KINDS)
157
+ for (const d of DIFFICULTIES) {
158
+ const bar = o.override?.bars[kind]?.[d];
159
+ if (bar) bars[kind][d] = bar;
160
+ }
161
+ return {
162
+ families: o.models.families,
163
+ backends: o.models.backends,
164
+ scores,
165
+ treatLike,
166
+ bars,
167
+ listed: o.listed ?? {},
168
+ secs: o.secs ?? {},
169
+ };
170
+ }
171
+
172
+ /** What the catalog knows about one `backend:model#effort` rung. */
173
+ export interface RungInfo {
174
+ rung: string;
175
+ parsed: Rung;
176
+ key: BillingKey;
177
+ family: Family | null;
178
+ /** `<family id or backend model id>#<effort>`: the key scores, treat-likes and timings use */
179
+ canonical: string;
180
+ efforts: string[];
181
+ context: number | null;
182
+ /** true when the backend's last listing has it, false when that listing lacks it, null with no listing */
183
+ listed: boolean | null;
184
+ }
185
+
186
+ function familyOf(c: Catalog, r: Rung): Family | null {
187
+ const mk = modelKeyOf(billingKeyOf(r));
188
+ return c.families.find((f) => f.on[mk]?.id === r.model) ?? null;
189
+ }
190
+
191
+ /** The backend's listing; the native `claude` backend has none of its own: it runs claude-code's models. */
192
+ const listingOf = (c: Catalog, backend: string) => c.listed[backend === "claude" ? "claude-code" : backend];
193
+
194
+ /** A rung's catalog facts; `rung` must parse (E_ADMIT_RUNG otherwise). */
195
+ export function rungInfo(c: Catalog, rung: string): RungInfo {
196
+ const parsed = parseRung(rung);
197
+ const key = billingKeyOf(parsed);
198
+ const family = familyOf(c, parsed);
199
+ const shipped = family?.on[modelKeyOf(key)];
200
+ const listing = listingOf(c, parsed.backend);
201
+ const found = listing?.models.find((m) => m.id === parsed.model);
202
+ return {
203
+ rung,
204
+ parsed,
205
+ key,
206
+ family,
207
+ canonical: `${family?.id ?? parsed.model}#${parsed.effort}`,
208
+ efforts: found?.efforts ?? shipped?.efforts ?? [],
209
+ context: found?.context ?? shipped?.context ?? null,
210
+ listed: listing && listing.models.length > 0 ? found !== undefined : null,
211
+ };
212
+ }
213
+
214
+ /** The rung's scores: its own, else those of the rung it is treated like. null when unscored. */
215
+ export function scoresOf(
216
+ c: Catalog,
217
+ canonical: string,
218
+ ): { values: Partial<Record<Dim, number>>; records: Partial<Record<Dim, Score>>; via: string | null } | null {
219
+ const own = c.scores[canonical];
220
+ const via = own ? null : (c.treatLike[canonical]?.like ?? null);
221
+ const records = own ?? (via ? c.scores[via] : undefined);
222
+ if (!records || Object.keys(records).length === 0) return null;
223
+ const values: Partial<Record<Dim, number>> = {};
224
+ for (const d of DIMS) if (records[d]) values[d] = records[d].value;
225
+ return { values, records, via };
226
+ }
227
+
228
+ /** Spec §4 roles: what a rung must offer to be placed on a role. */
229
+ export const ROLE_NEEDS: Record<Role, { toolUse?: true; imageIn?: true; imageGen?: true }> = {
230
+ architect: { toolUse: true },
231
+ verifier: { toolUse: true },
232
+ worker: { toolUse: true },
233
+ reviewer: { toolUse: true },
234
+ "ui-reviewer": { toolUse: true, imageIn: true },
235
+ artist: { imageGen: true },
236
+ writer: { toolUse: true },
237
+ researcher: { toolUse: true },
238
+ };
239
+
240
+ /**
241
+ * A listed model catherd has no family for can use tools (Zen and Go serve coding models) and reports
242
+ * its own image input; image generation is a backend capability (spec §5.2: Codex's tool).
243
+ */
244
+ export function capableFor(c: Catalog, info: RungInfo, role: Role): boolean {
245
+ const need = ROLE_NEEDS[role];
246
+ const listedImage = listingOf(c, info.parsed.backend)?.models.find(
247
+ (m) => m.id === info.parsed.model,
248
+ )?.imageIn;
249
+ const caps = info.family?.capabilities ?? {
250
+ toolUse: true,
251
+ imageIn: listedImage ?? false,
252
+ reasoning: false,
253
+ };
254
+ if (need.toolUse && !caps.toolUse) return false;
255
+ if (need.imageIn && !caps.imageIn) return false;
256
+ if (need.imageGen && !c.backends[info.parsed.backend]?.imageGen) return false;
257
+ return true;
258
+ }
259
+
260
+ /** An effort the rung's model offers; `default` (no effort flag, spec §5.1) always is. */
261
+ export const effortOffered = (info: RungInfo): boolean =>
262
+ info.parsed.effort === "default" || info.efforts.includes(info.parsed.effort);
@@ -0,0 +1,52 @@
1
+ import { overlaps } from "./lane.ts";
2
+
3
+ /**
4
+ * `git status --porcelain=v1 -z` rows. A rename or copy (R/C in either column) carries its source path as
5
+ * the next token; both paths are reported, so moving a file out of a path counts as a change to it.
6
+ */
7
+ export function parsePorcelainZ(out: string): { xy: string; path: string }[] {
8
+ const tokens = out.split("\0");
9
+ const rows: { xy: string; path: string }[] = [];
10
+ for (let i = 0; i < tokens.length; i++) {
11
+ const t = tokens[i] as string;
12
+ if (t.length < 4) continue;
13
+ const xy = t.slice(0, 2);
14
+ rows.push({ xy, path: t.slice(3) });
15
+ if (/[RC]/.test(xy)) {
16
+ const source = tokens[++i];
17
+ if (source) rows.push({ xy, path: source });
18
+ }
19
+ }
20
+ return rows;
21
+ }
22
+
23
+ /** Repo-relative path → a fingerprint of its dirty state (status, size, mtime); clean paths are absent. */
24
+ export type Snapshot = Record<string, string>;
25
+
26
+ /**
27
+ * The paths dirty after a run whose fingerprint differs from before it, so an edit to a file that was
28
+ * already dirty counts. A path that became clean (committed or reverted meanwhile) does not.
29
+ */
30
+ export function changedPaths(before: Snapshot, after: Snapshot): string[] {
31
+ return Object.keys(after)
32
+ .filter((p) => before[p] !== after[p])
33
+ .sort();
34
+ }
35
+
36
+ /**
37
+ * Spec §4.4: `changedOwned` are the changes inside the lane's Owns; `violations` the ones outside it
38
+ * that no overlapping dispatch owns either. `strict` is false for a laneless dispatch that may write
39
+ * (it has no Owns to break), so it reports no violations.
40
+ */
41
+ export function splitChanges(
42
+ changed: string[],
43
+ owns: string[],
44
+ others: string[],
45
+ strict: boolean,
46
+ ): { changedOwned: string[]; violations: string[] } {
47
+ const inside = (p: string, paths: string[]) => overlaps([p], paths).length > 0;
48
+ return {
49
+ changedOwned: changed.filter((p) => inside(p, owns)),
50
+ violations: strict ? changed.filter((p) => !inside(p, owns) && !inside(p, others)) : [],
51
+ };
52
+ }
@@ -0,0 +1,89 @@
1
+ import type { BillingKey, Family } from "./catalog.ts";
2
+
3
+ /** Spec §5.3: how the user pays for each billing key. */
4
+ export const BILLING_MODES = ["chatgpt-plan", "claude-plan", "subscription", "metered"] as const;
5
+ export type BillingMode = (typeof BILLING_MODES)[number];
6
+
7
+ /** Spec §7.1's default `billing`: the owner's Codex/ChatGPT plan, Claude plan and OpenCode Go. */
8
+ export const DEFAULT_BILLING: Record<BillingKey, BillingMode> = {
9
+ codex: "chatgpt-plan",
10
+ claude: "claude-plan",
11
+ "claude-code": "claude-plan",
12
+ "opencode-go": "subscription",
13
+ opencode: "metered",
14
+ cursor: "metered",
15
+ grok: "metered",
16
+ };
17
+
18
+ /**
19
+ * Tokens per task relative to `medium`, from GPT-6 Sol's published DeepSWE dollars per task
20
+ * (low 0.16, medium 0.38, high 0.64, xhigh 1.00, max 2.74; research 2026-09-25-models.md §3.1).
21
+ * `ultra` is `max` with parallel subagents: ×4 is catherd's estimate. An unknown variant counts as 1.
22
+ */
23
+ const EFFORT_FACTOR: Record<string, number> = {
24
+ none: 0.3,
25
+ minimal: 0.3,
26
+ low: 0.4,
27
+ medium: 1,
28
+ default: 1,
29
+ high: 1.7,
30
+ xhigh: 2.6,
31
+ max: 7.2,
32
+ ultra: 28.8,
33
+ };
34
+
35
+ /** A reference task at medium effort: uncached input, cached input and output tokens (≈ $0.38 on Sol). */
36
+ const REF_TASK = { input: 60_000, cached: 400_000, output: 18_000 };
37
+
38
+ export const effortFactor = (effort: string): number => EFFORT_FACTOR[effort] ?? 1;
39
+
40
+ /** Estimated list-price dollars for one task at `effort`. */
41
+ export function taskUsd(price: Family["price"], effort: string): number {
42
+ const perTask =
43
+ REF_TASK.input * price.input + REF_TASK.cached * price.cached + REF_TASK.output * price.output;
44
+ return (perTask / 1_000_000) * effortFactor(effort);
45
+ }
46
+
47
+ /** One GPT-6 Luna task at medium, in list-price dollars: the unit of a chatgpt-plan weight of 1. */
48
+ export const CHATGPT_UNIT_USD = 0.019;
49
+
50
+ export interface Cost {
51
+ /** 0: paid from a subscription; 1: metered. Every tier-0 rung ranks before any tier-1 rung. */
52
+ tier: 0 | 1;
53
+ /** comparable across billing modes, in list-price dollars per task; null when unknown */
54
+ value: number | null;
55
+ mode: BillingMode;
56
+ }
57
+
58
+ /**
59
+ * Spec §5.3. Every mode yields list-price dollars per task, so plans compare with each other:
60
+ * chatgpt-plan scales the official quota weight (Luna 1, Sol 20, Astra 60) by the effort factor;
61
+ * claude-plan uses API prices as the proxy, with Fable metered (catherd cannot tell Pro from Max);
62
+ * Go's share of its monthly dollar limit is dollars / limit, which orders the same as dollars.
63
+ */
64
+ export function costOf(family: Family | null, effort: string, mode: BillingMode): Cost {
65
+ if (!family) return { tier: mode === "metered" ? 1 : 0, value: null, mode };
66
+ const usd = taskUsd(family.price, effort);
67
+ switch (mode) {
68
+ case "chatgpt-plan":
69
+ return {
70
+ tier: 0,
71
+ value:
72
+ family.planWeight === undefined ? usd : family.planWeight * CHATGPT_UNIT_USD * effortFactor(effort),
73
+ mode,
74
+ };
75
+ case "claude-plan":
76
+ return { tier: family.meteredOnPlan ? 1 : 0, value: usd, mode };
77
+ case "subscription":
78
+ return { tier: 0, value: usd, mode };
79
+ case "metered":
80
+ return { tier: 1, value: usd, mode };
81
+ }
82
+ }
83
+
84
+ /** Cheaper first: tier, then value, an unknown value last. */
85
+ export function compareCost(a: Cost, b: Cost): number {
86
+ if (a.tier !== b.tier) return a.tier - b.tier;
87
+ if (a.value === null || b.value === null) return (a.value === null ? 1 : 0) - (b.value === null ? 1 : 0);
88
+ return a.value - b.value;
89
+ }
@@ -0,0 +1,60 @@
1
+ export type ErrorCode =
2
+ | "E_CONFIG_INVALID"
3
+ | "E_CONFIG_NEWER_SCHEMA"
4
+ | "E_CONFIG_KEYBIND"
5
+ | "E_BACKEND_MISSING"
6
+ | "E_BACKEND_TOO_OLD"
7
+ | "E_BACKEND_NOT_LOGGED_IN"
8
+ | "E_BACKEND_MODEL_UNKNOWN"
9
+ | "E_RUN_NOT_FOUND"
10
+ | "E_RUN_CORRUPT"
11
+ | "E_RUN_BUDGET"
12
+ | "E_RUN_COMMIT"
13
+ | "E_RUN_NOT_LIVE"
14
+ | "E_ADMIT_RUNG"
15
+ | "E_ADMIT_DUPLICATE"
16
+ | "E_ADMIT_OVERLAP"
17
+ | "E_ADMIT_ID"
18
+ | "E_ADMIT_THREAD"
19
+ | "E_LANE_INVALID"
20
+ | "E_LAND_GATE"
21
+ | "E_CLIMB_DESIGN"
22
+ | "E_JEV_KEY"
23
+ | "E_JEV_NETWORK"
24
+ | "E_JEV_RESPONSE"
25
+ | "E_IO_LOCK"
26
+ | "E_IO_WRITE"
27
+ | "E_IO_PATH"
28
+ | "E_IO_UNEXPECTED"
29
+ | "E_INPUT_INVALID"
30
+ | "E_RUNTIME_TOO_OLD";
31
+
32
+ /**
33
+ * How to get a newer catherd. `bunx catherd-cli@latest` alone opens the newest one's dashboard and upgrades
34
+ * nothing that is installed (audit N12).
35
+ */
36
+ export const UPGRADE = "bun add -g catherd-cli@latest (or run bunx catherd-cli@latest <command>)";
37
+
38
+ /** Every failure catherd reports: a stable code, a message, and the exact action that fixes it. */
39
+ export class CatherdError extends Error {
40
+ readonly code: ErrorCode;
41
+ readonly fix: string | undefined;
42
+
43
+ constructor(code: ErrorCode, message: string, opts: { fix?: string; cause?: unknown } = {}) {
44
+ super(message, opts.cause === undefined ? undefined : { cause: opts.cause });
45
+ this.name = "CatherdError";
46
+ this.code = code;
47
+ this.fix = opts.fix;
48
+ }
49
+
50
+ toJSON(): { code: ErrorCode; message: string; fix?: string } {
51
+ return this.fix === undefined
52
+ ? { code: this.code, message: this.message }
53
+ : { code: this.code, message: this.message, fix: this.fix };
54
+ }
55
+ }
56
+
57
+ export const isCatherdError = (e: unknown): e is CatherdError => e instanceof CatherdError;
58
+
59
+ /** The message of anything thrown: an Error's message, else the value as a string. */
60
+ export const errorMessage = (e: unknown): string => (e instanceof Error ? e.message : String(e));
@@ -0,0 +1,119 @@
1
+ import { billingKeyOf, type Catalog, DIMS, type Dim, rungInfo, scoresOf } from "./catalog.ts";
2
+ import { type BillingMode, compareCost, type Cost, costOf, DEFAULT_BILLING } from "./cost.ts";
3
+ import { type Rung, tryParseRung } from "./ids.ts";
4
+ import { DIFFICULTIES, KINDS } from "./lane.ts";
5
+
6
+ /**
7
+ * Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
8
+ * quota, except that the native `claude` path and headless `claude-code` both draw on the Claude plan.
9
+ */
10
+ export const quotaOf = (r: Rung): string => (billingKeyOf(r) === "claude" ? "claude-code" : billingKeyOf(r));
11
+
12
+ /** Whether a rung draws on the Claude plan (native `claude:` or headless `claude-code:`). */
13
+ export function claudeBilled(rung: string): boolean {
14
+ const r = tryParseRung(rung);
15
+ return r !== null && quotaOf(r) === "claude-code";
16
+ }
17
+
18
+ /** A rung's scores by dim, its own or borrowed through a treat-like; null when unscored or not a rung. */
19
+ export function valuesOf(c: Catalog, rung: string): Partial<Record<Dim, number>> | null {
20
+ try {
21
+ return scoresOf(c, rungInfo(c, rung).canonical)?.values ?? null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** The dims used by the routing bars (kind × difficulty) the rung clears: what makes it fit its lanes. */
28
+ export function barDims(c: Catalog, rung: string): Dim[] {
29
+ const v = valuesOf(c, rung);
30
+ if (!v) return [];
31
+ const used = new Set<Dim>();
32
+ for (const kind of KINDS)
33
+ for (const d of DIFFICULTIES) {
34
+ const bar = Object.entries(c.bars[kind][d]).filter(([, min]) => min !== undefined) as [Dim, number][];
35
+ if (bar.every(([dim, min]) => (v[dim] ?? Number.NEGATIVE_INFINITY) >= min))
36
+ for (const [dim] of bar) used.add(dim);
37
+ }
38
+ return DIMS.filter((d) => used.has(d));
39
+ }
40
+
41
+ /**
42
+ * Spec 1.1 §11: the dims, among those the rung's bars use, on which `standIn` scores below `rung` (a
43
+ * missing score counts as below). Empty means the stand-in clears every bar the rung clears, at least as
44
+ * well: no downgrade.
45
+ */
46
+ export function downgradeDims(c: Catalog, rung: string, standIn: string): Dim[] {
47
+ const a = valuesOf(c, rung);
48
+ if (!a) return [];
49
+ const b = valuesOf(c, standIn) ?? {};
50
+ return barDims(c, rung).filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
51
+ }
52
+
53
+ /**
54
+ * Spec 1.1 §11 "the ladder goes down": the dims both rungs are scored on where `upper` (the later rung)
55
+ * scores below `lower`, when it scores above it on none of them. Empty when it does not go down.
56
+ */
57
+ export function ladderDropDims(c: Catalog, lower: string, upper: string): Dim[] {
58
+ const a = valuesOf(c, lower);
59
+ const b = valuesOf(c, upper);
60
+ if (!a || !b) return [];
61
+ const shared = DIMS.filter((d) => a[d] !== undefined && b[d] !== undefined);
62
+ if (shared.some((d) => (b[d] as number) > (a[d] as number))) return [];
63
+ return shared.filter((d) => (b[d] as number) < (a[d] as number));
64
+ }
65
+
66
+ const costFor = (c: Catalog, billing: Partial<Record<string, BillingMode>>, rung: string): Cost => {
67
+ const info = rungInfo(c, rung);
68
+ return costOf(info.family, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]);
69
+ };
70
+
71
+ /**
72
+ * Spec 1.1 §11: the rungs of `pool` that can stand in for `rung`, best first. A stand-in is on another
73
+ * quota, scored, paid from a plan or subscription under `billing` (a metered one spends money nobody chose
74
+ * to), startable by `dispatch` (not a native `claude:` subagent), and no downgrade on the rung's bar dims.
75
+ * Claude-billed stand-ins rank last, then the cheapest first.
76
+ */
77
+ export function rankStandIns(
78
+ c: Catalog,
79
+ billing: Partial<Record<string, BillingMode>>,
80
+ rung: string,
81
+ pool: readonly string[],
82
+ ): string[] {
83
+ const from = tryParseRung(rung);
84
+ if (!from) return [];
85
+ const fits = [...new Set(pool)].filter((x) => {
86
+ const r = tryParseRung(x);
87
+ if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !valuesOf(c, x)) return false;
88
+ if (costFor(c, billing, x).tier === 1) return false;
89
+ return downgradeDims(c, rung, x).length === 0;
90
+ });
91
+ return fits.sort(
92
+ (a, b) =>
93
+ Number(claudeBilled(a)) - Number(claudeBilled(b)) ||
94
+ compareCost(costFor(c, billing, a), costFor(c, billing, b)) ||
95
+ a.localeCompare(b),
96
+ );
97
+ }
98
+
99
+ /** How a family's `on` key or a model id's prefix maps to the backend that runs it. */
100
+ const BACKEND_OF_KEY: Record<string, string> = { "opencode-go": "opencode" };
101
+
102
+ /**
103
+ * Every rung the catalog knows how to name: each shipped family's model on each backend at each effort,
104
+ * each listed model at each effort, and each treat-like whose model is an opencode id (`opencode-go/…`,
105
+ * `opencode/…`), such as the shipped Kimi K3 stand-in. Sorted, without duplicates.
106
+ */
107
+ export function catalogRungs(c: Catalog): string[] {
108
+ const out = new Set<string>();
109
+ for (const f of c.families)
110
+ for (const [key, m] of Object.entries(f.on)) {
111
+ if (!m) continue;
112
+ for (const e of m.efforts) out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
113
+ }
114
+ for (const [backend, l] of Object.entries(c.listed))
115
+ for (const m of l.models) for (const e of m.efforts) out.add(`${backend}:${m.id}#${e}`);
116
+ for (const canonical of Object.keys(c.treatLike))
117
+ if (/^opencode(-go)?\//.test(canonical)) out.add(`opencode:${canonical}`);
118
+ return [...out].sort();
119
+ }
@@ -0,0 +1,22 @@
1
+ import type { RunRecord } from "./record.ts";
2
+
3
+ /** Spec §4.4: what the orchestrator does next, read off one finished record. `dir` is the dispatch folder, relative to the run. */
4
+ export function dispatchHints(r: RunRecord, owns: string[], dir: string): string[] {
5
+ const h: string[] = [];
6
+ if (r.status === "limit") h.push(`limit: ${r.backend} hit a usage limit on ${r.rung}`);
7
+ if (r.status === "cli-too-old") h.push(`cli-too-old: ${r.error?.message ?? `upgrade ${r.backend}`}`);
8
+ if (r.status === "failed") h.push(`failed: read ${dir}/stderr`);
9
+ if (r.gitUnavailable) h.push("git-unavailable: changed files unknown");
10
+ if (r.replyStatus === "refused" || r.replyStatus === "blocked") h.push(`climb: ${r.replyStatus}`);
11
+ else if (
12
+ r.status === "ok" &&
13
+ !r.gitUnavailable &&
14
+ r.access !== "read-only" &&
15
+ owns.length > 0 &&
16
+ r.changedOwned.length === 0
17
+ )
18
+ h.push("climb: unchanged");
19
+ if (r.violations.length > 0) h.push(`violation: ${r.violations.join(", ")}`);
20
+ if (r.threadHeavy) h.push("thread-heavy");
21
+ return h;
22
+ }