catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CatherdError } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
export const SCORECARD_TASKS = [
|
|
5
|
+
"raqam-myanmar",
|
|
6
|
+
"catherd-findings",
|
|
7
|
+
"sanitell-payment-3",
|
|
8
|
+
"injected-lao",
|
|
9
|
+
] as const;
|
|
10
|
+
export type ScorecardTaskId = (typeof SCORECARD_TASKS)[number];
|
|
11
|
+
export const INJECTED_TASK: ScorecardTaskId = "injected-lao";
|
|
12
|
+
|
|
13
|
+
/** The spec's "no worse than 1.5", read as the four tasks' summed wall-clock within 10 % (plan 28, ruling 6). */
|
|
14
|
+
export const WALL_TOLERANCE = 0.1;
|
|
15
|
+
|
|
16
|
+
const text = z.string().min(1);
|
|
17
|
+
|
|
18
|
+
export const ScorecardTaskSchema = z.strictObject({
|
|
19
|
+
schema: z.literal(1),
|
|
20
|
+
id: z.enum(SCORECARD_TASKS),
|
|
21
|
+
title: text,
|
|
22
|
+
goal: text,
|
|
23
|
+
source: text.nullable(),
|
|
24
|
+
commit: z.string().regex(/^[0-9a-f]{40}$/),
|
|
25
|
+
plan: z.union([z.strictObject({ file: text }), z.strictObject({ repoPath: text })]),
|
|
26
|
+
inject: z
|
|
27
|
+
.strictObject({
|
|
28
|
+
message: text,
|
|
29
|
+
edits: z.array(z.strictObject({ path: text, find: text, replace: z.string() })).min(1),
|
|
30
|
+
})
|
|
31
|
+
.nullable()
|
|
32
|
+
.default(null),
|
|
33
|
+
limits: z.number().int().min(0).default(0),
|
|
34
|
+
setup: z.array(text).default([]),
|
|
35
|
+
acceptance: text,
|
|
36
|
+
acceptanceTimeoutMin: z.number().int().positive(),
|
|
37
|
+
acceptFiles: z.array(z.strictObject({ from: text, to: text })).default([]),
|
|
38
|
+
prompt: z.string().includes("{plan}"),
|
|
39
|
+
});
|
|
40
|
+
export type ScorecardTask = z.infer<typeof ScorecardTaskSchema>;
|
|
41
|
+
|
|
42
|
+
export interface Interventions {
|
|
43
|
+
parks: number;
|
|
44
|
+
answers: number;
|
|
45
|
+
takeovers: number;
|
|
46
|
+
manualDispatches: number;
|
|
47
|
+
pauses: number;
|
|
48
|
+
superseded: number;
|
|
49
|
+
chat: number;
|
|
50
|
+
/** the owner's cancel requests (a daemon run) */
|
|
51
|
+
cancels: number;
|
|
52
|
+
/** the owner's plan approvals, not a plan given as input (a daemon run) */
|
|
53
|
+
approvals: number;
|
|
54
|
+
total: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface QuotaRow {
|
|
58
|
+
backend: string;
|
|
59
|
+
runs: number;
|
|
60
|
+
input: number;
|
|
61
|
+
cached: number;
|
|
62
|
+
output: number;
|
|
63
|
+
limits: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TaskScore {
|
|
67
|
+
task: ScorecardTaskId;
|
|
68
|
+
run: string;
|
|
69
|
+
catherdVersion: string;
|
|
70
|
+
startCommit: string;
|
|
71
|
+
head: string | null;
|
|
72
|
+
milestones: string[];
|
|
73
|
+
landed: string[];
|
|
74
|
+
finished: boolean;
|
|
75
|
+
interventions: Interventions;
|
|
76
|
+
reviewRounds: number;
|
|
77
|
+
lanes: { total: number; landed: number; climbs: number };
|
|
78
|
+
wallMinutes: number;
|
|
79
|
+
pausedMinutes: number;
|
|
80
|
+
quota: { backends: QuotaRow[]; claudeAgentTokens: number };
|
|
81
|
+
acceptance: { passed: boolean; code: number | null; timedOut: boolean; tail: string[] };
|
|
82
|
+
firstTry: boolean;
|
|
83
|
+
injected: { limits: number; standInOk: boolean } | null;
|
|
84
|
+
recordedAt: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ScorecardFile {
|
|
88
|
+
schema: 1;
|
|
89
|
+
label: string;
|
|
90
|
+
tasks: Partial<Record<ScorecardTaskId, TaskScore>>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const ScorecardFileSchema = z.looseObject({
|
|
94
|
+
schema: z.literal(1),
|
|
95
|
+
label: z.string(),
|
|
96
|
+
tasks: z.record(z.string(), z.unknown()),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const count = z.number().int().min(0);
|
|
100
|
+
|
|
101
|
+
/** The fields `passBar` and the comparison table read; the rest of a row is carried untouched. */
|
|
102
|
+
const TaskScoreRowSchema = z.looseObject({
|
|
103
|
+
catherdVersion: z.string(),
|
|
104
|
+
finished: z.boolean(),
|
|
105
|
+
interventions: z.looseObject({
|
|
106
|
+
parks: count,
|
|
107
|
+
answers: count,
|
|
108
|
+
takeovers: count,
|
|
109
|
+
manualDispatches: count,
|
|
110
|
+
pauses: count,
|
|
111
|
+
superseded: count,
|
|
112
|
+
chat: count,
|
|
113
|
+
total: count,
|
|
114
|
+
}),
|
|
115
|
+
reviewRounds: count,
|
|
116
|
+
wallMinutes: z.number().min(0),
|
|
117
|
+
quota: z.looseObject({
|
|
118
|
+
backends: z.array(z.looseObject({ input: z.number().min(0), output: z.number().min(0) })),
|
|
119
|
+
}),
|
|
120
|
+
firstTry: z.boolean(),
|
|
121
|
+
injected: z.looseObject({ limits: count, standInOk: z.boolean() }).nullable(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Checks every row `passBar` consumes; a malformed one fails naming the label, the task and the field. */
|
|
125
|
+
export function assertScorecardRows(f: z.infer<typeof ScorecardFileSchema>, file: string): ScorecardFile {
|
|
126
|
+
for (const [task, row] of Object.entries(f.tasks)) {
|
|
127
|
+
const r = TaskScoreRowSchema.safeParse(row);
|
|
128
|
+
const issue = r.success ? null : r.error.issues[0];
|
|
129
|
+
const field = issue ? issue.path.join(".") || "row" : null;
|
|
130
|
+
if (!(SCORECARD_TASKS as readonly string[]).includes(task) || issue)
|
|
131
|
+
throw new CatherdError(
|
|
132
|
+
"E_INPUT_INVALID",
|
|
133
|
+
`scorecard ${f.label} (${file}): ${task}: ${field ?? "unknown task"}: ${issue?.message ?? "not one of the four tasks"}`,
|
|
134
|
+
{ fix: `fix or delete ${file}, then record the task again` },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return f as unknown as ScorecardFile;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function assertLabel(label: string): string {
|
|
141
|
+
if (!/^[a-z0-9][a-z0-9.-]{0,40}$/.test(label)) {
|
|
142
|
+
throw new CatherdError("E_INPUT_INVALID", `\`${label}\` is not a scorecard label`, {
|
|
143
|
+
fix: "a version or plan name: 1.5.0, plan-29, 2.0.0-beta.1",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return label;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Answers are shown, not added: each one closes a park that is already counted. */
|
|
150
|
+
export function interventionTotal(i: Omit<Interventions, "total">): number {
|
|
151
|
+
return (
|
|
152
|
+
i.parks + i.takeovers + i.manualDispatches + i.pauses + i.superseded + i.chat + i.cancels + i.approvals
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The injected task recovered by itself: a limit hit, a stand-in that ended ok, and nobody stepped in. */
|
|
157
|
+
export function recovered(s: TaskScore): boolean {
|
|
158
|
+
return (
|
|
159
|
+
s.injected !== null &&
|
|
160
|
+
s.injected.limits > 0 &&
|
|
161
|
+
s.injected.standInOk &&
|
|
162
|
+
s.finished &&
|
|
163
|
+
s.interventions.total === 0
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface Verdict {
|
|
168
|
+
pass: boolean;
|
|
169
|
+
failures: string[];
|
|
170
|
+
wall: { now: number; baseline: number | null };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sumWall = (f: ScorecardFile): number =>
|
|
174
|
+
SCORECARD_TASKS.reduce((n, t) => n + (f.tasks[t]?.wallMinutes ?? 0), 0);
|
|
175
|
+
|
|
176
|
+
/** The spec's pass bar. Quota, review rounds and the first try are reported, never gated. */
|
|
177
|
+
export function passBar(current: ScorecardFile, baseline: ScorecardFile | null): Verdict {
|
|
178
|
+
const failures: string[] = [];
|
|
179
|
+
for (const t of SCORECARD_TASKS) {
|
|
180
|
+
const s = current.tasks[t];
|
|
181
|
+
if (!s) failures.push(`${t}: not recorded`);
|
|
182
|
+
else if (!s.finished) failures.push(`${t}: not finished`);
|
|
183
|
+
else if (s.interventions.total > 0) failures.push(`${t}: ${s.interventions.total} intervention(s)`);
|
|
184
|
+
}
|
|
185
|
+
const injected = current.tasks[INJECTED_TASK];
|
|
186
|
+
if (injected && !recovered(injected)) failures.push(`${INJECTED_TASK}: did not recover by itself`);
|
|
187
|
+
const now = sumWall(current);
|
|
188
|
+
const full = baseline !== null && SCORECARD_TASKS.every((t) => baseline.tasks[t] !== undefined);
|
|
189
|
+
const base = full ? sumWall(baseline) : null;
|
|
190
|
+
if (base === null) failures.push("no full baseline to compare wall-clock against");
|
|
191
|
+
else if (now > base * (1 + WALL_TOLERANCE)) {
|
|
192
|
+
failures.push(
|
|
193
|
+
`wall-clock ${Math.round(now)} min against the baseline's ${Math.round(base)} min (more than 10 % slower)`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return { pass: failures.length === 0, failures, wall: { now, baseline: base } };
|
|
197
|
+
}
|
package/src/domain/select.ts
CHANGED
|
@@ -1,136 +1,293 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
capableFor,
|
|
4
|
-
type Dim,
|
|
5
|
-
effortOffered,
|
|
6
|
-
type RungInfo,
|
|
7
|
-
rungInfo,
|
|
8
|
-
scoresOf,
|
|
9
|
-
} from "./catalog.ts";
|
|
1
|
+
import { type Bars, barOf, percentilesOf } from "./bars.ts";
|
|
2
|
+
import { type Catalog, capableFor, effortOffered, type RungInfo, rungInfo } from "./catalog.ts";
|
|
10
3
|
import { type BillingMode, type Cost, compareCost, costOf, DEFAULT_BILLING } from "./cost.ts";
|
|
11
4
|
import { CatherdError } from "./errors.ts";
|
|
12
|
-
import {
|
|
13
|
-
import type
|
|
5
|
+
import { quotaOf } from "./failover.ts";
|
|
6
|
+
import { type EffortWord, effortRank, tryParseRung } from "./ids.ts";
|
|
7
|
+
import { DIFFICULTIES, type Difficulty } from "./lane.ts";
|
|
8
|
+
import { ROLES, type Role } from "./roles.ts";
|
|
14
9
|
|
|
15
10
|
/** What routing reads from a profile for one role. */
|
|
16
11
|
export interface RoutingProfile {
|
|
17
12
|
objective: "cost" | "speed";
|
|
18
13
|
billing: Partial<Record<string, BillingMode>>;
|
|
19
14
|
role: { enabled: boolean; rungs: string[]; defaultRung?: string };
|
|
15
|
+
/**
|
|
16
|
+
* spec 1.5 plan 24: the run's dispatches so far per quota (`quotaOf`), the headroom a tie between rungs of
|
|
17
|
+
* equal intelligence is broken on; absent, every quota counts 0 and the candidates' order breaks it
|
|
18
|
+
*/
|
|
19
|
+
usage?: Partial<Record<string, number>>;
|
|
20
|
+
/** `<backend>:<model>` → the highest effort routing may start (plan 33); a rung above it is skipped */
|
|
21
|
+
maxEffort?: Readonly<Record<string, string>>;
|
|
22
|
+
/** this role's bar percentiles over the profile's, laid over the shipped ones (`percentilesOf`) */
|
|
23
|
+
bars?: Bars;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a start check reads of a profile: `ProfileView` and the resolved `Profile` both satisfy it. */
|
|
27
|
+
export interface ProfileLike {
|
|
28
|
+
objective: "cost" | "speed";
|
|
29
|
+
billing: Partial<Record<string, BillingMode>>;
|
|
30
|
+
roles: Partial<Record<Role, { enabled: boolean; rungs: string[]; defaultRung?: string }>>;
|
|
31
|
+
maxEffort?: Readonly<Record<string, string>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Spec 2.0 decision 22: the roles that write code. The worker runs repo_code, terminal and ui lanes and every fix round. */
|
|
35
|
+
export const CODING_ROLES: readonly Role[] = ["worker"];
|
|
36
|
+
/** A coding role never runs below this effort, whatever the objective or the budget. */
|
|
37
|
+
export const CODING_FLOOR: EffortWord = "medium";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* False for a rung above its model's `maxEffort`, or below `CODING_FLOOR` for a coding role. A rung that does not
|
|
41
|
+
* parse, one whose effort is no effort word (`default`), and a ceiling that is no effort word all allow it.
|
|
42
|
+
*/
|
|
43
|
+
export function effortAllowed(
|
|
44
|
+
maxEffort: Readonly<Record<string, string>>,
|
|
45
|
+
role: Role,
|
|
46
|
+
rung: string,
|
|
47
|
+
): boolean {
|
|
48
|
+
const r = tryParseRung(rung);
|
|
49
|
+
const rank = r ? effortRank(r.effort) : -1;
|
|
50
|
+
if (!r || rank < 0) return true;
|
|
51
|
+
if (CODING_ROLES.includes(role) && rank < effortRank(CODING_FLOOR)) return false;
|
|
52
|
+
const cap = effortRank(maxEffort[`${r.backend}:${r.model}`] ?? "");
|
|
53
|
+
return cap < 0 || rank <= cap;
|
|
20
54
|
}
|
|
21
55
|
|
|
22
56
|
export interface Candidate {
|
|
23
57
|
rung: string;
|
|
24
58
|
info: RungInfo;
|
|
25
|
-
|
|
59
|
+
intelligence: number;
|
|
26
60
|
cost: Cost;
|
|
27
|
-
/**
|
|
28
|
-
|
|
61
|
+
/** the pair's tokens per second; null when Slopalytics has none */
|
|
62
|
+
speed: number | null;
|
|
29
63
|
}
|
|
30
64
|
|
|
31
65
|
export interface Pick {
|
|
32
66
|
rung: string;
|
|
33
67
|
ladder: string[];
|
|
68
|
+
/** no rung clears the difficulty's bar (`no rung clears hard (bar 47.1); best is …`) */
|
|
69
|
+
noClear?: string;
|
|
70
|
+
/** spec 1.5 plan 24: rungs of equal intelligence competed for the start, and how the tie was broken */
|
|
71
|
+
tie?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The enabled roles, in `ROLES` order, that the catalog can place no rung for. */
|
|
75
|
+
export function rolesWithoutRung(c: Catalog, v: ProfileLike): Role[] {
|
|
76
|
+
return ROLES.filter((role) => {
|
|
77
|
+
const r = v.roles[role];
|
|
78
|
+
return (
|
|
79
|
+
r?.enabled === true &&
|
|
80
|
+
candidates(
|
|
81
|
+
c,
|
|
82
|
+
{
|
|
83
|
+
objective: v.objective,
|
|
84
|
+
billing: v.billing,
|
|
85
|
+
role: r,
|
|
86
|
+
...(v.maxEffort ? { maxEffort: v.maxEffort } : {}),
|
|
87
|
+
},
|
|
88
|
+
role,
|
|
89
|
+
).length === 0
|
|
90
|
+
);
|
|
91
|
+
});
|
|
34
92
|
}
|
|
35
93
|
|
|
36
94
|
const byNull = (a: number | null, b: number | null) =>
|
|
37
95
|
a === null || b === null ? (a === null ? 1 : 0) - (b === null ? 1 : 0) : a - b;
|
|
38
96
|
|
|
39
|
-
function secsOf(c: Catalog, canonical: string, kind: Kind | null): number | null {
|
|
40
|
-
return c.secs[`${canonical}|${kind ?? "*"}`] ?? c.secs[`${canonical}|*`] ?? null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
97
|
/**
|
|
44
|
-
* The role's enabled rungs that the catalog can place: parseable, offered by their model, listed by
|
|
45
|
-
* the backend when it has a listing, capable for the role
|
|
46
|
-
*
|
|
47
|
-
*
|
|
98
|
+
* The role's enabled rungs that the catalog can place: parseable, indexed, offered by their model, listed by
|
|
99
|
+
* the backend when it has a listing, and capable for the role. Ordered by cost (spec §5.3), or by speed with
|
|
100
|
+
* cost breaking ties; the profile's ladder order breaks every remaining tie (spec 1.5 plan 24), so equal rungs
|
|
101
|
+
* start in the order the user wrote.
|
|
48
102
|
*/
|
|
49
|
-
export function candidates(c: Catalog, p: RoutingProfile, role: Role
|
|
103
|
+
export function candidates(c: Catalog, p: RoutingProfile, role: Role): Candidate[] {
|
|
50
104
|
if (!p.role.enabled) return [];
|
|
51
105
|
const out: Candidate[] = [];
|
|
52
|
-
|
|
106
|
+
const order = [...new Set(p.role.rungs)];
|
|
107
|
+
for (const rung of order) {
|
|
108
|
+
if (!effortAllowed(p.maxEffort ?? {}, role, rung)) continue;
|
|
53
109
|
let info: RungInfo;
|
|
54
110
|
try {
|
|
55
111
|
info = rungInfo(c, rung);
|
|
56
112
|
} catch {
|
|
57
113
|
continue;
|
|
58
114
|
}
|
|
59
|
-
if (!effortOffered(info) || info.listed === false || !capableFor(c, info, role)) continue;
|
|
60
|
-
const s = scoresOf(c, info.canonical);
|
|
61
|
-
if (!s) continue;
|
|
115
|
+
if (!info.pair || !effortOffered(info) || info.listed === false || !capableFor(c, info, role)) continue;
|
|
62
116
|
const mode = p.billing[info.key] ?? DEFAULT_BILLING[info.key];
|
|
63
117
|
out.push({
|
|
64
118
|
rung,
|
|
65
119
|
info,
|
|
66
|
-
|
|
67
|
-
cost: costOf(info.family, info.parsed.effort, mode),
|
|
68
|
-
|
|
120
|
+
intelligence: info.pair.intelligence,
|
|
121
|
+
cost: costOf(info.family, info.pair, info.parsed.effort, mode),
|
|
122
|
+
speed: info.pair.speed,
|
|
69
123
|
});
|
|
70
124
|
}
|
|
71
|
-
const
|
|
72
|
-
const
|
|
125
|
+
const written = (a: Candidate, b: Candidate) => order.indexOf(a.rung) - order.indexOf(b.rung);
|
|
126
|
+
const cost = (a: Candidate, b: Candidate) => compareCost(a.cost, b.cost) || written(a, b);
|
|
127
|
+
// fastest first, a pair with no speed last
|
|
128
|
+
const speed = (a: Candidate, b: Candidate) =>
|
|
129
|
+
byNull(a.speed === null ? null : -a.speed, b.speed === null ? null : -b.speed) ||
|
|
130
|
+
compareCost(a.cost, b.cost) ||
|
|
131
|
+
written(a, b);
|
|
73
132
|
return out.sort(p.objective === "speed" ? speed : cost);
|
|
74
133
|
}
|
|
75
134
|
|
|
76
|
-
export
|
|
77
|
-
return Object.entries(c.bars[kind][difficulty]).every(
|
|
78
|
-
([dim, min]) => min === undefined || (cand.scores[dim as Dim] ?? Number.NEGATIVE_INFINITY) >= min,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
135
|
+
export const clearsBar = (cand: Candidate, bar: number): boolean => cand.intelligence >= bar;
|
|
81
136
|
|
|
82
137
|
function noRung(role: Role): CatherdError {
|
|
83
|
-
return new CatherdError("E_CONFIG_INVALID", `role "${role}" has no enabled, capable,
|
|
84
|
-
fix:
|
|
138
|
+
return new CatherdError("E_CONFIG_INVALID", `role "${role}" has no enabled, capable, indexed rung`, {
|
|
139
|
+
fix: `pick indexed pairs: catherd catalog list --role ${role}`,
|
|
85
140
|
});
|
|
86
141
|
}
|
|
87
142
|
|
|
143
|
+
/** Spec 1.5 plan 24: how many of `rungs` (a run's dispatched rungs) drew on each quota. */
|
|
144
|
+
export function quotaUsage(rungs: readonly string[]): Record<string, number> {
|
|
145
|
+
const out: Record<string, number> = {};
|
|
146
|
+
for (const rung of rungs) {
|
|
147
|
+
const r = tryParseRung(rung);
|
|
148
|
+
if (r) out[quotaOf(r)] = (out[quotaOf(r)] ?? 0) + 1;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Equal intelligence: a tie for the start. */
|
|
154
|
+
const same = (a: Candidate, b: Candidate): boolean => a.intelligence === b.intelligence;
|
|
155
|
+
|
|
156
|
+
const quota = (x: Candidate): string => quotaOf(x.info.parsed);
|
|
157
|
+
|
|
88
158
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
159
|
+
* Spec 1.5 plan 24: when rungs on other quotas are exactly as intelligent as `start`, the start goes to the
|
|
160
|
+
* quota with the most headroom (the fewest of the run's dispatches so far), the candidates' order (cost, then
|
|
161
|
+
* the profile's ladder order) breaking an equal count. `tie` says what decided. Rungs of one quota that tie
|
|
162
|
+
* are no tie (cost already orders them), nor is a rung of another cost tier.
|
|
163
|
+
*/
|
|
164
|
+
function breakTie(
|
|
165
|
+
start: Candidate,
|
|
166
|
+
order: Candidate[],
|
|
167
|
+
usage: Partial<Record<string, number>>,
|
|
168
|
+
): { start: Candidate; tie?: string } {
|
|
169
|
+
// a metered rung never wins a tie over a plan's: headroom is free only within the start's cost tier
|
|
170
|
+
const rivals = order.filter(
|
|
171
|
+
(x) => x !== start && quota(x) !== quota(start) && x.cost.tier === start.cost.tier && same(x, start),
|
|
172
|
+
);
|
|
173
|
+
if (rivals.length === 0) return { start };
|
|
174
|
+
const used = (x: Candidate) => usage[quota(x)] ?? 0;
|
|
175
|
+
const tied = [start, ...rivals];
|
|
176
|
+
const pick = [...tied].sort((a, b) => used(a) - used(b))[0] as Candidate;
|
|
177
|
+
const others = tied.filter((x) => x !== pick).map((x) => x.rung);
|
|
178
|
+
const counts = [...new Set(tied.map(quota))].map((q) => `${q} ${usage[q] ?? 0}`).join(", ");
|
|
179
|
+
const why = tied.every((x) => used(x) === used(pick))
|
|
180
|
+
? `equal headroom (${used(pick)} dispatches each); the profile's ladder order and cost decided`
|
|
181
|
+
: `${quota(pick)} has the most headroom (dispatches in this run: ${counts})`;
|
|
182
|
+
return {
|
|
183
|
+
start: pick,
|
|
184
|
+
tie: `tie on intelligence with ${others.join(", ")}: started on ${pick.rung}; ${why}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Spec 1.5 plan 24, "climb ladders only go up": the start (after a tie on equal intelligence goes to the quota
|
|
190
|
+
* with the most headroom), then, in `order`'s order, every other candidate at least as intelligent as the last
|
|
191
|
+
* rung kept. A stronger rung is on the ladder wherever cost puts it, and one weaker than the rung before it
|
|
192
|
+
* never is, so a climb never lands on a rung below the one it leaves.
|
|
193
|
+
*/
|
|
194
|
+
function ladderFrom(first: Candidate, order: Candidate[], usage: Partial<Record<string, number>> = {}): Pick {
|
|
195
|
+
const { start, tie } = breakTie(first, order, usage);
|
|
196
|
+
const kept = [start];
|
|
197
|
+
for (const x of order)
|
|
198
|
+
if (x !== start && x.intelligence >= (kept.at(-1) as Candidate).intelligence) kept.push(x);
|
|
199
|
+
return { rung: start.rung, ladder: kept.map((x) => x.rung), ...(tie ? { tie } : {}) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The role's default rung and every candidate at least as intelligent (the whole list when it has no default),
|
|
204
|
+
* in cost order under either objective: speed order would put faster, not stronger, rungs above the default.
|
|
91
205
|
*/
|
|
92
206
|
export function defaultLadder(c: Catalog, p: RoutingProfile, role: Role): Pick {
|
|
93
|
-
const all = candidates(c, { ...p, objective: "cost" }, role)
|
|
207
|
+
const all = candidates(c, { ...p, objective: "cost" }, role);
|
|
94
208
|
if (all.length === 0) throw noRung(role);
|
|
95
|
-
const
|
|
96
|
-
return
|
|
209
|
+
const start = all.find((x) => x.rung === p.role.defaultRung) ?? (all[0] as Candidate);
|
|
210
|
+
return ladderFrom(start, all, p.usage);
|
|
97
211
|
}
|
|
98
212
|
|
|
99
213
|
/**
|
|
100
|
-
* The role's default difficulty
|
|
101
|
-
*
|
|
102
|
-
*
|
|
214
|
+
* The role's default difficulty: the hardest difficulty whose bar the role's default rung clears (`build` when
|
|
215
|
+
* it clears none, or is no candidate: never `copy`, whose start is the cheapest rung). A route sure of the
|
|
216
|
+
* lane but not its difficulty starts there.
|
|
103
217
|
*/
|
|
104
|
-
export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role
|
|
218
|
+
export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role): Difficulty {
|
|
105
219
|
const start = defaultLadder(c, p, role).rung;
|
|
106
|
-
const cand = candidates(c, p, role
|
|
107
|
-
const
|
|
220
|
+
const cand = candidates(c, p, role).find((x) => x.rung === start);
|
|
221
|
+
const at = percentilesOf(p);
|
|
222
|
+
const cleared = cand ? DIFFICULTIES.filter((d) => clearsBar(cand, barOf(c, d, at))) : [];
|
|
108
223
|
return cleared.at(-1) ?? "build";
|
|
109
224
|
}
|
|
110
225
|
|
|
111
226
|
/**
|
|
112
|
-
* Under `objective: "speed"` the objective picks only the start (the fastest bar
|
|
113
|
-
* ladder above it is the other
|
|
114
|
-
*
|
|
227
|
+
* Under `objective: "speed"` the objective picks only the start (the fastest rung that clears the bar); the
|
|
228
|
+
* ladder above it is the other rungs at least as intelligent, least intelligent first, cost breaking ties, so a
|
|
229
|
+
* lane never climbs onto a weaker rung.
|
|
230
|
+
*/
|
|
231
|
+
function speedLadder(start: Candidate, all: Candidate[], p: RoutingProfile): Pick {
|
|
232
|
+
const order = [...all].sort((a, b) => a.intelligence - b.intelligence || compareCost(a.cost, b.cost));
|
|
233
|
+
return ladderFrom(start, order, p.usage);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Spec 1.5 plan 24: when no rung clears a bar, the most intelligent one (the candidates' order breaking a tie).
|
|
238
|
+
*/
|
|
239
|
+
const bestOf = (all: Candidate[]): Candidate =>
|
|
240
|
+
[...all].sort((a, b) => b.intelligence - a.intelligence)[0] as Candidate;
|
|
241
|
+
|
|
242
|
+
/** `no rung clears hard (bar 47.1); best is codex:gpt-6-sol#xhigh (44.2)`, and where a raised start came from. */
|
|
243
|
+
export function noClearLine(all: Candidate[], bar: number, d: Difficulty, raisedFrom?: Difficulty): string {
|
|
244
|
+
const best = bestOf(all);
|
|
245
|
+
return `no rung clears ${d} (bar ${bar.toFixed(1)}); best is ${best.rung} (${best.intelligence.toFixed(1)})${raisedFrom ? `; started at ${raisedFrom}'s start` : ""}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Spec §5.4: the start rung and ladder for a lane of this difficulty. The start is the first rung in objective
|
|
250
|
+
* order that clears the bar (under speed the fastest one with a speed, else the cheapest). When none does, it is
|
|
251
|
+
* the default rung, raised to an easier difficulty's start when that one is at least as intelligent (spec 1.5
|
|
252
|
+
* plan 24: `logic` starts no lower than `build`), and `noClear` says so. The ladder above the start only goes up.
|
|
115
253
|
*/
|
|
116
|
-
function
|
|
117
|
-
const
|
|
118
|
-
const dim: Dim = kind === "terminal" ? "terminal" : "repo_code";
|
|
119
|
-
const strength = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
|
|
120
|
-
const rest = clearing
|
|
121
|
-
.filter((x) => x !== start && strength(x) >= strength(start))
|
|
122
|
-
.sort((a, b) => strength(a) - strength(b) || compareCost(a.cost, b.cost));
|
|
123
|
-
return { rung: start.rung, ladder: [start, ...rest].map((x) => x.rung) };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Spec §5.4: the start rung and ladder for a lane of this kind and difficulty (0.x `select`). */
|
|
127
|
-
export function select(c: Catalog, p: RoutingProfile, role: Role, kind: Kind, difficulty: Difficulty): Pick {
|
|
128
|
-
const all = candidates(c, p, role, kind);
|
|
254
|
+
export function select(c: Catalog, p: RoutingProfile, role: Role, difficulty: Difficulty): Pick {
|
|
255
|
+
const all = candidates(c, p, role);
|
|
129
256
|
if (all.length === 0) throw noRung(role);
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
257
|
+
const at = percentilesOf(p);
|
|
258
|
+
const bar = barOf(c, difficulty, at);
|
|
259
|
+
if (all.length === 1) {
|
|
260
|
+
const only = all[0] as Candidate;
|
|
261
|
+
const one = { rung: only.rung, ladder: [only.rung] };
|
|
262
|
+
return clearsBar(only, bar) ? one : { ...one, noClear: noClearLine(all, bar, difficulty) };
|
|
263
|
+
}
|
|
264
|
+
const byCost = p.objective === "speed" ? candidates(c, { ...p, objective: "cost" }, role) : all;
|
|
265
|
+
if (p.objective === "speed") {
|
|
266
|
+
// the fastest clearing pair that has a speed, else the cheapest clearing one
|
|
267
|
+
const rung = (
|
|
268
|
+
all.find((x) => x.speed !== null && clearsBar(x, bar)) ?? byCost.find((x) => clearsBar(x, bar))
|
|
269
|
+
)?.rung;
|
|
270
|
+
const first = all.find((x) => x.rung === rung);
|
|
271
|
+
if (first) return speedLadder(first, all, p);
|
|
272
|
+
} else {
|
|
273
|
+
const first = all.find((x) => clearsBar(x, bar));
|
|
274
|
+
if (first) return ladderFrom(first, all, p.usage);
|
|
275
|
+
}
|
|
276
|
+
const fallback = defaultLadder(c, p, role).rung;
|
|
277
|
+
const base = byCost.find((x) => x.rung === fallback) ?? (byCost[0] as Candidate);
|
|
278
|
+
let start = base;
|
|
279
|
+
let raisedFrom: Difficulty | undefined;
|
|
280
|
+
for (const easier of DIFFICULTIES.slice(0, DIFFICULTIES.indexOf(difficulty)).reverse()) {
|
|
281
|
+
const s = byCost.find((x) => clearsBar(x, barOf(c, easier, at)));
|
|
282
|
+
if (!s) continue;
|
|
283
|
+
if (s.intelligence >= start.intelligence) {
|
|
284
|
+
start = s;
|
|
285
|
+
if (s !== base) raisedFrom = easier;
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
...ladderFrom(start, byCost, p.usage),
|
|
291
|
+
noClear: noClearLine(all, bar, difficulty, raisedFrom),
|
|
292
|
+
};
|
|
136
293
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CatherdError } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
/** One model at one effort, as the Slopalytics proxy indexes it: its intelligence and the facts that come with it. */
|
|
5
|
+
export interface SlopRecord {
|
|
6
|
+
family: string;
|
|
7
|
+
effort: string;
|
|
8
|
+
intelligence: number;
|
|
9
|
+
inputPrice: number | null;
|
|
10
|
+
outputPrice: number | null;
|
|
11
|
+
speed: number | null;
|
|
12
|
+
context: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RecordSchema = z.object({
|
|
16
|
+
family: z.string(),
|
|
17
|
+
effort: z.string(),
|
|
18
|
+
intelligence: z.number(),
|
|
19
|
+
inputPrice: z.number().nullable(),
|
|
20
|
+
outputPrice: z.number().nullable(),
|
|
21
|
+
speed: z.number().nullable(),
|
|
22
|
+
context: z.number().nullable(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** `<data>/slopalytics.json`: the last good answer, when it was fetched and checked, and the last failure. */
|
|
26
|
+
export const IndexFileSchema = z.looseObject({
|
|
27
|
+
schema: z.literal(1),
|
|
28
|
+
checkedAt: z.iso.datetime(),
|
|
29
|
+
fetchedAt: z.string().nullable(),
|
|
30
|
+
error: z.object({ at: z.string(), message: z.string() }).nullable(),
|
|
31
|
+
models: z.array(RecordSchema),
|
|
32
|
+
});
|
|
33
|
+
export type IndexFile = z.infer<typeof IndexFileSchema>;
|
|
34
|
+
|
|
35
|
+
/** A finite number or a finite numeric string (the proxy sends some prices as strings), else null. */
|
|
36
|
+
const num = (v: unknown): number | null => {
|
|
37
|
+
const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
|
|
38
|
+
return typeof n === "number" && Number.isFinite(n) ? n : null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The proxy's answer as the records catherd ranks on: only a record with a positive intelligence is a pair. */
|
|
42
|
+
export function parseProxy(body: unknown): { fetchedAt: string | null; models: SlopRecord[] } {
|
|
43
|
+
const b = body as { fetchedAt?: unknown; models?: unknown } | null;
|
|
44
|
+
if (typeof b !== "object" || b === null || !Array.isArray(b.models))
|
|
45
|
+
throw new CatherdError("E_INPUT_INVALID", "Slopalytics answered without a models list");
|
|
46
|
+
const models: SlopRecord[] = [];
|
|
47
|
+
for (const raw of b.models) {
|
|
48
|
+
const r = (raw ?? {}) as Record<string, unknown>;
|
|
49
|
+
const intelligence = num(r.intelligence);
|
|
50
|
+
if (typeof r.family !== "string" || !r.family) continue;
|
|
51
|
+
if (typeof r.effort !== "string" || !r.effort) continue;
|
|
52
|
+
if (intelligence === null || intelligence <= 0) continue;
|
|
53
|
+
const context = num(r.context);
|
|
54
|
+
models.push({
|
|
55
|
+
family: r.family,
|
|
56
|
+
effort: r.effort,
|
|
57
|
+
intelligence,
|
|
58
|
+
inputPrice: num(r.inputPrice),
|
|
59
|
+
outputPrice: num(r.outputPrice),
|
|
60
|
+
speed: num(r.speed),
|
|
61
|
+
context: context !== null && Number.isInteger(context) && context > 0 ? context : null,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { fetchedAt: typeof b.fetchedAt === "string" ? b.fetchedAt : null, models };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A model's Slopalytics family key: its provider dropped, lower case, dots as dashes (`gpt-6.1-sol` → `gpt-6-1-sol`). */
|
|
68
|
+
export const slopKey = (model: string): string =>
|
|
69
|
+
model
|
|
70
|
+
.slice(model.lastIndexOf("/") + 1)
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replaceAll(".", "-");
|
package/src/domain/state.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface StateView {
|
|
|
16
16
|
dirty: { path: string; owner: string | null }[];
|
|
17
17
|
running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
|
|
18
18
|
lastCheck: string | null;
|
|
19
|
+
/** spec 1.5 "Pinned per run": what changed since the pin; dispatch keeps the pinned values */
|
|
20
|
+
pinChanges?: string[];
|
|
19
21
|
next: string;
|
|
20
22
|
/** spec 1.1 §10: the protocol's next step, derived from the run's files; always the last line */
|
|
21
23
|
protocol: string;
|
|
@@ -40,6 +42,9 @@ export function renderState(s: StateView): string {
|
|
|
40
42
|
: ["- none"]),
|
|
41
43
|
"",
|
|
42
44
|
`Last check: ${s.lastCheck ?? "none"}`,
|
|
45
|
+
...(s.pinChanges?.length
|
|
46
|
+
? [`Pinned: ${s.pinChanges.join("; ")} (dispatch keeps the pinned values; run_pin re-pins)`]
|
|
47
|
+
: []),
|
|
43
48
|
"",
|
|
44
49
|
// 1.1: no tool waits; each running role's record arrives as a catherd message
|
|
45
50
|
`Next: ${waiting.length ? `running ${waiting.join(", ")} (results arrive as catherd messages; peek to check); then ${s.next}` : s.next}`,
|