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.
Files changed (104) hide show
  1. package/README.md +146 -36
  2. package/catalog/models.json +148 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +12 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +11 -0
  8. package/plugin/.mcp-codex.json +10 -0
  9. package/plugin/bin/catherd-mcp +1 -1
  10. package/plugin/skills/catherd/SKILL.md +45 -39
  11. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  12. package/src/adapters/access.ts +61 -18
  13. package/src/adapters/all.ts +6 -0
  14. package/src/adapters/antigravity/events.ts +131 -0
  15. package/src/adapters/antigravity/home.ts +46 -0
  16. package/src/adapters/antigravity/index.ts +320 -0
  17. package/src/adapters/antigravity/models.ts +37 -0
  18. package/src/adapters/backend.ts +34 -1
  19. package/src/adapters/claude-code/index.ts +2 -0
  20. package/src/adapters/cli.ts +1 -64
  21. package/src/adapters/codex/index.ts +2 -0
  22. package/src/adapters/cursor/events.ts +120 -0
  23. package/src/adapters/cursor/home.ts +79 -0
  24. package/src/adapters/cursor/index.ts +331 -0
  25. package/src/adapters/cursor/models.ts +30 -0
  26. package/src/adapters/discovery.ts +21 -0
  27. package/src/adapters/grok/events.ts +112 -0
  28. package/src/adapters/grok/home.ts +132 -0
  29. package/src/adapters/grok/index.ts +339 -0
  30. package/src/adapters/grok/models.ts +48 -0
  31. package/src/adapters/opencode/index.ts +2 -0
  32. package/src/cli.ts +49 -19
  33. package/src/domain/catalog.ts +10 -1
  34. package/src/domain/cost.ts +1 -0
  35. package/src/domain/errors.ts +1 -0
  36. package/src/domain/failover.ts +5 -2
  37. package/src/domain/host.ts +17 -0
  38. package/src/domain/ids.ts +2 -2
  39. package/src/domain/notice.ts +5 -2
  40. package/src/domain/profile-rules.ts +17 -0
  41. package/src/domain/profile.ts +89 -5
  42. package/src/domain/record.ts +3 -0
  43. package/src/domain/util.ts +3 -0
  44. package/src/entry/catalog-command.ts +4 -2
  45. package/src/entry/deps.ts +6 -3
  46. package/src/entry/doctor-command.ts +11 -2
  47. package/src/entry/host-arg.ts +28 -0
  48. package/src/entry/init-command.ts +18 -7
  49. package/src/entry/knowledge-command.ts +50 -0
  50. package/src/entry/lock-command.ts +7 -2
  51. package/src/entry/mcp/handshake.ts +3 -1
  52. package/src/entry/mcp/run-tools.ts +5 -2
  53. package/src/entry/mcp/server.ts +129 -40
  54. package/src/entry/mcp/setup-tools.ts +7 -2
  55. package/src/entry/profile-command.ts +108 -12
  56. package/src/entry/runs-command.ts +81 -5
  57. package/src/entry/tui/effects.ts +30 -10
  58. package/src/entry/tui/fixtures.ts +34 -10
  59. package/src/entry/tui/profile-tree.ts +10 -1
  60. package/src/entry/tui/run.tsx +10 -4
  61. package/src/entry/tui/state.ts +19 -6
  62. package/src/entry/tui/stories.ts +11 -4
  63. package/src/entry/tui/views/profile-actions.ts +4 -2
  64. package/src/entry/tui/views/profiles.tsx +16 -3
  65. package/src/entry/tui/views/save-dialog.tsx +2 -2
  66. package/src/infra/cli.ts +65 -0
  67. package/src/infra/codex-queue.ts +165 -0
  68. package/src/infra/delivery.ts +83 -0
  69. package/src/infra/dispatch-dir.ts +1 -0
  70. package/src/infra/env.ts +4 -1
  71. package/src/infra/host-context.ts +58 -0
  72. package/src/infra/paths.ts +3 -1
  73. package/src/infra/peer-inbox.ts +4 -1
  74. package/src/infra/supervisor.ts +3 -2
  75. package/src/services/admission.ts +34 -7
  76. package/src/services/agent-links.ts +30 -5
  77. package/src/services/backends.ts +49 -4
  78. package/src/services/capture.ts +112 -54
  79. package/src/services/catalog-refresh.ts +2 -1
  80. package/src/services/dispatch-service.ts +17 -7
  81. package/src/services/dispatches.ts +8 -1
  82. package/src/services/doctor-access.ts +14 -7
  83. package/src/services/doctor-backends.ts +39 -7
  84. package/src/services/doctor-checks.ts +43 -3
  85. package/src/services/doctor-push.ts +69 -90
  86. package/src/services/doctor.ts +107 -32
  87. package/src/services/finalize.ts +2 -1
  88. package/src/services/lane-service.ts +4 -10
  89. package/src/services/notifier.ts +322 -70
  90. package/src/services/peek.ts +46 -9
  91. package/src/services/ports.ts +8 -2
  92. package/src/services/profile-service.ts +117 -31
  93. package/src/services/profile-store.ts +126 -5
  94. package/src/services/routing-service.ts +6 -1
  95. package/src/services/run-debug.ts +98 -4
  96. package/src/services/run-service.ts +39 -4
  97. package/src/services/run-store.ts +21 -5
  98. package/src/services/runs-page.ts +3 -2
  99. package/src/services/session-view.ts +18 -13
  100. package/src/services/sessions.ts +32 -16
  101. package/src/services/setup.ts +31 -10
  102. package/src/services/source-derive.ts +32 -7
  103. package/src/services/summary.ts +40 -5
  104. 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 (!ctx.rawArgs.every((a) => a.startsWith("-"))) return;
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
- for (const a of argv) {
72
- if (a.startsWith("-")) continue;
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) {
@@ -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 = ["codex", "claude-code", "opencode-go", "opencode", "cursor", "grok"] as const;
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 {
@@ -13,6 +13,7 @@ export const DEFAULT_BILLING: Record<BillingKey, BillingMode> = {
13
13
  opencode: "metered",
14
14
  cursor: "metered",
15
15
  grok: "metered",
16
+ antigravity: "metered",
16
17
  };
17
18
 
18
19
  /**
@@ -5,6 +5,7 @@ export type ErrorCode =
5
5
  | "E_BACKEND_MISSING"
6
6
  | "E_BACKEND_TOO_OLD"
7
7
  | "E_BACKEND_NOT_LOGGED_IN"
8
+ | "E_BACKEND_CANNOT_RUN"
8
9
  | "E_BACKEND_MODEL_UNKNOWN"
9
10
  | "E_RUN_NOT_FOUND"
10
11
  | "E_RUN_CORRUPT"
@@ -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
- for (const e of m.efforts) out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
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 and grok: plan 8). */
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;
@@ -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("\n");
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
+ }
@@ -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
- /** The full default profile document, every field written out: what `init` and `profile new` save. */
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
- /** Spec §7.1: every field a document leaves out takes its default; a missing role takes the built-in one. */
199
- export function resolveProfile(doc: ProfileDoc, name: string): Profile {
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 ?? b.rungs)],
298
+ rungs: [...(d?.rungs ?? omitted)],
215
299
  ...(defaultRung ? { defaultRung } : {}),
216
300
  ...(d?.network === false ? { network: false as const } : {}),
217
301
  };
@@ -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
 
@@ -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
- return {
10
- profiles: profileService(),
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
- push: () => probePush(),
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: { type: "string", description: "the profile to set up and make active (default: default)" },
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
- if (args.profile !== undefined) ask?.skip?.();
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: name, overwrite });
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
  }