catherd-cli 0.2.0 → 1.0.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.
- package/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -25
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -69
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -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,58 @@
|
|
|
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_JEV_KEY"
|
|
21
|
+
| "E_JEV_NETWORK"
|
|
22
|
+
| "E_JEV_RESPONSE"
|
|
23
|
+
| "E_IO_LOCK"
|
|
24
|
+
| "E_IO_WRITE"
|
|
25
|
+
| "E_IO_PATH"
|
|
26
|
+
| "E_IO_UNEXPECTED"
|
|
27
|
+
| "E_INPUT_INVALID"
|
|
28
|
+
| "E_RUNTIME_TOO_OLD";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How to get a newer catherd. `bunx catherd-cli@latest` alone opens the newest one's dashboard and upgrades
|
|
32
|
+
* nothing that is installed (audit N12).
|
|
33
|
+
*/
|
|
34
|
+
export const UPGRADE = "bun add -g catherd-cli@latest (or run bunx catherd-cli@latest <command>)";
|
|
35
|
+
|
|
36
|
+
/** Every failure catherd reports: a stable code, a message, and the exact action that fixes it. */
|
|
37
|
+
export class CatherdError extends Error {
|
|
38
|
+
readonly code: ErrorCode;
|
|
39
|
+
readonly fix: string | undefined;
|
|
40
|
+
|
|
41
|
+
constructor(code: ErrorCode, message: string, opts: { fix?: string; cause?: unknown } = {}) {
|
|
42
|
+
super(message, opts.cause === undefined ? undefined : { cause: opts.cause });
|
|
43
|
+
this.name = "CatherdError";
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.fix = opts.fix;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
toJSON(): { code: ErrorCode; message: string; fix?: string } {
|
|
49
|
+
return this.fix === undefined
|
|
50
|
+
? { code: this.code, message: this.message }
|
|
51
|
+
: { code: this.code, message: this.message, fix: this.fix };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const isCatherdError = (e: unknown): e is CatherdError => e instanceof CatherdError;
|
|
56
|
+
|
|
57
|
+
/** The message of anything thrown: an Error's message, else the value as a string. */
|
|
58
|
+
export const errorMessage = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CatherdError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
/** Run, role, lane and dispatch ids end up in file names: no separator, no leading dot or dash. */
|
|
4
|
+
export const ID_PATTERN = /^[A-Za-z0-9][\w.-]{0,127}$/;
|
|
5
|
+
|
|
6
|
+
export function assertId(kind: string, value: string): string {
|
|
7
|
+
if (!ID_PATTERN.test(value) || value.includes(".."))
|
|
8
|
+
throw new CatherdError("E_ADMIT_ID", `bad ${kind} id "${value}"`, {
|
|
9
|
+
fix: "use letters, digits, '.', '_' and '-', starting with a letter or digit",
|
|
10
|
+
});
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The backends that run as a process through a `BackendAdapter` (cursor and grok: plan 8). */
|
|
15
|
+
export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok"] as const;
|
|
16
|
+
|
|
17
|
+
/** `claude` is the native Claude Code subagent path (no process); the rest are adapters. */
|
|
18
|
+
const RUNG_BACKENDS = [...ADAPTER_IDS, "claude"] as const;
|
|
19
|
+
type RungBackend = (typeof RUNG_BACKENDS)[number];
|
|
20
|
+
|
|
21
|
+
export interface Rung {
|
|
22
|
+
backend: RungBackend;
|
|
23
|
+
model: string;
|
|
24
|
+
effort: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A model id or effort starts with a letter or digit and holds no space, so it can never read as a flag. */
|
|
28
|
+
const MODEL = /^[A-Za-z0-9][^\s#]*$/;
|
|
29
|
+
const EFFORT = /^[A-Za-z0-9][\w-]*$/;
|
|
30
|
+
|
|
31
|
+
export function parseRung(s: string): Rung {
|
|
32
|
+
const colon = s.indexOf(":");
|
|
33
|
+
const hash = s.lastIndexOf("#");
|
|
34
|
+
const backend = s.slice(0, Math.max(colon, 0));
|
|
35
|
+
const model = s.slice(colon + 1, hash);
|
|
36
|
+
const effort = s.slice(hash + 1);
|
|
37
|
+
if (
|
|
38
|
+
colon < 1 ||
|
|
39
|
+
hash <= colon + 1 ||
|
|
40
|
+
!MODEL.test(model) ||
|
|
41
|
+
!EFFORT.test(effort) ||
|
|
42
|
+
!(RUNG_BACKENDS as readonly string[]).includes(backend)
|
|
43
|
+
)
|
|
44
|
+
throw new CatherdError("E_ADMIT_RUNG", `bad rung "${s}"`, {
|
|
45
|
+
fix: "write it as <backend>:<model>#<effort>",
|
|
46
|
+
});
|
|
47
|
+
return { backend: backend as RungBackend, model, effort };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `parseRung`, or null for a malformed rung (profile validation is what reports it). */
|
|
51
|
+
export function tryParseRung(s: string): Rung | null {
|
|
52
|
+
try {
|
|
53
|
+
return parseRung(s);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const formatRung = (r: Rung): string => `${r.backend}:${r.model}#${r.effort}`;
|
|
60
|
+
|
|
61
|
+
const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
62
|
+
|
|
63
|
+
let lastTime = -1;
|
|
64
|
+
let lastRand: number[] = [];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A monotonic ULID: 10 chars of millisecond time, then 16 random, so ids sort by creation.
|
|
68
|
+
* Within one millisecond (or when the clock steps back) the previous time is reused and
|
|
69
|
+
* the random part is incremented, so ids made in one process are strictly increasing.
|
|
70
|
+
*/
|
|
71
|
+
export function newDispatchId(now: number = Date.now()): string {
|
|
72
|
+
if (now <= lastTime && increment(lastRand)) now = lastTime;
|
|
73
|
+
else {
|
|
74
|
+
if (now <= lastTime) now = lastTime + 1;
|
|
75
|
+
lastRand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b % 32);
|
|
76
|
+
}
|
|
77
|
+
lastTime = now;
|
|
78
|
+
let t = now;
|
|
79
|
+
let time = "";
|
|
80
|
+
for (let i = 0; i < 10; i++) {
|
|
81
|
+
time = B32.charAt(t % 32) + time;
|
|
82
|
+
t = Math.floor(t / 32);
|
|
83
|
+
}
|
|
84
|
+
return time + lastRand.map((d) => B32.charAt(d)).join("");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Adds one to base32 digits in place; false when all 16 digits overflow. */
|
|
88
|
+
function increment(digits: number[]): boolean {
|
|
89
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
90
|
+
if ((digits[i] as number) < 31) {
|
|
91
|
+
digits[i] = (digits[i] as number) + 1;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
digits[i] = 0;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|