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
package/src/routing/catalog.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { createFetch } from "ofetch";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { assetPath, readJsonFile } from "../files.ts";
|
|
6
|
-
import { configDir, dataDir } from "../paths.ts";
|
|
7
|
-
import {
|
|
8
|
-
type Capabilities,
|
|
9
|
-
type Catalog,
|
|
10
|
-
type CatalogEntry,
|
|
11
|
-
type CatalogModel,
|
|
12
|
-
DIFFICULTIES,
|
|
13
|
-
KINDS,
|
|
14
|
-
ROLE_NEEDS,
|
|
15
|
-
type Role,
|
|
16
|
-
type RungId,
|
|
17
|
-
} from "../types.ts";
|
|
18
|
-
|
|
19
|
-
const Caps = z.object({
|
|
20
|
-
toolCall: z.boolean(),
|
|
21
|
-
imageIn: z.boolean(),
|
|
22
|
-
imageOut: z.boolean(),
|
|
23
|
-
reasoning: z.boolean(),
|
|
24
|
-
context: z.number(),
|
|
25
|
-
});
|
|
26
|
-
const Model = z.object({
|
|
27
|
-
id: z.string().min(1),
|
|
28
|
-
backend: z.enum(["codex", "opencode", "claude"]),
|
|
29
|
-
efforts: z.array(z.string().min(1)).min(1),
|
|
30
|
-
capabilities: Caps,
|
|
31
|
-
});
|
|
32
|
-
const Scores = z.object({
|
|
33
|
-
repo_code: z.number().optional(),
|
|
34
|
-
terminal: z.number().optional(),
|
|
35
|
-
honesty: z.number().optional(),
|
|
36
|
-
secs_per_task: z.number().optional(),
|
|
37
|
-
});
|
|
38
|
-
const Bar = z.object({
|
|
39
|
-
repo_code: z.number().optional(),
|
|
40
|
-
terminal: z.number().optional(),
|
|
41
|
-
honesty: z.number().optional(),
|
|
42
|
-
});
|
|
43
|
-
const Rung = z.string().regex(/^.+#[^#]+$/, "expected model#effort");
|
|
44
|
-
|
|
45
|
-
const CatalogSchema = z.object({
|
|
46
|
-
version: z.string(),
|
|
47
|
-
models: z.array(Model),
|
|
48
|
-
entries: z.array(z.object({ rung: Rung, scores: Scores, costRank: z.number() })),
|
|
49
|
-
bars: z.record(z.enum(KINDS), z.record(z.enum(DIFFICULTIES), Bar)),
|
|
50
|
-
treatLike: z.record(Rung, Rung),
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
const OverrideSchema = z.object({
|
|
54
|
-
models: z
|
|
55
|
-
.record(
|
|
56
|
-
z.string().min(1),
|
|
57
|
-
z
|
|
58
|
-
.object({ backend: Model.shape.backend, efforts: Model.shape.efforts, capabilities: Caps.partial() })
|
|
59
|
-
.partial(),
|
|
60
|
-
)
|
|
61
|
-
.optional(),
|
|
62
|
-
entries: z
|
|
63
|
-
.record(Rung, z.object({ scores: Scores.optional(), costRank: z.number().optional() }))
|
|
64
|
-
.optional(),
|
|
65
|
-
bars: z.partialRecord(z.enum(KINDS), z.partialRecord(z.enum(DIFFICULTIES), Bar)).optional(),
|
|
66
|
-
treatLike: z.record(Rung, Rung).optional(),
|
|
67
|
-
});
|
|
68
|
-
export type CatalogOverride = z.infer<typeof OverrideSchema>;
|
|
69
|
-
|
|
70
|
-
const SnapshotSchema = z.object({
|
|
71
|
-
fetchedAt: z.string(),
|
|
72
|
-
models: z.record(z.string(), Caps.extend({ efforts: z.array(z.string()) })),
|
|
73
|
-
});
|
|
74
|
-
export type ModelsDevSnapshot = {
|
|
75
|
-
fetchedAt: string;
|
|
76
|
-
models: Record<string, Capabilities & { efforts: string[] }>;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
export const snapshotPath = (): string => join(dataDir(), "models-dev.json");
|
|
80
|
-
export const overridePath = (): string => join(configDir(), "catalog.override.json");
|
|
81
|
-
|
|
82
|
-
export function loadCatalog(): Catalog {
|
|
83
|
-
const c: Catalog = readJsonFile(CatalogSchema, assetPath("catalog/catalog.json"));
|
|
84
|
-
const snapshot = [snapshotPath(), assetPath("catalog/models-dev.json")].find((f) => existsSync(f));
|
|
85
|
-
if (snapshot) {
|
|
86
|
-
const known = new Set(c.models.map((m) => m.id));
|
|
87
|
-
for (const [id, { efforts, ...capabilities }] of Object.entries(
|
|
88
|
-
readJsonFile(SnapshotSchema, snapshot).models,
|
|
89
|
-
)) {
|
|
90
|
-
if (!known.has(id))
|
|
91
|
-
c.models.push({ id, backend: "opencode", efforts: ["default", ...efforts], capabilities });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return existsSync(overridePath()) ? applyOverride(c, readJsonFile(OverrideSchema, overridePath())) : c;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function applyOverride(c: Catalog, o: CatalogOverride): Catalog {
|
|
98
|
-
const file = overridePath();
|
|
99
|
-
for (const [id, m] of Object.entries(o.models ?? {})) {
|
|
100
|
-
const cur = modelOf(c, id);
|
|
101
|
-
if (cur) {
|
|
102
|
-
if (m.backend) cur.backend = m.backend;
|
|
103
|
-
if (m.efforts) cur.efforts = m.efforts;
|
|
104
|
-
cur.capabilities = { ...cur.capabilities, ...m.capabilities };
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
const full = Model.safeParse({ id, ...m });
|
|
108
|
-
if (!full.success)
|
|
109
|
-
throw new Error(`catherd: ${file}: new model "${id}" needs a backend, efforts and every capability`);
|
|
110
|
-
c.models.push(full.data);
|
|
111
|
-
}
|
|
112
|
-
for (const [rung, e] of Object.entries(o.entries ?? {})) {
|
|
113
|
-
const cur = c.entries.find((x) => x.rung === rung);
|
|
114
|
-
if (cur) {
|
|
115
|
-
cur.scores = { ...cur.scores, ...e.scores };
|
|
116
|
-
cur.costRank = e.costRank ?? cur.costRank;
|
|
117
|
-
} else if (e.costRank === undefined) {
|
|
118
|
-
throw new Error(`catherd: ${file}: new entry "${rung}" needs a costRank`);
|
|
119
|
-
} else {
|
|
120
|
-
c.entries.push({ rung, scores: e.scores ?? {}, costRank: e.costRank });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
for (const kind of KINDS) {
|
|
124
|
-
for (const d of DIFFICULTIES) {
|
|
125
|
-
const bar = o.bars?.[kind]?.[d];
|
|
126
|
-
if (bar) c.bars[kind][d] = bar;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
for (const [rung, like] of Object.entries(o.treatLike ?? {})) {
|
|
130
|
-
if (!c.entries.some((e) => e.rung === like)) {
|
|
131
|
-
throw new Error(`catherd: ${file}: "${rung}" is treated like "${like}", which has no scores`);
|
|
132
|
-
}
|
|
133
|
-
c.treatLike[rung] = like;
|
|
134
|
-
}
|
|
135
|
-
return c;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function modelOf(c: Catalog, id: string): CatalogModel | undefined {
|
|
139
|
-
return c.models.find((m) => m.id === id);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function capableFor(role: Role, m: CatalogModel): boolean {
|
|
143
|
-
return Object.entries(ROLE_NEEDS[role]).every(([k, need]) => {
|
|
144
|
-
const have = m.capabilities[k as keyof Capabilities];
|
|
145
|
-
return typeof need === "number" ? Number(have) >= need : have === need;
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function entryFor(c: Catalog, rung: RungId): CatalogEntry | undefined {
|
|
150
|
-
const own = c.entries.find((e) => e.rung === rung);
|
|
151
|
-
if (own) return own;
|
|
152
|
-
const like = c.treatLike[rung];
|
|
153
|
-
const borrowed = like === undefined ? undefined : c.entries.find((e) => e.rung === like);
|
|
154
|
-
return borrowed && { ...borrowed, rung };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function isScored(c: Catalog, rung: RungId): boolean {
|
|
158
|
-
return entryFor(c, rung) !== undefined;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Merges one "treat like" into <config>/catalog.override.json, keeping every other field. */
|
|
162
|
-
export function saveTreatLike(rung: RungId, like: RungId): void {
|
|
163
|
-
const base = loadCatalog();
|
|
164
|
-
if (!base.entries.some((e) => e.rung === like)) {
|
|
165
|
-
throw new Error(`catherd: ${overridePath()}: "${rung}" is treated like "${like}", which has no scores`);
|
|
166
|
-
}
|
|
167
|
-
const file = overridePath();
|
|
168
|
-
const current: CatalogOverride = existsSync(file) ? readJsonFile(OverrideSchema, file) : {};
|
|
169
|
-
const next: CatalogOverride = { ...current, treatLike: { ...current.treatLike, [rung]: like } };
|
|
170
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
171
|
-
writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
175
|
-
|
|
176
|
-
interface ModelsDevModel {
|
|
177
|
-
tool_call?: boolean;
|
|
178
|
-
reasoning?: boolean;
|
|
179
|
-
status?: string;
|
|
180
|
-
modalities?: { input?: string[]; output?: string[] };
|
|
181
|
-
limit?: { context?: number };
|
|
182
|
-
// models.dev lists a bare "no effort" option as null alongside the named efforts.
|
|
183
|
-
reasoning_options?: { type: string; values?: (string | null)[] }[];
|
|
184
|
-
}
|
|
185
|
-
export type ModelsDevApi = Record<
|
|
186
|
-
string,
|
|
187
|
-
{ id?: string; models?: Record<string, ModelsDevModel> } | undefined
|
|
188
|
-
>;
|
|
189
|
-
|
|
190
|
-
export function trimModelsDev(api: ModelsDevApi, fetchedAt: string): ModelsDevSnapshot {
|
|
191
|
-
const models: ModelsDevSnapshot["models"] = {};
|
|
192
|
-
for (const [provider, p] of Object.entries(api)) {
|
|
193
|
-
for (const [id, m] of Object.entries(p?.models ?? {})) {
|
|
194
|
-
const imageOut = m.modalities?.output?.includes("image") ?? false;
|
|
195
|
-
if (m.status === "deprecated" || !(m.tool_call || imageOut)) continue;
|
|
196
|
-
models[`${provider}/${id}`] = {
|
|
197
|
-
toolCall: m.tool_call ?? false,
|
|
198
|
-
imageIn: m.modalities?.input?.includes("image") ?? false,
|
|
199
|
-
imageOut,
|
|
200
|
-
reasoning: m.reasoning ?? false,
|
|
201
|
-
context: m.limit?.context ?? 0,
|
|
202
|
-
efforts: (m.reasoning_options?.find((r) => r.type === "effort")?.values ?? []).filter(
|
|
203
|
-
(v): v is string => typeof v === "string",
|
|
204
|
-
),
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return { fetchedAt, models };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export async function refreshModelsDev(
|
|
212
|
-
o: { fetchImpl?: typeof fetch; to?: string } = {},
|
|
213
|
-
): Promise<{ models: number }> {
|
|
214
|
-
const api = await createFetch({ fetch: o.fetchImpl ?? globalThis.fetch })<ModelsDevApi>(MODELS_DEV_URL, {
|
|
215
|
-
retry: 2,
|
|
216
|
-
retryDelay: 1000,
|
|
217
|
-
timeout: 60_000,
|
|
218
|
-
});
|
|
219
|
-
const snap = trimModelsDev(typeof api === "object" && api !== null ? api : {}, new Date().toISOString());
|
|
220
|
-
const count = Object.keys(snap.models).length;
|
|
221
|
-
if (count === 0) throw new Error("catherd: models.dev returned no models; the previous snapshot is kept");
|
|
222
|
-
const to = o.to ?? snapshotPath();
|
|
223
|
-
mkdirSync(dirname(to), { recursive: true });
|
|
224
|
-
writeFileSync(`${to}.tmp`, `${JSON.stringify(snap)}\n`);
|
|
225
|
-
renameSync(`${to}.tmp`, to);
|
|
226
|
-
return { models: count };
|
|
227
|
-
}
|
package/src/routing/commands.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import { loadCatalog, refreshModelsDev, snapshotPath } from "./catalog.ts";
|
|
3
|
-
|
|
4
|
-
const refresh = defineCommand({
|
|
5
|
-
meta: { name: "refresh", description: "Refresh the local models.dev snapshot" },
|
|
6
|
-
async run() {
|
|
7
|
-
try {
|
|
8
|
-
const { models } = await refreshModelsDev();
|
|
9
|
-
console.log(
|
|
10
|
-
`catherd: ${models} models from models.dev → ${snapshotPath()} (catalog: ${loadCatalog().models.length} models)`,
|
|
11
|
-
);
|
|
12
|
-
} catch (e) {
|
|
13
|
-
console.error(`catherd: catalog refresh failed: ${(e as Error).message}`);
|
|
14
|
-
process.exitCode = 1;
|
|
15
|
-
}
|
|
16
|
-
},
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export const catalogCommand = defineCommand({
|
|
20
|
-
meta: { name: "catalog", description: "The model catalog" },
|
|
21
|
-
subCommands: { refresh },
|
|
22
|
-
});
|
package/src/routing/jev.ts
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { createFetch } from "ofetch";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { appendJsonl } from "../core/runstore.ts";
|
|
7
|
-
import { configDir } from "../paths.ts";
|
|
8
|
-
|
|
9
|
-
export const JEV_URL = "https://api.typesafe.ai/v1/systemone";
|
|
10
|
-
export const JEV_MODEL = "jev-1.13.0";
|
|
11
|
-
|
|
12
|
-
export interface JevQuestion {
|
|
13
|
-
type: "choice";
|
|
14
|
-
instructions: string;
|
|
15
|
-
criteria: Record<string, string>;
|
|
16
|
-
}
|
|
17
|
-
export type JevQuestions = Record<string, JevQuestion>;
|
|
18
|
-
export interface JevAnswer {
|
|
19
|
-
choice: string;
|
|
20
|
-
confidence: number;
|
|
21
|
-
probabilities: Record<string, number>;
|
|
22
|
-
}
|
|
23
|
-
export type JevAnswers = Record<string, JevAnswer>;
|
|
24
|
-
export interface JevOpts {
|
|
25
|
-
fetchImpl?: typeof fetch;
|
|
26
|
-
retryDelayMs?: number;
|
|
27
|
-
}
|
|
28
|
-
export interface JevLogRow {
|
|
29
|
-
ts: string;
|
|
30
|
-
questions: string[];
|
|
31
|
-
answers: JevAnswers | null;
|
|
32
|
-
used: string;
|
|
33
|
-
source: "jev" | "default";
|
|
34
|
-
why: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Versioned with JEV_MODEL. difficulty, finding and same-defect are verbatim from the
|
|
38
|
-
// codex-orchestration jev.sh that ran on 2026-09-23/24; kind is new, and the recorded fixtures used it.
|
|
39
|
-
export const QUESTIONS = {
|
|
40
|
-
kind: {
|
|
41
|
-
type: "choice",
|
|
42
|
-
instructions: "What kind of work does the lane in the state describe?",
|
|
43
|
-
criteria: {
|
|
44
|
-
repo_code: "Code and tests inside an application or library repository",
|
|
45
|
-
terminal: "Shell, build, CI, infrastructure or environment work, where the terminal is the main tool",
|
|
46
|
-
ui: "User-interface work judged on screen: layout, styling, components",
|
|
47
|
-
prose: "Writing that is not code: docs, a changelog, release notes, a merge request body",
|
|
48
|
-
research: "Finding facts to answer a question, with no change to the product",
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
difficulty: {
|
|
52
|
-
type: "choice",
|
|
53
|
-
instructions: "How hard is the work described in the state for a coding agent?",
|
|
54
|
-
criteria: {
|
|
55
|
-
copy: "Mechanical: rename, move, copy an existing pattern verbatim",
|
|
56
|
-
build: "New code that follows an existing pattern in the repo",
|
|
57
|
-
logic: "Non-trivial logic: state, concurrency, data invariants, edge cases",
|
|
58
|
-
hard: "Unclear cause, cross-system design, or no pattern to follow",
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
finding: {
|
|
62
|
-
type: "choice",
|
|
63
|
-
instructions: "A reviewer reported `finding` on the work planned in `lane`. Where is the defect?",
|
|
64
|
-
criteria: {
|
|
65
|
-
design: "The plan itself is wrong: its decisions, signatures or data shapes cannot meet the goal",
|
|
66
|
-
code: "The plan is sound and the code does not implement it correctly",
|
|
67
|
-
unclear: "The finding does not say enough to tell",
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
"same-defect": {
|
|
71
|
-
type: "choice",
|
|
72
|
-
instructions:
|
|
73
|
-
"Does `after` report the same underlying defect as `before`, even if worded differently or at another line?",
|
|
74
|
-
criteria: { yes: "Same underlying defect", no: "A different defect" },
|
|
75
|
-
},
|
|
76
|
-
} satisfies JevQuestions;
|
|
77
|
-
|
|
78
|
-
const RETRY_CODES = [429, ...Array.from({ length: 100 }, (_, i) => 500 + i)];
|
|
79
|
-
|
|
80
|
-
const Reply = z.object({
|
|
81
|
-
answers: z.record(
|
|
82
|
-
z.string(),
|
|
83
|
-
z.object({
|
|
84
|
-
choice: z.string(),
|
|
85
|
-
confidence: z.number(),
|
|
86
|
-
probabilities: z.record(z.string(), z.number()).default(() => ({})),
|
|
87
|
-
}),
|
|
88
|
-
),
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const credentialsFile = () => join(configDir(), "credentials.json");
|
|
92
|
-
|
|
93
|
-
/** The key file TypeSafe's own tools use, so a user who already has one never types it again. */
|
|
94
|
-
export const typesafeKeyFile = (): string =>
|
|
95
|
-
join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "typesafe", "api_key");
|
|
96
|
-
|
|
97
|
-
export function jevKey(): string | null {
|
|
98
|
-
const env = process.env.TYPESAFE_API_KEY?.trim();
|
|
99
|
-
if (env) return env;
|
|
100
|
-
try {
|
|
101
|
-
const k: unknown = JSON.parse(readFileSync(credentialsFile(), "utf8")).typesafeApiKey;
|
|
102
|
-
if (typeof k === "string" && k.trim()) return k.trim();
|
|
103
|
-
} catch {}
|
|
104
|
-
if (!existsSync(typesafeKeyFile())) return null;
|
|
105
|
-
return readFileSync(typesafeKeyFile(), "utf8").trim() || null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function saveJevKey(key: string): void {
|
|
109
|
-
mkdirSync(configDir(), { recursive: true });
|
|
110
|
-
writeFileSync(credentialsFile(), `${JSON.stringify({ typesafeApiKey: key.trim() }, null, 2)}\n`, {
|
|
111
|
-
mode: 0o600,
|
|
112
|
-
});
|
|
113
|
-
// writeFileSync's mode applies only when it creates the file.
|
|
114
|
-
chmodSync(credentialsFile(), 0o600);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function callJev(
|
|
118
|
-
q: JevQuestions,
|
|
119
|
-
state: Record<string, string>,
|
|
120
|
-
o: JevOpts & { key?: string | null } = {},
|
|
121
|
-
): Promise<{ answers: JevAnswers | null; error: string | null }> {
|
|
122
|
-
const key = o.key === undefined ? jevKey() : o.key;
|
|
123
|
-
if (!key) return { answers: null, error: "no key" };
|
|
124
|
-
const base = o.retryDelayMs ?? 1000;
|
|
125
|
-
let raw: unknown;
|
|
126
|
-
try {
|
|
127
|
-
raw = await createFetch({ fetch: o.fetchImpl ?? globalThis.fetch })(JEV_URL, {
|
|
128
|
-
method: "POST",
|
|
129
|
-
headers: { authorization: `Bearer ${key}` },
|
|
130
|
-
body: { model: JEV_MODEL, state, questions: q },
|
|
131
|
-
retry: 3,
|
|
132
|
-
retryDelay: (ctx) => base * (4 - Number(ctx.options.retry)),
|
|
133
|
-
retryStatusCodes: RETRY_CODES,
|
|
134
|
-
timeout: 30_000,
|
|
135
|
-
});
|
|
136
|
-
} catch (e) {
|
|
137
|
-
const status = (e as { status?: number }).status;
|
|
138
|
-
return { answers: null, error: status ? `http ${status}` : "network error" };
|
|
139
|
-
}
|
|
140
|
-
const parsed = Reply.safeParse(raw);
|
|
141
|
-
if (!parsed.success) return { answers: null, error: "unexpected response" };
|
|
142
|
-
const answers: JevAnswers = {};
|
|
143
|
-
for (const [id, question] of Object.entries(q)) {
|
|
144
|
-
const a = parsed.data.answers[id];
|
|
145
|
-
if (!a || !Object.hasOwn(question.criteria, a.choice))
|
|
146
|
-
return { answers: null, error: `no valid answer to ${id}` };
|
|
147
|
-
answers[id] = a;
|
|
148
|
-
}
|
|
149
|
-
return { answers, error: null };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function logJev(runDir: string, row: Omit<JevLogRow, "ts">): void {
|
|
153
|
-
appendJsonl(join(runDir, "jev.jsonl"), { ts: new Date().toISOString(), ...row });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function askJev(
|
|
157
|
-
q: JevQuestions,
|
|
158
|
-
state: Record<string, string>,
|
|
159
|
-
o: JevOpts & { runDir?: string } = {},
|
|
160
|
-
): Promise<JevAnswers | null> {
|
|
161
|
-
const { answers, error } = await callJev(q, state, o);
|
|
162
|
-
if (o.runDir) {
|
|
163
|
-
logJev(o.runDir, {
|
|
164
|
-
questions: Object.keys(q),
|
|
165
|
-
answers,
|
|
166
|
-
used: answers
|
|
167
|
-
? Object.entries(answers)
|
|
168
|
-
.map(([id, a]) => `${id}=${a.choice}`)
|
|
169
|
-
.join(" ")
|
|
170
|
-
: "none",
|
|
171
|
-
source: answers ? "jev" : "default",
|
|
172
|
-
why: error ?? "raw answers, no threshold applied",
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
return answers;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export async function testJevKey(key: string, o: JevOpts = {}): Promise<boolean> {
|
|
179
|
-
const { answers } = await callJev(
|
|
180
|
-
{ difficulty: QUESTIONS.difficulty },
|
|
181
|
-
{ lane: "Rename the variable total to sum in src/cart.ts." },
|
|
182
|
-
{ ...o, key },
|
|
183
|
-
);
|
|
184
|
-
return answers !== null;
|
|
185
|
-
}
|
package/src/routing/route.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type Catalog,
|
|
3
|
-
DIFFICULTIES,
|
|
4
|
-
KINDS,
|
|
5
|
-
type Profile,
|
|
6
|
-
type Role,
|
|
7
|
-
type RouteDecision,
|
|
8
|
-
type RungId,
|
|
9
|
-
} from "../types.ts";
|
|
10
|
-
import { callJev, type JevAnswers, type JevOpts, logJev, QUESTIONS } from "./jev.ts";
|
|
11
|
-
import { candidates, defaultLadder, select } from "./select.ts";
|
|
12
|
-
|
|
13
|
-
const ROUTE_MIN = 0.75;
|
|
14
|
-
const FINDING_MIN = 0.75;
|
|
15
|
-
const SAME_DEFECT_MIN = 0.7;
|
|
16
|
-
// spec §11b: at 80% of budget spent, the start rung is the cheapest bar-clearing one, regardless
|
|
17
|
-
// of objective, so a run that is close to its budget stops paying for speed or a favored model.
|
|
18
|
-
const BUDGET_FORCE = 0.8;
|
|
19
|
-
|
|
20
|
-
function read<T extends string>(
|
|
21
|
-
answers: JevAnswers | null,
|
|
22
|
-
id: string,
|
|
23
|
-
options: readonly T[],
|
|
24
|
-
): { value: T; confidence: number } | null {
|
|
25
|
-
const a = answers?.[id];
|
|
26
|
-
return a && (options as readonly string[]).includes(a.choice)
|
|
27
|
-
? { value: a.choice as T, confidence: a.confidence }
|
|
28
|
-
: null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** The cheapest bar-clearing pick, whatever the profile's own objective. */
|
|
32
|
-
function cheapestPick(
|
|
33
|
-
p: Profile,
|
|
34
|
-
c: Catalog,
|
|
35
|
-
role: Role,
|
|
36
|
-
kind: string | undefined,
|
|
37
|
-
difficulty: string | undefined,
|
|
38
|
-
): { rung: RungId; ladder: RungId[] } {
|
|
39
|
-
const costP = p.objective === "cost" ? p : { ...p, objective: "cost" as const };
|
|
40
|
-
const isKind = (k: string): k is (typeof KINDS)[number] => (KINDS as readonly string[]).includes(k);
|
|
41
|
-
const isDifficulty = (d: string): d is (typeof DIFFICULTIES)[number] =>
|
|
42
|
-
(DIFFICULTIES as readonly string[]).includes(d);
|
|
43
|
-
return kind && difficulty && isKind(kind) && isDifficulty(difficulty)
|
|
44
|
-
? select(costP, c, role, kind, difficulty)
|
|
45
|
-
: defaultLadder(costP, c, role);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export async function route(
|
|
49
|
-
o: {
|
|
50
|
-
runDir: string;
|
|
51
|
-
profile: Profile;
|
|
52
|
-
catalog: Catalog;
|
|
53
|
-
role: Role;
|
|
54
|
-
laneText: string;
|
|
55
|
-
budget?: { spentFraction: number };
|
|
56
|
-
} & JevOpts,
|
|
57
|
-
): Promise<RouteDecision> {
|
|
58
|
-
const { profile: p, catalog: c, role } = o;
|
|
59
|
-
if (candidates(p, c, role).length <= 1) {
|
|
60
|
-
return {
|
|
61
|
-
kind: null,
|
|
62
|
-
difficulty: null,
|
|
63
|
-
confidence: { kind: null, difficulty: null },
|
|
64
|
-
source: "default",
|
|
65
|
-
...defaultLadder(p, c, role),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
const { answers, error } = await callJev(
|
|
69
|
-
{ kind: QUESTIONS.kind, difficulty: QUESTIONS.difficulty },
|
|
70
|
-
{ lane: o.laneText },
|
|
71
|
-
o,
|
|
72
|
-
);
|
|
73
|
-
const kind = read(answers, "kind", KINDS);
|
|
74
|
-
const difficulty = read(answers, "difficulty", DIFFICULTIES);
|
|
75
|
-
let pick = defaultLadder(p, c, role);
|
|
76
|
-
let source: "jev" | "default" = "default";
|
|
77
|
-
if (kind && difficulty && Math.min(kind.confidence, difficulty.confidence) >= ROUTE_MIN) {
|
|
78
|
-
pick = select(p, c, role, kind.value, difficulty.value);
|
|
79
|
-
source = "jev";
|
|
80
|
-
}
|
|
81
|
-
if ((o.budget?.spentFraction ?? 0) >= BUDGET_FORCE) {
|
|
82
|
-
pick = cheapestPick(p, c, role, kind?.value, difficulty?.value);
|
|
83
|
-
}
|
|
84
|
-
logJev(o.runDir, {
|
|
85
|
-
questions: ["kind", "difficulty"],
|
|
86
|
-
answers,
|
|
87
|
-
used: `${role} ${pick.rung}`,
|
|
88
|
-
source,
|
|
89
|
-
why:
|
|
90
|
-
error ??
|
|
91
|
-
`kind ${kind?.confidence}, difficulty ${difficulty?.confidence}: ${source === "jev" ? "both ≥" : "below"} ${ROUTE_MIN}`,
|
|
92
|
-
});
|
|
93
|
-
return {
|
|
94
|
-
kind: kind?.value ?? null,
|
|
95
|
-
difficulty: difficulty?.value ?? null,
|
|
96
|
-
confidence: { kind: kind?.confidence ?? null, difficulty: difficulty?.confidence ?? null },
|
|
97
|
-
source,
|
|
98
|
-
...pick,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function decide<T extends string>(
|
|
103
|
-
runDir: string,
|
|
104
|
-
id: "finding" | "same-defect",
|
|
105
|
-
state: Record<string, string>,
|
|
106
|
-
options: readonly T[],
|
|
107
|
-
min: number,
|
|
108
|
-
fallback: T,
|
|
109
|
-
o: JevOpts,
|
|
110
|
-
): Promise<{ value: T; confidence: number | null; source: "jev" | "default" }> {
|
|
111
|
-
const { answers, error } = await callJev({ [id]: QUESTIONS[id] }, state, o);
|
|
112
|
-
const a = read(answers, id, options);
|
|
113
|
-
const sure = a !== null && a.confidence >= min;
|
|
114
|
-
const value = sure ? a.value : fallback;
|
|
115
|
-
const source = sure ? "jev" : "default";
|
|
116
|
-
logJev(runDir, {
|
|
117
|
-
questions: [id],
|
|
118
|
-
answers,
|
|
119
|
-
used: value,
|
|
120
|
-
source,
|
|
121
|
-
why: error ?? `${a?.confidence} ${sure ? "≥" : "<"} ${min}`,
|
|
122
|
-
});
|
|
123
|
-
return { value, confidence: a?.confidence ?? null, source };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function askFinding(
|
|
127
|
-
runDir: string,
|
|
128
|
-
laneText: string,
|
|
129
|
-
finding: string,
|
|
130
|
-
o: JevOpts = {},
|
|
131
|
-
): Promise<{ value: "design" | "code" | "unclear"; confidence: number | null; source: "jev" | "default" }> {
|
|
132
|
-
return decide(
|
|
133
|
-
runDir,
|
|
134
|
-
"finding",
|
|
135
|
-
{ lane: laneText, finding },
|
|
136
|
-
["design", "code", "unclear"] as const,
|
|
137
|
-
FINDING_MIN,
|
|
138
|
-
"code",
|
|
139
|
-
o,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function askSameDefect(
|
|
144
|
-
runDir: string,
|
|
145
|
-
before: string,
|
|
146
|
-
after: string,
|
|
147
|
-
o: JevOpts = {},
|
|
148
|
-
): Promise<{ value: "yes" | "no"; confidence: number | null; source: "jev" | "default" }> {
|
|
149
|
-
return decide(runDir, "same-defect", { before, after }, ["yes", "no"] as const, SAME_DEFECT_MIN, "no", o);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function nextRung(ladder: RungId[], current: RungId): RungId | null {
|
|
153
|
-
const i = ladder.indexOf(current);
|
|
154
|
-
return i >= 0 && i < ladder.length - 1 ? (ladder[i + 1] as RungId) : null;
|
|
155
|
-
}
|
package/src/routing/select.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type Catalog,
|
|
3
|
-
type Difficulty,
|
|
4
|
-
type Kind,
|
|
5
|
-
type Profile,
|
|
6
|
-
type Role,
|
|
7
|
-
type RungId,
|
|
8
|
-
rungOf,
|
|
9
|
-
type ScoreDim,
|
|
10
|
-
} from "../types.ts";
|
|
11
|
-
import { capableFor, entryFor, modelOf } from "./catalog.ts";
|
|
12
|
-
|
|
13
|
-
export function candidates(p: Profile, c: Catalog, role: Role): RungId[] {
|
|
14
|
-
const rc = p.roles[role];
|
|
15
|
-
if (!rc.enabled) return [];
|
|
16
|
-
const scored: { rung: RungId; costRank: number; secs: number }[] = [];
|
|
17
|
-
for (const [id, efforts] of Object.entries(rc.models)) {
|
|
18
|
-
const m = modelOf(c, id);
|
|
19
|
-
if (!m || !capableFor(role, m)) continue;
|
|
20
|
-
for (const effort of new Set(efforts)) {
|
|
21
|
-
const rung = rungOf(id, effort);
|
|
22
|
-
const entry = m.efforts.includes(effort) ? entryFor(c, rung) : undefined;
|
|
23
|
-
if (entry) {
|
|
24
|
-
scored.push({
|
|
25
|
-
rung,
|
|
26
|
-
costRank: entry.costRank,
|
|
27
|
-
secs: entry.scores.secs_per_task ?? Number.POSITIVE_INFINITY,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
const [first, second] =
|
|
33
|
-
p.objective === "speed" ? (["secs", "costRank"] as const) : (["costRank", "secs"] as const);
|
|
34
|
-
// Infinity - Infinity is NaN, which `||` passes over and sort reads as a tie.
|
|
35
|
-
return scored.sort((a, b) => a[first] - b[first] || a[second] - b[second]).map((s) => s.rung);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function clearsBar(c: Catalog, rung: RungId, kind: Kind, difficulty: Difficulty): boolean {
|
|
39
|
-
const scores = entryFor(c, rung)?.scores;
|
|
40
|
-
if (!scores) return false;
|
|
41
|
-
return Object.entries(c.bars[kind][difficulty]).every(
|
|
42
|
-
([dim, min]) => min === undefined || (scores[dim as ScoreDim] ?? Number.NEGATIVE_INFINITY) >= min,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function defaultLadder(p: Profile, c: Catalog, role: Role): { rung: RungId; ladder: RungId[] } {
|
|
47
|
-
const all = candidates(p, c, role);
|
|
48
|
-
if (all.length === 0) {
|
|
49
|
-
throw new Error(`catherd: role "${role}" has no enabled, capable, scored model; run profile_validate`);
|
|
50
|
-
}
|
|
51
|
-
const i = Math.max(0, all.indexOf(p.roles[role].defaultRung ?? ""));
|
|
52
|
-
return { rung: all[i] as RungId, ladder: all.slice(i) };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* OVERRIDES cross-plan fix 2: under `objective: "speed"`, the objective picks only the start rung
|
|
57
|
-
* (the fastest bar-clearing candidate). The climb ladder above it is the other bar-clearing
|
|
58
|
-
* candidates at least as strong as the start on the kind's primary dimension (terminal for
|
|
59
|
-
* terminal work, repo_code otherwise), weakest first, costRank breaking ties — so a lane never
|
|
60
|
-
* climbs onto a weaker rung. `objective: "cost"` keeps the plain cost-then-secs order, unchanged.
|
|
61
|
-
*/
|
|
62
|
-
function speedLadder(c: Catalog, clearing: RungId[], kind: Kind): { rung: RungId; ladder: RungId[] } {
|
|
63
|
-
const start = clearing[0] as RungId;
|
|
64
|
-
const dim: ScoreDim = kind === "terminal" ? "terminal" : "repo_code";
|
|
65
|
-
const strengthOf = (r: RungId) => entryFor(c, r)?.scores[dim] ?? Number.NEGATIVE_INFINITY;
|
|
66
|
-
const startStrength = strengthOf(start);
|
|
67
|
-
const rest = clearing
|
|
68
|
-
.filter((r) => r !== start && strengthOf(r) >= startStrength)
|
|
69
|
-
.sort(
|
|
70
|
-
(a, b) =>
|
|
71
|
-
strengthOf(a) - strengthOf(b) || (entryFor(c, a)?.costRank ?? 0) - (entryFor(c, b)?.costRank ?? 0),
|
|
72
|
-
);
|
|
73
|
-
return { rung: start, ladder: [start, ...rest] };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function select(
|
|
77
|
-
p: Profile,
|
|
78
|
-
c: Catalog,
|
|
79
|
-
role: Role,
|
|
80
|
-
kind: Kind,
|
|
81
|
-
difficulty: Difficulty,
|
|
82
|
-
): { rung: RungId; ladder: RungId[] } {
|
|
83
|
-
const all = candidates(p, c, role);
|
|
84
|
-
if (all.length === 1) return { rung: all[0] as RungId, ladder: all };
|
|
85
|
-
const clearing = all.filter((r) => clearsBar(c, r, kind, difficulty));
|
|
86
|
-
if (clearing.length === 0) return defaultLadder(p, c, role);
|
|
87
|
-
return p.objective === "speed"
|
|
88
|
-
? speedLadder(c, clearing, kind)
|
|
89
|
-
: { rung: clearing[0] as RungId, ladder: clearing };
|
|
90
|
-
}
|