catherd-cli 0.2.1 → 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 -26
- 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 -84
- 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,283 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Difficulty, KINDS, type Kind, parseLaneHeader } from "./lane.ts";
|
|
3
|
+
import { scrubKeyShapes, scrubSecretAssignments } from "./secrets.ts";
|
|
4
|
+
|
|
5
|
+
// Spec §5.5 and research 2026-09-25-jev.md: Jev's three question types, catherd's question sets as
|
|
6
|
+
// versioned data (catalog/jev.json), the trimmed lane state, and the decision rules.
|
|
7
|
+
|
|
8
|
+
const ChoiceQ = z.object({
|
|
9
|
+
type: z.literal("choice"),
|
|
10
|
+
instructions: z.string(),
|
|
11
|
+
criteria: z.record(z.string(), z.string()),
|
|
12
|
+
});
|
|
13
|
+
const ScoreQ = z.object({
|
|
14
|
+
type: z.literal("score"),
|
|
15
|
+
instructions: z.string(),
|
|
16
|
+
criteria: z.array(z.string()).min(2).max(10),
|
|
17
|
+
});
|
|
18
|
+
const NoulQ = z.object({
|
|
19
|
+
type: z.literal("noul"),
|
|
20
|
+
instructions: z.string(),
|
|
21
|
+
criteria: z.object({ true: z.string(), false: z.string() }).optional(),
|
|
22
|
+
});
|
|
23
|
+
const QuestionSchema = z.discriminatedUnion("type", [ChoiceQ, ScoreQ, NoulQ]);
|
|
24
|
+
export type JevQuestion = z.infer<typeof QuestionSchema>;
|
|
25
|
+
|
|
26
|
+
const RouteRuleSchema = z.object({
|
|
27
|
+
kindMin: z.number().min(0).max(1),
|
|
28
|
+
trackMin: z.number().gt(0.5).max(1),
|
|
29
|
+
/** exactly two levels each, in order: copy then build, logic then hard */
|
|
30
|
+
trackA: z.array(z.string()).length(2),
|
|
31
|
+
trackB: z.array(z.string()).length(2),
|
|
32
|
+
});
|
|
33
|
+
export type RouteRule = z.infer<typeof RouteRuleSchema>;
|
|
34
|
+
const VerdictRuleSchema = z.object({ min: z.number().min(0).max(1), fallback: z.string() });
|
|
35
|
+
export type VerdictRule = z.infer<typeof VerdictRuleSchema>;
|
|
36
|
+
|
|
37
|
+
export const JevFileSchema = z.looseObject({
|
|
38
|
+
schema: z.literal(1),
|
|
39
|
+
model: z.string(),
|
|
40
|
+
sets: z.object({
|
|
41
|
+
"route-v2": z.object({ questions: z.record(z.string(), QuestionSchema), rule: RouteRuleSchema }),
|
|
42
|
+
finding: z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
|
|
43
|
+
"same-defect": z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
export type JevFile = z.infer<typeof JevFileSchema>;
|
|
47
|
+
export type SetName = keyof JevFile["sets"];
|
|
48
|
+
|
|
49
|
+
/** The answers each verdict set's caller accepts; a set's `fallback` must be one of them. */
|
|
50
|
+
export const VERDICT_OPTIONS = {
|
|
51
|
+
finding: ["design", "code", "unclear"],
|
|
52
|
+
"same-defect": ["yes", "no"],
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
/** JSON with object keys sorted at every depth, so equal data always hashes the same. */
|
|
56
|
+
export function canonicalJson(v: unknown): string {
|
|
57
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
58
|
+
if (v !== null && typeof v === "object")
|
|
59
|
+
return `{${Object.keys(v)
|
|
60
|
+
.filter((k) => (v as Record<string, unknown>)[k] !== undefined)
|
|
61
|
+
.sort()
|
|
62
|
+
.map((k) => `${JSON.stringify(k)}:${canonicalJson((v as Record<string, unknown>)[k])}`)
|
|
63
|
+
.join(",")}}`;
|
|
64
|
+
return JSON.stringify(v) ?? "null";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const sha256 = (s: string): string => new Bun.CryptoHasher("sha256").update(s).digest("hex");
|
|
68
|
+
|
|
69
|
+
/** `<set>#<8 hex>` over the model, questions and rule: any change to one names a new question set. */
|
|
70
|
+
export function questionSetId(f: JevFile, name: SetName): string {
|
|
71
|
+
const set = f.sets[name];
|
|
72
|
+
return `${name}#${sha256(canonicalJson({ model: f.model, questions: set.questions, rule: set.rule })).slice(0, 8)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The per-run cache key of one request. */
|
|
76
|
+
export const requestKey = (model: string, state: unknown, questions: unknown): string =>
|
|
77
|
+
sha256(canonicalJson({ model, state, questions }));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Key-shaped strings (domain/secrets.ts) and `key = value` secrets replaced by [secret]; the pair, a
|
|
81
|
+
* bearer and a URL's user keep their names.
|
|
82
|
+
*/
|
|
83
|
+
export const scrubSecrets = (text: string): string =>
|
|
84
|
+
scrubSecretAssignments(scrubKeyShapes(text, "[secret]"), "[secret]");
|
|
85
|
+
|
|
86
|
+
/** Research §5.3: code is not what Jev judges, and long state rots its answers. */
|
|
87
|
+
export const BODY_MAX = 6000;
|
|
88
|
+
const HEADER = /^\s*[*_]*(owns|fast check|kind|difficulty)[*_]*\s*:/i;
|
|
89
|
+
|
|
90
|
+
export interface LaneState {
|
|
91
|
+
title: string | null;
|
|
92
|
+
owns: string[];
|
|
93
|
+
fast_check: string | null;
|
|
94
|
+
body: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Spec §5.5: `{ title, owns, fast_check, body }`. The body drops the header lines (so Jev never echoes
|
|
99
|
+
* the architect's own Kind/Difficulty), fenced code, and secrets, and is capped at BODY_MAX characters.
|
|
100
|
+
* Every field is scrubbed of secrets.
|
|
101
|
+
*/
|
|
102
|
+
export function laneState(text: string): LaneState {
|
|
103
|
+
const h = parseLaneHeader(text);
|
|
104
|
+
const body = dropFences(text)
|
|
105
|
+
.split("\n")
|
|
106
|
+
.filter((l) => !/^#\s/.test(l) && !HEADER.test(l))
|
|
107
|
+
.join("\n")
|
|
108
|
+
.trim();
|
|
109
|
+
return {
|
|
110
|
+
title: h.title === null ? null : scrubSecrets(h.title),
|
|
111
|
+
owns: h.owns.map(scrubSecrets).filter(Boolean),
|
|
112
|
+
fast_check: h.fastCheck === null ? null : scrubSecrets(h.fastCheck),
|
|
113
|
+
body: capBody(scrubSecrets(body)),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Fenced code, indented or not, becomes "[code omitted]"; a fence left open runs to the end of the text, as Markdown renders it. */
|
|
118
|
+
const dropFences = (text: string): string =>
|
|
119
|
+
text.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*(?:\n[\s\S]*?^[ \t]*\1[^\n]*$|[\s\S]*$)/gm, "[code omitted]");
|
|
120
|
+
|
|
121
|
+
/** Capped at BODY_MAX characters, never ending the cut on half a surrogate pair. */
|
|
122
|
+
const capBody = (text: string): string =>
|
|
123
|
+
text.length > BODY_MAX ? `${text.slice(0, BODY_MAX).replace(/[\uD800-\uDBFF]$/, "")}…` : text;
|
|
124
|
+
|
|
125
|
+
/** Free text sent to Jev (a finding, a defect): no fenced code, no secrets, capped like a lane's body. */
|
|
126
|
+
export function scrubFree(text: string): string {
|
|
127
|
+
return capBody(scrubSecrets(dropFences(text).trim()));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const Prob = z.record(z.string(), z.number().min(0).max(1));
|
|
131
|
+
const AnswerSchema = z.discriminatedUnion("type", [
|
|
132
|
+
z.object({ type: z.literal("choice"), choice: z.string(), probabilities: Prob, confidence: z.number() }),
|
|
133
|
+
z.object({
|
|
134
|
+
type: z.literal("score"),
|
|
135
|
+
score: z.number(),
|
|
136
|
+
probabilities: Prob,
|
|
137
|
+
confidence: z.number(),
|
|
138
|
+
}),
|
|
139
|
+
z.object({ type: z.literal("noul"), noul: z.number().min(0).max(1) }),
|
|
140
|
+
]);
|
|
141
|
+
type JevAnswer = z.infer<typeof AnswerSchema>;
|
|
142
|
+
export type JevAnswers = Record<string, JevAnswer>;
|
|
143
|
+
|
|
144
|
+
const ReplySchema = z.object({
|
|
145
|
+
model: z.string(),
|
|
146
|
+
answers: z.record(z.string(), z.unknown()),
|
|
147
|
+
usage: z.object({ input_tokens: z.number(), output_tokens: z.number() }).optional(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export type ParsedReply =
|
|
151
|
+
| {
|
|
152
|
+
ok: true;
|
|
153
|
+
model: string;
|
|
154
|
+
usage: { input_tokens: number; output_tokens: number } | null;
|
|
155
|
+
answers: JevAnswers;
|
|
156
|
+
}
|
|
157
|
+
| { ok: false; error: string };
|
|
158
|
+
|
|
159
|
+
/** Every question answered, each in its own type and within its options, or an error naming the first. */
|
|
160
|
+
export function parseReply(questions: Record<string, JevQuestion>, body: unknown): ParsedReply {
|
|
161
|
+
const r = ReplySchema.safeParse(body);
|
|
162
|
+
if (!r.success) return { ok: false, error: "unexpected response" };
|
|
163
|
+
const answers: JevAnswers = {};
|
|
164
|
+
for (const [id, q] of Object.entries(questions)) {
|
|
165
|
+
const a = AnswerSchema.safeParse(r.data.answers[id]);
|
|
166
|
+
if (!a.success || a.data.type !== q.type) return { ok: false, error: `no valid answer to ${id}` };
|
|
167
|
+
if (a.data.type === "choice" && q.type === "choice" && !Object.hasOwn(q.criteria, a.data.choice))
|
|
168
|
+
return { ok: false, error: `no valid answer to ${id}` };
|
|
169
|
+
if (
|
|
170
|
+
a.data.type === "score" &&
|
|
171
|
+
q.type === "score" &&
|
|
172
|
+
Object.keys(a.data.probabilities).some((l) => !/^\d+$/.test(l) || Number(l) >= q.criteria.length)
|
|
173
|
+
)
|
|
174
|
+
return { ok: false, error: `no valid answer to ${id}` };
|
|
175
|
+
answers[id] = a.data;
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, model: r.data.model, usage: r.data.usage ?? null, answers };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const pOf = (a: JevAnswer | undefined, option: string): number =>
|
|
181
|
+
a && a.type !== "noul" ? (a.probabilities[option] ?? 0) : 0;
|
|
182
|
+
|
|
183
|
+
export interface RouteJudgement {
|
|
184
|
+
/** the kind, when p_max ≥ kindMin and it is one of catherd's kinds */
|
|
185
|
+
kind: Kind | null;
|
|
186
|
+
pKind: number | null;
|
|
187
|
+
/** "A" (copy/build) or "B" (logic/hard) on summed probabilities, null in the dead band */
|
|
188
|
+
track: "A" | "B" | null;
|
|
189
|
+
difficulty: Difficulty | null;
|
|
190
|
+
pA: number | null;
|
|
191
|
+
pB: number | null;
|
|
192
|
+
/** the atomic questions, logged for calibration only */
|
|
193
|
+
nouls: Record<string, number>;
|
|
194
|
+
rule: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Spec §5.5's decision rule. Confidence is `(n·p_max − 1)/(n − 1)`, so it means different things for
|
|
199
|
+
* different option counts; the rule reads probabilities instead: the kind at p_max ≥ kindMin, the track
|
|
200
|
+
* when the summed probability of its levels reaches trackMin, and neither in between (the dead band).
|
|
201
|
+
*/
|
|
202
|
+
export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
|
|
203
|
+
const k = answers.kind;
|
|
204
|
+
const d = answers.difficulty;
|
|
205
|
+
const nouls: Record<string, number> = {};
|
|
206
|
+
for (const [id, a] of Object.entries(answers)) if (a.type === "noul") nouls[id] = a.noul;
|
|
207
|
+
let kind: Kind | null = null;
|
|
208
|
+
let pKind: number | null = null;
|
|
209
|
+
if (k && k.type === "choice") {
|
|
210
|
+
const [top, p] = Object.entries(k.probabilities).reduce((m, e) => (e[1] > m[1] ? e : m), ["", -1]);
|
|
211
|
+
pKind = p < 0 ? null : p;
|
|
212
|
+
if (p >= rule.kindMin && (KINDS as readonly string[]).includes(top)) kind = top as Kind;
|
|
213
|
+
}
|
|
214
|
+
if (!d || d.type !== "score")
|
|
215
|
+
return { kind, pKind, track: null, difficulty: null, pA: null, pB: null, nouls, rule: "no difficulty" };
|
|
216
|
+
// Rounded before comparing: 0.1 + 0.7 is 0.7999999999999999 in floating point, and must reach 0.8.
|
|
217
|
+
const round = (x: number) => Math.round(x * 1e6) / 1e6;
|
|
218
|
+
const sum = (levels: string[]) => round(levels.reduce((s, l) => s + pOf(d, l), 0));
|
|
219
|
+
const pA = sum(rule.trackA);
|
|
220
|
+
const pB = sum(rule.trackB);
|
|
221
|
+
if (pA >= rule.trackMin)
|
|
222
|
+
return {
|
|
223
|
+
kind,
|
|
224
|
+
pKind,
|
|
225
|
+
track: "A",
|
|
226
|
+
difficulty: pOf(d, rule.trackA[0] ?? "") > pOf(d, rule.trackA[1] ?? "") ? "copy" : "build",
|
|
227
|
+
pA,
|
|
228
|
+
pB,
|
|
229
|
+
nouls,
|
|
230
|
+
rule: `P(A) ${pA} ≥ ${rule.trackMin}`,
|
|
231
|
+
};
|
|
232
|
+
if (pB >= rule.trackMin)
|
|
233
|
+
return {
|
|
234
|
+
kind,
|
|
235
|
+
pKind,
|
|
236
|
+
track: "B",
|
|
237
|
+
difficulty: pOf(d, rule.trackB[1] ?? "") > pOf(d, rule.trackB[0] ?? "") ? "hard" : "logic",
|
|
238
|
+
pA,
|
|
239
|
+
pB,
|
|
240
|
+
nouls,
|
|
241
|
+
rule: `P(B) ${pB} ≥ ${rule.trackMin}`,
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
kind,
|
|
245
|
+
pKind,
|
|
246
|
+
track: null,
|
|
247
|
+
difficulty: null,
|
|
248
|
+
pA,
|
|
249
|
+
pB,
|
|
250
|
+
nouls,
|
|
251
|
+
rule: `P(A) ${pA}, P(B) ${pB}: both below ${rule.trackMin}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface Verdict<T extends string> {
|
|
256
|
+
value: T;
|
|
257
|
+
/** the chosen answer's probability, when Jev answered */
|
|
258
|
+
probability: number | null;
|
|
259
|
+
/** Jev's own confidence statistic, when it answered */
|
|
260
|
+
confidence: number | null;
|
|
261
|
+
source: "jev" | "default";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Spec §5.5: `finding` and `same-defect` keep their 0.x meaning, with thresholds on probability. */
|
|
265
|
+
export function judgeVerdict<T extends string>(
|
|
266
|
+
rule: VerdictRule,
|
|
267
|
+
id: string,
|
|
268
|
+
options: readonly T[],
|
|
269
|
+
answers: JevAnswers | null,
|
|
270
|
+
): Verdict<T> {
|
|
271
|
+
const a = answers?.[id];
|
|
272
|
+
const fallback = rule.fallback as T;
|
|
273
|
+
if (!a || a.type !== "choice" || !(options as readonly string[]).includes(a.choice))
|
|
274
|
+
return { value: fallback, probability: null, confidence: null, source: "default" };
|
|
275
|
+
const p = a.probabilities[a.choice] ?? 0;
|
|
276
|
+
const sure = p >= rule.min;
|
|
277
|
+
return {
|
|
278
|
+
value: sure ? (a.choice as T) : fallback,
|
|
279
|
+
probability: p,
|
|
280
|
+
confidence: a.confidence,
|
|
281
|
+
source: sure ? "jev" : "default",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CatherdError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
export const KINDS = ["repo_code", "terminal", "ui", "prose", "research"] as const;
|
|
4
|
+
export type Kind = (typeof KINDS)[number];
|
|
5
|
+
export const DIFFICULTIES = ["copy", "build", "logic", "hard"] as const;
|
|
6
|
+
export type Difficulty = (typeof DIFFICULTIES)[number];
|
|
7
|
+
|
|
8
|
+
export interface LaneHeader {
|
|
9
|
+
title: string | null;
|
|
10
|
+
owns: string[];
|
|
11
|
+
fastCheck: string | null;
|
|
12
|
+
kind: Kind | null;
|
|
13
|
+
difficulty: Difficulty | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const unquote = (s: string) =>
|
|
17
|
+
s
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/^`+|`+$/g, "")
|
|
20
|
+
.trim();
|
|
21
|
+
|
|
22
|
+
/** The value of the first `Label:` line, allowing `**Label:**` and `_Label_:` emphasis. */
|
|
23
|
+
function field(text: string, label: string): string | null {
|
|
24
|
+
const re = new RegExp(`^\\s*[*_]*${label}[*_]*\\s*:[*_]*\\s*(.*)$`, "i");
|
|
25
|
+
for (const line of text.split("\n")) {
|
|
26
|
+
const m = re.exec(line);
|
|
27
|
+
if (m) return (m[1] ?? "").trim();
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const oneOf = <T extends string>(v: string | null, options: readonly T[]): T | null =>
|
|
33
|
+
v !== null && (options as readonly string[]).includes(unquote(v)) ? (unquote(v) as T) : null;
|
|
34
|
+
|
|
35
|
+
/** Canonical form: no "." or empty segments, a trailing "/" kept as a directory marker. */
|
|
36
|
+
export function normalizeOwned(p: string): string {
|
|
37
|
+
const s = unquote(p);
|
|
38
|
+
const parts = s.split("/").filter((seg) => seg !== "" && seg !== ".");
|
|
39
|
+
if (s.startsWith("/") || parts.length === 0 || parts.includes(".."))
|
|
40
|
+
throw new CatherdError(
|
|
41
|
+
"E_LANE_INVALID",
|
|
42
|
+
`owned path "${p}" must be relative to the repo and stay inside it`,
|
|
43
|
+
{
|
|
44
|
+
fix: "write Owns: paths like src/foo/ or src/bar.ts",
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
return parts.join("/") + (s.endsWith("/") ? "/" : "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseLaneHeader(text: string): LaneHeader {
|
|
51
|
+
const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? null;
|
|
52
|
+
const owns = (field(text, "owns") ?? "").split(",").map(unquote).filter(Boolean).map(normalizeOwned);
|
|
53
|
+
const check = field(text, "fast check");
|
|
54
|
+
return {
|
|
55
|
+
title,
|
|
56
|
+
owns,
|
|
57
|
+
fastCheck: check ? unquote(check) || null : null,
|
|
58
|
+
kind: oneOf(field(text, "kind"), KINDS),
|
|
59
|
+
difficulty: oneOf(field(text, "difficulty"), DIFFICULTIES),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bare = (p: string) => p.replace(/\/+$/, "");
|
|
64
|
+
const covers = (outer: string, inner: string) => inner === outer || inner.startsWith(`${outer}/`);
|
|
65
|
+
|
|
66
|
+
/** The entries of `a` that overlap any entry of `b`; a path covers everything under it. */
|
|
67
|
+
export function overlaps(a: string[], b: string[]): string[] {
|
|
68
|
+
return a.filter((x) => b.some((y) => covers(bare(x), bare(y)) || covers(bare(y), bare(x))));
|
|
69
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
billingKeyOf,
|
|
3
|
+
type Catalog,
|
|
4
|
+
capableFor,
|
|
5
|
+
effortOffered,
|
|
6
|
+
ROLE_NEEDS,
|
|
7
|
+
type RungInfo,
|
|
8
|
+
rungInfo,
|
|
9
|
+
scoresOf,
|
|
10
|
+
} from "./catalog.ts";
|
|
11
|
+
import { type Rung, tryParseRung } from "./ids.ts";
|
|
12
|
+
import { DIFFICULTIES, KINDS } from "./lane.ts";
|
|
13
|
+
import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
|
|
14
|
+
import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
|
|
15
|
+
import { candidates, clearsBar, type RoutingProfile } from "./select.ts";
|
|
16
|
+
|
|
17
|
+
/** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
|
|
18
|
+
export interface Issue {
|
|
19
|
+
path: string;
|
|
20
|
+
message: string;
|
|
21
|
+
fix?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Validation {
|
|
25
|
+
errors: Issue[];
|
|
26
|
+
warnings: Issue[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TREAT_LIKE_FIX = (rung: string) =>
|
|
30
|
+
`map it with: catherd catalog treat-like ${rung} <a scored rung>; catalog_query lists them`;
|
|
31
|
+
|
|
32
|
+
export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
|
|
33
|
+
objective: p.objective,
|
|
34
|
+
billing: p.billing,
|
|
35
|
+
role: p.roles[role],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
|
|
40
|
+
* quota, except that the native `claude` path and headless `claude-code` both draw on the Claude plan.
|
|
41
|
+
*/
|
|
42
|
+
export const quotaOf = (r: Rung): string => (billingKeyOf(r) === "claude" ? "claude-code" : billingKeyOf(r));
|
|
43
|
+
|
|
44
|
+
/** Whether a rung's scores are catherd's guess: borrowed through a treat-like, or only `inferred` ones. */
|
|
45
|
+
export function inferredScores(c: Catalog, info: RungInfo): { inferred: boolean; via: string | null } {
|
|
46
|
+
const s = scoresOf(c, info.canonical);
|
|
47
|
+
if (!s) return { inferred: false, via: null };
|
|
48
|
+
const records = Object.values(s.records);
|
|
49
|
+
return { inferred: s.via !== null || records.every((r) => r.confidence === "inferred"), via: s.via };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Spec §7.1. Errors: the worker disabled; an enabled role with no usable rung; an unscored rung without a
|
|
54
|
+
* treat-like; a failover stand-in unscored or on the same quota; a rung whose backend catherd cannot run
|
|
55
|
+
* (`backends` lists those it can, `claude` included). Everything else worth knowing is a warning: an access
|
|
56
|
+
* mode other than the role's default, an effort or model the last listing does not offer, a stand-in
|
|
57
|
+
* that never runs. With the stored `doc` `p` came from, a value this catherd does not know is also a
|
|
58
|
+
* warning, one that says how the value is read.
|
|
59
|
+
*/
|
|
60
|
+
export function validateProfile(
|
|
61
|
+
p: Profile,
|
|
62
|
+
c: Catalog,
|
|
63
|
+
backends: readonly string[],
|
|
64
|
+
doc?: ProfileDoc,
|
|
65
|
+
): Validation {
|
|
66
|
+
const errors: Issue[] = [];
|
|
67
|
+
const warnings: Issue[] = [];
|
|
68
|
+
for (const u of doc ? unknownValues(doc) : [])
|
|
69
|
+
warnings.push({
|
|
70
|
+
path: u.path,
|
|
71
|
+
message: `"${u.value}" is not a value this catherd knows (a newer one wrote it?); it is read as ${u.readAs}`,
|
|
72
|
+
fix: "upgrade catherd (bun add -g catherd-cli@latest), or set a value this version knows",
|
|
73
|
+
});
|
|
74
|
+
if (!p.roles.worker.enabled)
|
|
75
|
+
errors.push({
|
|
76
|
+
path: "roles.worker.enabled",
|
|
77
|
+
message: "the worker cannot be disabled",
|
|
78
|
+
fix: "catherd profile set roles.worker.enabled true",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const role of ROLES) {
|
|
82
|
+
const rc = p.roles[role];
|
|
83
|
+
const at = `roles.${role}`;
|
|
84
|
+
if (rc.access !== DEFAULT_ACCESS[role])
|
|
85
|
+
warnings.push({
|
|
86
|
+
path: `${at}.access`,
|
|
87
|
+
message: `${role} runs ${rc.access}; catherd's default for it is ${DEFAULT_ACCESS[role]}`,
|
|
88
|
+
});
|
|
89
|
+
if (!rc.enabled) continue;
|
|
90
|
+
for (const rung of rc.rungs) {
|
|
91
|
+
const r = tryParseRung(rung);
|
|
92
|
+
if (!r) {
|
|
93
|
+
errors.push({
|
|
94
|
+
path: `${at}.rungs`,
|
|
95
|
+
message: `"${rung}" is not a rung`,
|
|
96
|
+
fix: "write it as <backend>:<model>#<effort>",
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!backends.includes(r.backend)) {
|
|
101
|
+
errors.push({
|
|
102
|
+
path: `${at}.rungs`,
|
|
103
|
+
message: `${rung}: catherd cannot run ${r.backend} yet`,
|
|
104
|
+
fix: `use a rung on ${backends.join(", ")}`,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const info = rungInfo(c, rung);
|
|
109
|
+
if (!capableFor(c, info, role))
|
|
110
|
+
errors.push({
|
|
111
|
+
path: `${at}.rungs`,
|
|
112
|
+
message: `${rung} cannot fill the ${role} role (it needs ${Object.keys(ROLE_NEEDS[role]).join(" + ")})`,
|
|
113
|
+
fix: `catalog_query({ role: "${role}" }) lists the models that can`,
|
|
114
|
+
});
|
|
115
|
+
if (info.efforts.length > 0 && !effortOffered(info))
|
|
116
|
+
errors.push({
|
|
117
|
+
path: `${at}.rungs`,
|
|
118
|
+
message: `${r.model} has no effort "${r.effort}" on ${r.backend} (it has ${info.efforts.join(", ")})`,
|
|
119
|
+
});
|
|
120
|
+
else if (info.efforts.length === 0 && r.effort !== "default")
|
|
121
|
+
warnings.push({
|
|
122
|
+
path: `${at}.rungs`,
|
|
123
|
+
message: `${rung}: catherd cannot check its effort until ${r.backend} lists its models`,
|
|
124
|
+
fix: "catherd catalog refresh",
|
|
125
|
+
});
|
|
126
|
+
if (info.listed === false)
|
|
127
|
+
warnings.push({
|
|
128
|
+
path: `${at}.rungs`,
|
|
129
|
+
message: `${r.backend}'s last listing does not offer ${r.model}; routing skips it`,
|
|
130
|
+
});
|
|
131
|
+
if (!scoresOf(c, info.canonical))
|
|
132
|
+
errors.push({ path: `${at}.rungs`, message: `${rung} is unscored`, fix: TREAT_LIKE_FIX(rung) });
|
|
133
|
+
}
|
|
134
|
+
if (rc.defaultRung !== undefined && !rc.rungs.includes(rc.defaultRung))
|
|
135
|
+
errors.push({
|
|
136
|
+
path: `${at}.defaultRung`,
|
|
137
|
+
message: `${rc.defaultRung} is not one of the ${role}'s rungs`,
|
|
138
|
+
fix: `catherd profile set ${at}.defaultRung null, or add it to ${at}.rungs`,
|
|
139
|
+
});
|
|
140
|
+
const usable = candidates(c, routingProfileOf(p, role), role);
|
|
141
|
+
if (usable.length === 0)
|
|
142
|
+
errors.push({
|
|
143
|
+
path: `${at}.rungs`,
|
|
144
|
+
message: `the ${role} role has no usable rung`,
|
|
145
|
+
fix: `enable a scored rung its backend offers, or turn the role off with: catherd profile set ${at}.enabled false`,
|
|
146
|
+
});
|
|
147
|
+
if (usable.length > 1)
|
|
148
|
+
// Claude rungs have no honesty score, so they clear no shipped bar: select() starts on one only as the
|
|
149
|
+
// default rung (when nothing clears) and never climbs onto it
|
|
150
|
+
for (const x of usable)
|
|
151
|
+
if (
|
|
152
|
+
quotaOf(x.info.parsed) === "claude-code" &&
|
|
153
|
+
!KINDS.some((k) => DIFFICULTIES.some((d) => clearsBar(c, x, k, d)))
|
|
154
|
+
)
|
|
155
|
+
warnings.push({
|
|
156
|
+
path: `${at}.rungs`,
|
|
157
|
+
message: `${x.rung} clears no routing bar, so a lane starts on it only as the role's default rung and never climbs onto it`,
|
|
158
|
+
});
|
|
159
|
+
if (role === "worker" && usable.length > 1) {
|
|
160
|
+
const missed = KINDS.flatMap((k) =>
|
|
161
|
+
DIFFICULTIES.filter((d) => !usable.some((x) => clearsBar(c, x, k, d))).map((d) => `${k}/${d}`),
|
|
162
|
+
);
|
|
163
|
+
if (missed.length)
|
|
164
|
+
warnings.push({
|
|
165
|
+
path: `${at}.rungs`,
|
|
166
|
+
message: `no worker rung clears the bar for ${missed.join(", ")}; those lanes start at the default rung`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
|
|
172
|
+
for (const [from, to] of Object.entries(p.failover)) {
|
|
173
|
+
const at = `failover.${from}`;
|
|
174
|
+
const a = tryParseRung(from);
|
|
175
|
+
const b = tryParseRung(to);
|
|
176
|
+
if (!a || !b) {
|
|
177
|
+
errors.push({
|
|
178
|
+
path: at,
|
|
179
|
+
message: `"${!a ? from : to}" is not a rung`,
|
|
180
|
+
fix: "write it as <backend>:<model>#<effort>",
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (!backends.includes(b.backend)) {
|
|
185
|
+
errors.push({ path: at, message: `stand-in ${to}: catherd cannot run ${b.backend} yet` });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!scoresOf(c, rungInfo(c, to).canonical))
|
|
189
|
+
errors.push({ path: at, message: `stand-in ${to} is unscored`, fix: TREAT_LIKE_FIX(to) });
|
|
190
|
+
if (quotaOf(a) === quotaOf(b))
|
|
191
|
+
errors.push({
|
|
192
|
+
path: at,
|
|
193
|
+
message: `stand-in ${to} draws on the same quota as ${from}, which is out when ${from} hits its limit`,
|
|
194
|
+
fix: "name a stand-in on another backend or plan",
|
|
195
|
+
});
|
|
196
|
+
if (b.backend === "claude")
|
|
197
|
+
warnings.push({
|
|
198
|
+
path: at,
|
|
199
|
+
message: `stand-in ${to} is a native subagent: the orchestrator must start it, dispatch cannot`,
|
|
200
|
+
});
|
|
201
|
+
if (!ladders.has(from))
|
|
202
|
+
warnings.push({ path: at, message: `${from} is on no enabled role's ladder, so this never runs` });
|
|
203
|
+
}
|
|
204
|
+
return { errors, warnings };
|
|
205
|
+
}
|