catherd-cli 1.2.1 → 1.4.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 +146 -36
- package/catalog/models.json +148 -5
- package/catalog/scores.json +24 -0
- package/catalog/sources.json +12 -2
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +45 -39
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +61 -18
- package/src/adapters/all.ts +6 -0
- package/src/adapters/antigravity/events.ts +131 -0
- package/src/adapters/antigravity/home.ts +46 -0
- package/src/adapters/antigravity/index.ts +320 -0
- package/src/adapters/antigravity/models.ts +37 -0
- package/src/adapters/backend.ts +34 -1
- package/src/adapters/claude-code/index.ts +2 -0
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/index.ts +2 -0
- package/src/adapters/cursor/events.ts +120 -0
- package/src/adapters/cursor/home.ts +79 -0
- package/src/adapters/cursor/index.ts +331 -0
- package/src/adapters/cursor/models.ts +30 -0
- package/src/adapters/discovery.ts +21 -0
- package/src/adapters/grok/events.ts +112 -0
- package/src/adapters/grok/home.ts +132 -0
- package/src/adapters/grok/index.ts +339 -0
- package/src/adapters/grok/models.ts +48 -0
- package/src/adapters/opencode/index.ts +2 -0
- package/src/cli.ts +49 -19
- package/src/domain/catalog.ts +10 -1
- package/src/domain/cost.ts +1 -0
- package/src/domain/errors.ts +1 -0
- package/src/domain/failover.ts +5 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/ids.ts +2 -2
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +89 -5
- package/src/domain/record.ts +3 -0
- package/src/domain/util.ts +3 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/knowledge-command.ts +50 -0
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +30 -10
- package/src/entry/tui/fixtures.ts +34 -10
- package/src/entry/tui/profile-tree.ts +10 -1
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +16 -3
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/paths.ts +3 -1
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +34 -7
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +49 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +8 -1
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +39 -7
- package/src/services/doctor-checks.ts +43 -3
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +107 -32
- package/src/services/finalize.ts +2 -1
- package/src/services/lane-service.ts +4 -10
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +117 -31
- package/src/services/profile-store.ts +126 -5
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +39 -4
- package/src/services/run-store.ts +21 -5
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +32 -7
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { HOST_ARG } from "./entry/host-arg.ts";
|
|
2
3
|
import type { ArgsDef } from "citty";
|
|
3
4
|
import { errorMessage } from "./domain/errors.ts";
|
|
4
5
|
import { runtimeRefusal } from "./domain/runtime.ts";
|
|
@@ -12,7 +13,7 @@ if (refusal) {
|
|
|
12
13
|
|
|
13
14
|
const { realpathSync } = await import("node:fs");
|
|
14
15
|
const { fileURLToPath } = await import("node:url");
|
|
15
|
-
const { defineCommand, renderUsage, runCommand } = await import("citty");
|
|
16
|
+
const { defineCommand, renderUsage, runCommand, parseArgs } = await import("citty");
|
|
16
17
|
const { CatherdError, isCatherdError } = await import("./domain/errors.ts");
|
|
17
18
|
const { EXIT, exitCodeOf, printError, stripAnsi } = await import("./entry/cli-kit.ts");
|
|
18
19
|
const { VERSION } = await import("./infra/version.ts");
|
|
@@ -21,6 +22,7 @@ type Command = ReturnType<typeof defineCommand>;
|
|
|
21
22
|
|
|
22
23
|
/** The dashboard's flags, typed as any command's args so `main` stays a plain `Command`. */
|
|
23
24
|
const dashboardArgs: ArgsDef = {
|
|
25
|
+
...HOST_ARG,
|
|
24
26
|
plain: {
|
|
25
27
|
type: "boolean",
|
|
26
28
|
description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
|
|
@@ -47,6 +49,7 @@ export const main: Command = defineCommand({
|
|
|
47
49
|
profile: () => import("./entry/profile-command.ts").then((m) => m.profileCommand),
|
|
48
50
|
doctor: () => import("./entry/doctor-command.ts").then((m) => m.doctorCommand),
|
|
49
51
|
catalog: () => import("./entry/catalog-command.ts").then((m) => m.catalogCommand),
|
|
52
|
+
knowledge: () => import("./entry/knowledge-command.ts").then((m) => m.knowledgeCommand),
|
|
50
53
|
lock: () => import("./entry/lock-command.ts").then((m) => m.lockCommand),
|
|
51
54
|
"capture-fixtures": () =>
|
|
52
55
|
import("./entry/capture-fixtures-command.ts").then((m) => m.captureFixturesCommand),
|
|
@@ -55,7 +58,7 @@ export const main: Command = defineCommand({
|
|
|
55
58
|
},
|
|
56
59
|
// citty runs this after any subcommand too; only a bare `catherd` loads and opens the dashboard
|
|
57
60
|
async run(ctx) {
|
|
58
|
-
if (
|
|
61
|
+
if (ctx.args._.length > 0) return;
|
|
59
62
|
await (await import("./entry/tui/run.tsx")).tuiRun(ctx);
|
|
60
63
|
},
|
|
61
64
|
});
|
|
@@ -68,14 +71,24 @@ async function commandFor(argv: string[]): Promise<[Command, Command | undefined
|
|
|
68
71
|
let cmd = main;
|
|
69
72
|
let parent: Command | undefined;
|
|
70
73
|
const path: string[] = [];
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
let remaining = argv;
|
|
75
|
+
while (true) {
|
|
76
|
+
const defs = await resolve(cmd.args ?? {});
|
|
77
|
+
const navigationArgs: ArgsDef = Object.fromEntries(
|
|
78
|
+
Object.entries(defs).map(([key, def]) => [
|
|
79
|
+
key,
|
|
80
|
+
{ ...def, required: false, ...(def.type === "enum" ? { type: "string" as const } : {}) },
|
|
81
|
+
]),
|
|
82
|
+
) as ArgsDef;
|
|
83
|
+
const a = parseArgs(remaining, navigationArgs)._[0];
|
|
84
|
+
if (!a) break;
|
|
73
85
|
const subs = cmd.subCommands ? await resolve(cmd.subCommands) : undefined;
|
|
74
86
|
const next = subs?.[a];
|
|
75
87
|
if (!next) break;
|
|
76
88
|
parent = cmd;
|
|
77
89
|
cmd = (await resolve(next)) as Command;
|
|
78
90
|
path.push(a);
|
|
91
|
+
remaining = remaining.slice(remaining.indexOf(a) + 1);
|
|
79
92
|
}
|
|
80
93
|
return [cmd, parent, path];
|
|
81
94
|
}
|
|
@@ -122,22 +135,39 @@ export async function runCli(argv: string[]): Promise<number> {
|
|
|
122
135
|
console.log(`${await helpText(cmd, path)}\n`);
|
|
123
136
|
return EXIT.ok;
|
|
124
137
|
}
|
|
125
|
-
const command = own.find((a) => !a.startsWith("-"));
|
|
126
|
-
// a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
|
|
127
|
-
const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
|
|
128
|
-
// citty negates a boolean as `--no-<name>`
|
|
129
|
-
const known = (name: string) =>
|
|
130
|
-
Object.hasOwn(dashboardArgs, name) ||
|
|
131
|
-
(name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
|
|
132
|
-
const unknown = command === undefined ? own.find((a) => !known(flag(a))) : undefined;
|
|
133
|
-
if (unknown !== undefined) {
|
|
134
|
-
printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
|
|
135
|
-
return EXIT.usage;
|
|
136
|
-
}
|
|
137
|
-
// `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
|
|
138
|
-
// the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
|
|
139
|
-
if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
|
|
140
138
|
try {
|
|
139
|
+
const command = parseArgs(own, dashboardArgs)._[0];
|
|
140
|
+
// a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
|
|
141
|
+
const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
|
|
142
|
+
// citty negates a boolean as `--no-<name>`
|
|
143
|
+
const known = (name: string) =>
|
|
144
|
+
Object.hasOwn(dashboardArgs, name) ||
|
|
145
|
+
(name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
|
|
146
|
+
const unknown =
|
|
147
|
+
command === undefined
|
|
148
|
+
? own.find((a, i) => a.startsWith("-") && !known(flag(a)) && own[i - 1] !== "--host")
|
|
149
|
+
: undefined;
|
|
150
|
+
if (unknown !== undefined) {
|
|
151
|
+
printError(new CatherdError("E_INPUT_INVALID", `unknown option ${unknown}`, { fix: "catherd --help" }));
|
|
152
|
+
return EXIT.usage;
|
|
153
|
+
}
|
|
154
|
+
// `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
|
|
155
|
+
// the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
|
|
156
|
+
if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
|
|
157
|
+
const parsed = parseArgs(own, dashboardArgs);
|
|
158
|
+
if (parsed.host !== undefined) {
|
|
159
|
+
const forwarded = own.filter(
|
|
160
|
+
(a, i) => a !== "--host" && own[i - 1] !== "--host" && !a.startsWith("--host="),
|
|
161
|
+
);
|
|
162
|
+
rawArgs.splice(
|
|
163
|
+
0,
|
|
164
|
+
rawArgs.length,
|
|
165
|
+
...forwarded,
|
|
166
|
+
"--host",
|
|
167
|
+
String(parsed.host),
|
|
168
|
+
...(dashdash < 0 ? [] : argv.slice(dashdash)),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
141
171
|
await runCommand(main, { rawArgs });
|
|
142
172
|
return Number(process.exitCode ?? EXIT.ok);
|
|
143
173
|
} catch (e) {
|
package/src/domain/catalog.ts
CHANGED
|
@@ -40,11 +40,20 @@ export const BILLING_KEYS = [
|
|
|
40
40
|
"opencode",
|
|
41
41
|
"cursor",
|
|
42
42
|
"grok",
|
|
43
|
+
"antigravity",
|
|
43
44
|
] as const;
|
|
44
45
|
export type BillingKey = (typeof BILLING_KEYS)[number];
|
|
45
46
|
|
|
46
47
|
/** The keys a family's `on` map uses; the native `claude` pseudo-backend runs claude-code's model ids. */
|
|
47
|
-
const MODEL_KEYS = [
|
|
48
|
+
const MODEL_KEYS = [
|
|
49
|
+
"codex",
|
|
50
|
+
"claude-code",
|
|
51
|
+
"opencode-go",
|
|
52
|
+
"opencode",
|
|
53
|
+
"cursor",
|
|
54
|
+
"grok",
|
|
55
|
+
"antigravity",
|
|
56
|
+
] as const;
|
|
48
57
|
type ModelKey = (typeof MODEL_KEYS)[number];
|
|
49
58
|
|
|
50
59
|
export function billingKeyOf(r: Rung): BillingKey {
|
package/src/domain/cost.ts
CHANGED
package/src/domain/errors.ts
CHANGED
package/src/domain/failover.ts
CHANGED
|
@@ -113,7 +113,8 @@ function effortGap(a: string, b: string): number {
|
|
|
113
113
|
const BACKEND_OF_KEY: Record<string, string> = { "opencode-go": "opencode" };
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
* Every rung the catalog knows how to name: each shipped family's model on each backend at each effort
|
|
116
|
+
* Every rung the catalog knows how to name: each shipped family's model on each backend at each effort (at
|
|
117
|
+
* `#default` when it has none),
|
|
117
118
|
* each listed model at each effort, and each treat-like whose model is an opencode id (`opencode-go/…`,
|
|
118
119
|
* `opencode/…`), such as the shipped Kimi K3 stand-in. Sorted, without duplicates.
|
|
119
120
|
*/
|
|
@@ -122,7 +123,9 @@ export function catalogRungs(c: Catalog): string[] {
|
|
|
122
123
|
for (const f of c.families)
|
|
123
124
|
for (const [key, m] of Object.entries(f.on)) {
|
|
124
125
|
if (!m) continue;
|
|
125
|
-
|
|
126
|
+
// a model with no effort has one rung, #default (Claude Code's Haiku, Cursor's Composer; spec 1.3 §7.1)
|
|
127
|
+
for (const e of m.efforts.length ? m.efforts : ["default"])
|
|
128
|
+
out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
|
|
126
129
|
}
|
|
127
130
|
for (const [backend, l] of Object.entries(c.listed))
|
|
128
131
|
for (const m of l.models) for (const e of m.efforts) out.add(`${backend}:${m.id}#${e}`);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type OrchestrationHost = "claude-code" | "codex" | "unknown";
|
|
2
|
+
export type KnownHost = Exclude<OrchestrationHost, "unknown">;
|
|
3
|
+
export interface HostSessionRef {
|
|
4
|
+
host: KnownHost;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
hostSessionId: string | null;
|
|
7
|
+
name: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface HostContext {
|
|
10
|
+
host: OrchestrationHost;
|
|
11
|
+
session: HostSessionRef | null;
|
|
12
|
+
conflict: string | null;
|
|
13
|
+
}
|
|
14
|
+
export function sessionKey(s: Pick<HostSessionRef, "host" | "sessionId">): string {
|
|
15
|
+
// a Codex thread is a UUID, whatever its casing: an owner stored before normalization still matches
|
|
16
|
+
return JSON.stringify([s.host, s.host === "codex" ? s.sessionId.toLowerCase() : s.sessionId]);
|
|
17
|
+
}
|
package/src/domain/ids.ts
CHANGED
|
@@ -11,8 +11,8 @@ export function assertId(kind: string, value: string): string {
|
|
|
11
11
|
return value;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/** The backends that run as a process through a `BackendAdapter` (cursor
|
|
15
|
-
export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok"] as const;
|
|
14
|
+
/** The backends that run as a process through a `BackendAdapter` (spec 1.3: cursor, grok and antigravity). */
|
|
15
|
+
export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok", "antigravity"] as const;
|
|
16
16
|
|
|
17
17
|
/** `claude` is the native Claude Code subagent path (no process); the rest are adapters. */
|
|
18
18
|
const RUNG_BACKENDS = [...ADAPTER_IDS, "claude"] as const;
|
package/src/domain/notice.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type NoticePriority = "later" | "next";
|
|
|
9
9
|
/** One role's news: it finished (`record`), or an event while it runs (`stall`). */
|
|
10
10
|
export interface Notice {
|
|
11
11
|
kind: "finished" | "stalled";
|
|
12
|
+
eventId: string;
|
|
12
13
|
runId: string;
|
|
13
14
|
runTitle: string;
|
|
14
15
|
dispatchId: string;
|
|
@@ -62,9 +63,11 @@ const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2
|
|
|
62
63
|
|
|
63
64
|
function block(n: Notice): string {
|
|
64
65
|
const call = `run: "${n.runId}", name: "${n.name}"`;
|
|
65
|
-
if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})`;
|
|
66
|
+
if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})\nEvent: ${n.eventId}`;
|
|
66
67
|
const reply = capNoticeReply(n.reply);
|
|
67
|
-
return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`].join(
|
|
68
|
+
return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`, `Event: ${n.eventId}`].join(
|
|
69
|
+
"\n",
|
|
70
|
+
);
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/** The preview line of a message that carries several roles: `catherd · <title> · 3 roles finished: …`. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "./host.ts";
|
|
1
2
|
import {
|
|
2
3
|
type Catalog,
|
|
3
4
|
type Dim,
|
|
@@ -145,6 +146,7 @@ export function validateProfile(
|
|
|
145
146
|
c: Catalog,
|
|
146
147
|
backends: readonly string[],
|
|
147
148
|
doc?: ProfileDoc,
|
|
149
|
+
host: OrchestrationHost = "unknown",
|
|
148
150
|
): Validation {
|
|
149
151
|
const errors: Issue[] = [];
|
|
150
152
|
const warnings: Issue[] = [];
|
|
@@ -171,6 +173,8 @@ export function validateProfile(
|
|
|
171
173
|
});
|
|
172
174
|
if (!rc.enabled) continue;
|
|
173
175
|
for (const rung of rc.rungs) {
|
|
176
|
+
const native = nativeClaudeIssue(rung, host, `${at}.rungs`);
|
|
177
|
+
if (native) errors.push(native);
|
|
174
178
|
const r = tryParseRung(rung);
|
|
175
179
|
if (!r) {
|
|
176
180
|
errors.push({
|
|
@@ -274,6 +278,8 @@ export function validateProfile(
|
|
|
274
278
|
});
|
|
275
279
|
for (const [from, to] of Object.entries(p.failover)) {
|
|
276
280
|
const at = `failover.${from}`;
|
|
281
|
+
const native = ladders.has(from) ? nativeClaudeIssue(to, host, at) : null;
|
|
282
|
+
if (native) errors.push(native);
|
|
277
283
|
const a = tryParseRung(from);
|
|
278
284
|
const b = tryParseRung(to);
|
|
279
285
|
if (!a || !b) {
|
|
@@ -327,3 +333,14 @@ export function validateProfile(
|
|
|
327
333
|
warnings.push({ path: x.path, message: standInMessage(x), fix: SUGGEST_FIX(x.rungs[0] as string) });
|
|
328
334
|
return { errors, warnings };
|
|
329
335
|
}
|
|
336
|
+
|
|
337
|
+
export function nativeClaudeIssue(rung: string, host: OrchestrationHost, path: string): Issue | null {
|
|
338
|
+
const r = tryParseRung(rung);
|
|
339
|
+
return host !== "claude-code" && r?.backend === "claude"
|
|
340
|
+
? {
|
|
341
|
+
path,
|
|
342
|
+
message: `${rung}: native Claude subagents require the claude-code orchestration host`,
|
|
343
|
+
fix: `use claude-code:${r.model}#${r.effort} for headless execution, or --host claude-code`,
|
|
344
|
+
}
|
|
345
|
+
: null;
|
|
346
|
+
}
|
package/src/domain/profile.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "./host.ts";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import type { Budget } from "./budget.ts";
|
|
3
4
|
import { BILLING_KEYS, type BillingKey } from "./catalog.ts";
|
|
@@ -169,10 +170,67 @@ export const DEFAULT_FAILOVER: Record<string, string> = {
|
|
|
169
170
|
[SOL("medium")]: "opencode:opencode-go/kimi-k3#max",
|
|
170
171
|
};
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Cursor's live slug and efforts for a paired family (`cursor-agent models`, 2026-10-01). Empty efforts means the
|
|
175
|
+
* bare slug.
|
|
176
|
+
*/
|
|
177
|
+
const CURSOR_PAIR: Record<string, { id: string; efforts: readonly string[] }> = {
|
|
178
|
+
"grok-4.7": { id: "grok-4.7", efforts: ["low", "medium", "high", "xhigh"] },
|
|
179
|
+
"grok-4.6": { id: "cursor-grok-4.6", efforts: ["low", "medium", "high", "xhigh"] },
|
|
180
|
+
"grok-4.5": { id: "cursor-grok-4.5", efforts: ["low", "medium", "high"] },
|
|
181
|
+
"gemini-3.8-flash": { id: "gemini-3.8-flash", efforts: ["low", "medium", "high"] },
|
|
182
|
+
"gemini-3.7-flash": { id: "gemini-3.7-flash", efforts: ["low", "medium", "high"] },
|
|
183
|
+
"gemini-3.6-flash": { id: "gemini-3.6-flash", efforts: ["minimal", "low", "medium", "high"] },
|
|
184
|
+
"gemini-3.1-pro": { id: "gemini-3.1-pro", efforts: [] },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** The Cursor effort a paired rung can actually run: the same one when listed, else `high`, else the bare slug. */
|
|
188
|
+
function cursorPairEffort(model: string, effort: string): string {
|
|
189
|
+
const list = CURSOR_PAIR[model]?.efforts ?? [];
|
|
190
|
+
if (list.length === 0) return "default";
|
|
191
|
+
if (effort !== "default" && list.includes(effort)) return effort;
|
|
192
|
+
return list.includes("high") ? "high" : (list[0] as string);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Spec 1.3 §7.3: the same model through two backends bills two pools, so each stands in for the other on a
|
|
197
|
+
* limit, at the same effort where both list it. Otherwise the stand-in runs at `high`: Grok 4.5 `xhigh` at Cursor
|
|
198
|
+
* `high`, Cursor's Gemini 3.6 `minimal` at agy `high`; Gemini 3.1 Pro's bare Cursor slug pairs with agy `high`.
|
|
199
|
+
* Grok and Gemini are paired with Cursor. Consulted after the profile's own failover and never written into a
|
|
200
|
+
* profile, where a pair whose rung is on no ladder would warn "never runs"; never a stand-in for a shipped Codex,
|
|
201
|
+
* Claude or opencode rung.
|
|
202
|
+
*/
|
|
203
|
+
export const PAIRED_FAILOVER: Record<string, string> = Object.fromEntries(
|
|
204
|
+
(
|
|
205
|
+
[
|
|
206
|
+
["grok", ["grok-4.7", "grok-4.6", "grok-4.5"], ["low", "medium", "high", "xhigh"]],
|
|
207
|
+
[
|
|
208
|
+
"antigravity",
|
|
209
|
+
["gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash"],
|
|
210
|
+
["low", "medium", "high"],
|
|
211
|
+
],
|
|
212
|
+
["antigravity", ["gemini-3.1-pro"], ["low", "high"]],
|
|
213
|
+
] as const
|
|
214
|
+
).flatMap(([backend, models, efforts]) =>
|
|
215
|
+
models.flatMap((m) => {
|
|
216
|
+
const cursor = CURSOR_PAIR[m] as { id: string; efforts: readonly string[] };
|
|
217
|
+
const back = ["default", ...efforts].map(
|
|
218
|
+
(e) => [`${backend}:${m}#${e}`, `cursor:${cursor.id}#${cursorPairEffort(m, e)}`] as const,
|
|
219
|
+
);
|
|
220
|
+
// `#default` too, as on the other side: a Cursor rung with no effort flag fails over the same way
|
|
221
|
+
const fore = [...new Set(["default", ...cursor.efforts])].map((e) => {
|
|
222
|
+
const there = e !== "default" && (efforts as readonly string[]).includes(e) ? e : "high";
|
|
223
|
+
return [`cursor:${cursor.id}#${e}`, `${backend}:${m}#${there}`] as const;
|
|
224
|
+
});
|
|
225
|
+
return [...back, ...fore];
|
|
226
|
+
}),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
|
|
172
230
|
/** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
|
|
173
231
|
const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
|
|
174
232
|
|
|
175
|
-
/**
|
|
233
|
+
/** Stored defaults for init/new; architect/verifier rungs resolve from the current host. */
|
|
176
234
|
export function defaultProfileDoc(name = "default"): ProfileDoc {
|
|
177
235
|
return {
|
|
178
236
|
schema: PROFILE_SCHEMA,
|
|
@@ -180,7 +238,7 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
|
|
|
180
238
|
objective: "cost",
|
|
181
239
|
jev: { use: "auto" },
|
|
182
240
|
billing: Object.fromEntries(WRITTEN_BILLING.map((k) => [k, DEFAULT_BILLING[k]])),
|
|
183
|
-
roles: structuredClone(BUILTIN_ROLES),
|
|
241
|
+
roles: hostDefaultsDoc({ schema: PROFILE_SCHEMA, roles: structuredClone(BUILTIN_ROLES) }).roles,
|
|
184
242
|
harness: {
|
|
185
243
|
codex: { isolated: false },
|
|
186
244
|
"claude-code": { isolated: false },
|
|
@@ -195,12 +253,38 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
|
|
|
195
253
|
};
|
|
196
254
|
}
|
|
197
255
|
|
|
198
|
-
/**
|
|
199
|
-
export function
|
|
256
|
+
/** Remove only the four architect/verifier execution choices, preserving other stored fields. */
|
|
257
|
+
export function hostDefaultsDoc(doc: ProfileDoc): ProfileDoc {
|
|
258
|
+
const next = structuredClone(doc);
|
|
259
|
+
for (const role of ["architect", "verifier"] as const) {
|
|
260
|
+
const r = next.roles?.[role];
|
|
261
|
+
if (r) {
|
|
262
|
+
delete r.rungs;
|
|
263
|
+
delete r.defaultRung;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return next;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Resolve omitted role execution choices from the explicit orchestration host. */
|
|
270
|
+
export function resolveProfile(doc: ProfileDoc, name: string, host: OrchestrationHost): Profile {
|
|
200
271
|
const roles = {} as Record<Role, RoleConfig>;
|
|
201
272
|
for (const role of ROLES) {
|
|
202
273
|
const d = doc.roles?.[role];
|
|
203
274
|
const b = BUILTIN_ROLES[role];
|
|
275
|
+
let omitted = b.rungs;
|
|
276
|
+
if ((role === "architect" || role === "verifier") && d?.rungs === undefined) {
|
|
277
|
+
if (host === "unknown" && (d?.enabled ?? b.enabled))
|
|
278
|
+
throw new CatherdError("E_CONFIG_INVALID", `roles.${role}.rungs needs an orchestration host`, {
|
|
279
|
+
fix: "pass --host codex or --host claude-code, or set explicit role rungs",
|
|
280
|
+
});
|
|
281
|
+
omitted =
|
|
282
|
+
host === "codex"
|
|
283
|
+
? [`codex:gpt-6.1-sol#${role === "architect" ? "high" : "low"}`]
|
|
284
|
+
: host === "unknown"
|
|
285
|
+
? []
|
|
286
|
+
: b.rungs;
|
|
287
|
+
}
|
|
204
288
|
// a role that lists its own rungs never inherits the built-in default rung, which may not be among them
|
|
205
289
|
const defaultRung = d?.defaultRung ?? (d?.rungs === undefined ? b.defaultRung : undefined);
|
|
206
290
|
roles[role] = {
|
|
@@ -211,7 +295,7 @@ export function resolveProfile(doc: ProfileDoc, name: string): Profile {
|
|
|
211
295
|
: known(ACCESS, d.access)
|
|
212
296
|
? d.access
|
|
213
297
|
: STORED_FALLBACK.access,
|
|
214
|
-
rungs: [...(d?.rungs ??
|
|
298
|
+
rungs: [...(d?.rungs ?? omitted)],
|
|
215
299
|
...(defaultRung ? { defaultRung } : {}),
|
|
216
300
|
...(d?.network === false ? { network: false as const } : {}),
|
|
217
301
|
};
|
package/src/domain/record.ts
CHANGED
|
@@ -68,11 +68,14 @@ export const RunRecordSchema = z.looseObject({
|
|
|
68
68
|
threadHeavy: z.boolean(),
|
|
69
69
|
access: z.enum(ACCESS),
|
|
70
70
|
isolated: z.boolean(),
|
|
71
|
+
/** the role's network grant (1.1 §5) it ran under, copied from admit.json; absent before 1.3 */
|
|
72
|
+
network: z.boolean().optional(),
|
|
71
73
|
images: z.array(z.string()),
|
|
72
74
|
error: z.object({ code: z.string(), message: z.string() }).nullable(),
|
|
73
75
|
replyPath: z.string(),
|
|
74
76
|
/** the Claude Code session that dispatched it, copied from admit.json (spec §3.3); absent before 1.1 */
|
|
75
77
|
sessionId: z.string().optional(),
|
|
78
|
+
host: z.enum(["claude-code", "codex"]).optional(),
|
|
76
79
|
});
|
|
77
80
|
export type RunRecord = z.infer<typeof RunRecordSchema>;
|
|
78
81
|
|
package/src/domain/util.ts
CHANGED
|
@@ -5,6 +5,9 @@ export const slug = (s: string): string =>
|
|
|
5
5
|
.replace(/[^a-z0-9]+/g, "-")
|
|
6
6
|
.replace(/^-|-$/g, "");
|
|
7
7
|
|
|
8
|
+
/** One table cell or one line of text: `|` and newlines become `/`, whitespace runs one space, trimmed. */
|
|
9
|
+
export const cell = (s: string): string => s.replace(/[|\n]/g, "/").replace(/\s+/g, " ").trim();
|
|
10
|
+
|
|
8
11
|
/** The middle value, or the mean of the two middle values for an even count; null when there are none. */
|
|
9
12
|
export function median(xs: readonly number[]): number | null {
|
|
10
13
|
if (xs.length === 0) return null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HOST_ARG, terminalHost } from "./host-arg.ts";
|
|
1
2
|
import { defineCommand } from "citty";
|
|
2
3
|
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
3
4
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
@@ -194,6 +195,7 @@ const treatLike = defineCommand({
|
|
|
194
195
|
"Score an unscored rung as a scored one; --suggest ranks stand-ins, --clear and --reset remove your mappings",
|
|
195
196
|
},
|
|
196
197
|
args: {
|
|
198
|
+
...HOST_ARG,
|
|
197
199
|
rung: {
|
|
198
200
|
type: "positional",
|
|
199
201
|
required: false,
|
|
@@ -228,14 +230,14 @@ const treatLike = defineCommand({
|
|
|
228
230
|
return;
|
|
229
231
|
}
|
|
230
232
|
if (args.clear !== undefined) {
|
|
231
|
-
const r = await clearTreatLike(args.clear);
|
|
233
|
+
const r = await clearTreatLike(args.clear, terminalHost(args.host).host);
|
|
232
234
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
233
235
|
for (const l of leftLines(r.left)) console.log(l);
|
|
234
236
|
console.log(`${mark("ok")} ${r.rung} is no longer treated like ${r.like}`);
|
|
235
237
|
return;
|
|
236
238
|
}
|
|
237
239
|
if (args.reset) {
|
|
238
|
-
const r = await resetTreatLikes();
|
|
240
|
+
const r = await resetTreatLikes(terminalHost(args.host).host);
|
|
239
241
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
240
242
|
for (const l of leftLines(r.left)) console.log(l);
|
|
241
243
|
console.log(
|
package/src/entry/deps.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HostContext } from "../domain/host.ts";
|
|
1
2
|
import { readSessionEnv } from "../infra/claude-session.ts";
|
|
2
3
|
import { VERSION } from "../infra/version.ts";
|
|
3
4
|
import type { Deps } from "../services/ports.ts";
|
|
@@ -5,13 +6,15 @@ import { profileService } from "../services/profile-service.ts";
|
|
|
5
6
|
import { routingService } from "../services/routing-service.ts";
|
|
6
7
|
|
|
7
8
|
/** The services every entry point (MCP server, CLI commands) runs on. */
|
|
8
|
-
export function defaultDeps(): Deps {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export function defaultDeps(host: HostContext = { host: "unknown", session: null, conflict: null }): Deps {
|
|
10
|
+
const deps: Deps = {
|
|
11
|
+
host,
|
|
12
|
+
profiles: profileService(() => deps.host),
|
|
11
13
|
routing: routingService(),
|
|
12
14
|
version: VERSION,
|
|
13
15
|
pollMs: 250,
|
|
14
16
|
session: readSessionEnv(process.env),
|
|
15
17
|
now: Date.now,
|
|
16
18
|
};
|
|
19
|
+
return deps;
|
|
17
20
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
2
|
+
import { HOST_ARG, terminalHost } from "./host-arg.ts";
|
|
1
3
|
import { defineCommand } from "citty";
|
|
2
4
|
import { VERSION } from "../infra/version.ts";
|
|
3
5
|
import { type DoctorReport, doctor } from "../services/doctor.ts";
|
|
4
|
-
import { probePush } from "../services/doctor-push.ts";
|
|
5
6
|
import type { Check } from "../services/doctor-checks.ts";
|
|
6
7
|
import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
7
8
|
import { mcpHandshake } from "./mcp/handshake.ts";
|
|
@@ -34,15 +35,23 @@ export const doctorCommand = defineCommand({
|
|
|
34
35
|
description: "Readiness report: Bun, backends, Jev, the plugin, agents, the MCP server, locks",
|
|
35
36
|
},
|
|
36
37
|
args: {
|
|
38
|
+
...HOST_ARG,
|
|
37
39
|
...JSON_ARG,
|
|
40
|
+
"test-push": {
|
|
41
|
+
type: "boolean",
|
|
42
|
+
description:
|
|
43
|
+
"send one labeled smoke to the validated original session (receipt does not prove processing)",
|
|
44
|
+
},
|
|
38
45
|
plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
|
|
39
46
|
},
|
|
40
47
|
async run({ args }) {
|
|
41
48
|
const r = await doctor({
|
|
49
|
+
host: terminalHost(args.host),
|
|
50
|
+
repo: await gitToplevel(process.cwd()),
|
|
42
51
|
bunVersion: Bun.version,
|
|
43
52
|
version: VERSION,
|
|
44
53
|
handshake: () => mcpHandshake(),
|
|
45
|
-
|
|
54
|
+
testPush: args["test-push"] === true,
|
|
46
55
|
});
|
|
47
56
|
if (args.json) printJson(r);
|
|
48
57
|
else for (const l of formatReport(r, args.plain === true)) console.log(l);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
2
|
+
import type { HostContext } from "../domain/host.ts";
|
|
3
|
+
import { resolveHost } from "../infra/host-context.ts";
|
|
4
|
+
|
|
5
|
+
export const HOST_ARG = {
|
|
6
|
+
host: {
|
|
7
|
+
type: "enum" as const,
|
|
8
|
+
options: ["auto", "codex", "claude-code"],
|
|
9
|
+
description: "orchestration host (auto uses session evidence)",
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function terminalHost(value: string | undefined, env = process.env): HostContext {
|
|
14
|
+
if (value === undefined || value === "auto") return resolveHost({ env });
|
|
15
|
+
if (value !== "codex" && value !== "claude-code")
|
|
16
|
+
throw new CatherdError("E_INPUT_INVALID", `unknown host ${value}`, {
|
|
17
|
+
fix: "use --host auto, codex or claude-code",
|
|
18
|
+
});
|
|
19
|
+
const resolved = resolveHost({ env, terminalHost: value });
|
|
20
|
+
if (resolved.conflict) return resolved;
|
|
21
|
+
if (resolved.host !== value)
|
|
22
|
+
return {
|
|
23
|
+
host: "unknown",
|
|
24
|
+
session: null,
|
|
25
|
+
conflict: "Terminal host and launcher session evidence disagree; remove the other host’s environment",
|
|
26
|
+
};
|
|
27
|
+
return { ...resolved, session: null };
|
|
28
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
2
|
+
import { HOST_ARG, terminalHost } from "./host-arg.ts";
|
|
1
3
|
import { defineCommand } from "citty";
|
|
2
4
|
import { assertProfileName } from "../domain/profile.ts";
|
|
3
5
|
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
@@ -11,6 +13,7 @@ import { type SyncReport, syncSources } from "../services/source-sync.ts";
|
|
|
11
13
|
import { reinstallCommand } from "../services/doctor-checks.ts";
|
|
12
14
|
import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../services/global-install.ts";
|
|
13
15
|
import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
|
|
16
|
+
import { activeName } from "../services/profile-store.ts";
|
|
14
17
|
import { formatRefreshed, syncLines } from "./catalog-command.ts";
|
|
15
18
|
import { mark as markOf } from "./cli-kit.ts";
|
|
16
19
|
import { formatReport } from "./doctor-command.ts";
|
|
@@ -195,6 +198,7 @@ export const initCommand = defineCommand({
|
|
|
195
198
|
"First run: the global catherd command, the Jev key, the Artificial Analysis key, a sync of the public model sources, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the Artificial Analysis key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
|
|
196
199
|
},
|
|
197
200
|
args: {
|
|
201
|
+
...HOST_ARG,
|
|
198
202
|
// citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
|
|
199
203
|
"no-input": { type: "boolean", description: "ask nothing: keep what exists, else write the defaults" },
|
|
200
204
|
// read as global: false, like no-input above
|
|
@@ -202,7 +206,10 @@ export const initCommand = defineCommand({
|
|
|
202
206
|
type: "boolean",
|
|
203
207
|
description: "do not install the global catherd command (bun add -g catherd-cli@<this version>)",
|
|
204
208
|
},
|
|
205
|
-
profile: {
|
|
209
|
+
profile: {
|
|
210
|
+
type: "string",
|
|
211
|
+
description: "the profile to set up and make active (default: current repo profile)",
|
|
212
|
+
},
|
|
206
213
|
plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
|
|
207
214
|
},
|
|
208
215
|
async run({ args }) {
|
|
@@ -218,19 +225,21 @@ export const initCommand = defineCommand({
|
|
|
218
225
|
await jevStep(ask, {}, plain);
|
|
219
226
|
await aaStep(ask, {}, plain);
|
|
220
227
|
await syncStep({ plain });
|
|
221
|
-
|
|
222
|
-
const name = assertProfileName(
|
|
223
|
-
args.profile ?? (ask ? (await ask.ask("Profile to set up [default]: ")) || "default" : "default"),
|
|
224
|
-
);
|
|
228
|
+
const repo = await gitToplevel(process.cwd());
|
|
225
229
|
// 0.x files go first, so a legacy profile about to be moved aside is never asked about
|
|
226
230
|
const moved = moveLegacy();
|
|
231
|
+
const current = activeName(repo);
|
|
232
|
+
if (args.profile !== undefined) ask?.skip?.();
|
|
233
|
+
const selected =
|
|
234
|
+
args.profile ?? (ask ? (await ask.ask(`Profile to set up [${current}]: `)) || undefined : undefined);
|
|
235
|
+
const name = assertProfileName(selected ?? current);
|
|
227
236
|
const exists = hasProfileFile(name);
|
|
228
237
|
if (!exists) ask?.skip?.();
|
|
229
238
|
const overwrite =
|
|
230
239
|
ask !== null &&
|
|
231
240
|
exists &&
|
|
232
241
|
/^y(es)?$/i.test(await ask.ask(`Replace profile ${name} with the default profile? [y/N] `));
|
|
233
|
-
const r = await initSetup({ profile:
|
|
242
|
+
const r = await initSetup({ profile: selected, repo, overwrite, host: terminalHost(args.host) });
|
|
234
243
|
for (const f of [...moved, ...r.moved]) console.log(`${mark("ok")} moved a 0.x file aside: ${f}`);
|
|
235
244
|
for (const l of profileLines(r, plain)) console.log(l);
|
|
236
245
|
if (r.synced?.linked.length)
|
|
@@ -238,13 +247,15 @@ export const initCommand = defineCommand({
|
|
|
238
247
|
for (const x of r.refreshed) console.log(formatRefreshed(x, plain));
|
|
239
248
|
console.log("");
|
|
240
249
|
const report = await doctor({
|
|
250
|
+
host: terminalHost(args.host),
|
|
251
|
+
repo,
|
|
241
252
|
bunVersion: Bun.version,
|
|
242
253
|
version: VERSION,
|
|
243
254
|
handshake: () => mcpHandshake(),
|
|
244
255
|
});
|
|
245
256
|
for (const l of formatReport(report, plain)) console.log(l);
|
|
246
257
|
console.log("");
|
|
247
|
-
for (const l of PLUGIN_STEPS) console.log(l);
|
|
258
|
+
for (const l of terminalHost(args.host).host === "claude-code" ? PLUGIN_STEPS : []) console.log(l);
|
|
248
259
|
} finally {
|
|
249
260
|
ask?.close();
|
|
250
261
|
}
|