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
@@ -5,7 +5,7 @@ import { CatherdError } from "../domain/errors.ts";
5
5
  import { assertId } from "../domain/ids.ts";
6
6
  import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
7
7
  import type { OutcomeRow, RouteRow } from "../domain/route.ts";
8
- import { slug } from "../domain/util.ts";
8
+ import { cell, slug } from "../domain/util.ts";
9
9
  import { withFileLock } from "../infra/filelock.ts";
10
10
  import { dataDir, repoDir, runsDir } from "../infra/paths.ts";
11
11
  import {
@@ -21,7 +21,8 @@ import {
21
21
  } from "../infra/store.ts";
22
22
 
23
23
  /** Spec §3.3: the Claude Code session that started a run; absent on 1.0 runs and outside Claude Code. */
24
- const StartedBySchema = z.object({
24
+ const StartedBySchema = z.looseObject({
25
+ host: z.enum(["claude-code", "codex"]).default("claude-code"),
25
26
  sessionId: z.string(),
26
27
  hostSessionId: z.string().nullable(),
27
28
  name: z.string().nullable(),
@@ -82,7 +83,12 @@ export function createRun(o: {
82
83
  aLines: string[];
83
84
  version: string;
84
85
  now?: Date;
85
- startedBy?: StartedBy | null;
86
+ startedBy?: {
87
+ host?: StartedBy["host"];
88
+ sessionId: string;
89
+ hostSessionId: string | null;
90
+ name: string | null;
91
+ } | null;
86
92
  }): Run {
87
93
  const now = o.now ?? new Date();
88
94
  const root = runsDir(o.repo);
@@ -123,7 +129,7 @@ export function createRun(o: {
123
129
  aLines: o.aLines,
124
130
  createdAt: now.toISOString(),
125
131
  catherdVersion: o.version,
126
- ...(o.startedBy ? { startedBy: o.startedBy } : {}),
132
+ ...(o.startedBy ? { startedBy: StartedBySchema.parse(o.startedBy) } : {}),
127
133
  };
128
134
  writeJsonAtomic(p.meta, meta);
129
135
  return { id, dir, meta };
@@ -196,7 +202,7 @@ export function readRecords(run: Run): { records: RunRecord[]; corrupt: number }
196
202
  if (!r.success) bad++;
197
203
  else if (!seen.has(r.data.dispatchId)) {
198
204
  seen.add(r.data.dispatchId);
199
- records.push(r.data);
205
+ records.push(r.data.sessionId && !r.data.host ? { ...r.data, host: "claude-code" } : r.data);
200
206
  }
201
207
  }
202
208
  return { records, corrupt: bad };
@@ -273,6 +279,16 @@ export function appendLedger(run: Run, row: string): void {
273
279
  /** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
274
280
  export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
275
281
 
282
+ /**
283
+ * Appends one line to the repo's knowledge.md, `- <date> <source>: <text>` with `text` on one line, and returns
284
+ * it. The single writer of knowledge.md: `land`'s `learned` and `catherd knowledge add` both come through here.
285
+ */
286
+ export function appendKnowledge(toplevel: string, at: Date, source: string, text: string): string {
287
+ const line = `- ${at.toISOString().slice(0, 10)} ${source}: ${cell(text)}`;
288
+ appendPrivate(knowledgeFile(toplevel), `${line}\n`);
289
+ return line;
290
+ }
291
+
276
292
  const SERVER_OWNED = new Set([
277
293
  "meta.json",
278
294
  "state.md",
@@ -1,3 +1,4 @@
1
+ import { sessionKey } from "../domain/host.ts";
1
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { budgetStatus } from "../domain/budget.ts";
@@ -98,7 +99,7 @@ export interface MilestoneDetail {
98
99
  digest: string | null;
99
100
  }
100
101
 
101
- const keyOf = (g: SessionGroup): string | null => g.session?.sessionId ?? null;
102
+ const keyOf = (g: SessionGroup): string | null => (g.session ? sessionKey(g.session) : null);
102
103
 
103
104
  /** The runs that live on in a session now (it owns them): each run's live roles and landings count once. */
104
105
  const ownRuns = (g: SessionGroup): Run[] => g.runs.filter((x) => x.current).map((x) => x.run);
@@ -174,7 +175,7 @@ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
174
175
  try {
175
176
  const b = budgetStatus(
176
177
  spendOf(run, readRecords(run).records, liveDispatches(run, deps.now()), deps.now()),
177
- deps.profiles.forRepo(run.meta.repo).budget,
178
+ deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
178
179
  );
179
180
  budget = b?.fraction ?? null;
180
181
  } catch {
@@ -1,3 +1,4 @@
1
+ import { sessionKey, type KnownHost } from "../domain/host.ts";
1
2
  import { statSync } from "node:fs";
2
3
  import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
3
4
  import { type Run, runPaths } from "./run-store.ts";
@@ -14,6 +15,7 @@ import { readSessionRows } from "./sessions.ts";
14
15
 
15
16
  /** A session as the runs page shows it: its name read live while it runs, else the last one recorded. */
16
17
  export interface RunSession {
18
+ host: KnownHost;
17
19
  sessionId: string;
18
20
  hostSessionId: string | null;
19
21
  name: string;
@@ -56,10 +58,11 @@ export function runActivity(run: Run): number {
56
58
  /** The sessions each run has had, oldest first: its starter, then each one that took it over. */
57
59
  function trailOf(
58
60
  run: Run,
59
- ): { sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
61
+ ): { host: KnownHost; sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
60
62
  const rows = readSessionRows(run);
61
63
  const s = run.meta.startedBy;
62
- if (s && !rows.some((r) => r.sessionId === s.sessionId)) return [{ ...s, at: run.meta.createdAt }, ...rows];
64
+ if (s && !rows.some((r) => sessionKey(r) === sessionKey(s)))
65
+ return [{ ...s, at: run.meta.createdAt }, ...rows];
63
66
  return rows;
64
67
  }
65
68
 
@@ -70,21 +73,23 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
70
73
  const trails = new Map(runs.map((r) => [r.id, trailOf(r)]));
71
74
  for (const t of trails.values())
72
75
  for (const row of t) {
73
- const was = recorded.get(row.sessionId);
76
+ const was = recorded.get(sessionKey(row));
74
77
  if (!was || row.at >= was.at)
75
- recorded.set(row.sessionId, {
78
+ recorded.set(sessionKey(row), {
76
79
  name: row.name ?? was?.name ?? null,
77
80
  host: row.hostSessionId ?? was?.host ?? null,
78
81
  at: row.at,
79
82
  });
80
83
  }
81
84
  const sessionOf = (id: string): RunSession => {
82
- const live = liveSessionFile(id, files);
85
+ const [host, sessionId] = JSON.parse(id) as [KnownHost, string];
86
+ const live = host === "claude-code" ? liveSessionFile(sessionId, files) : null;
83
87
  const rec = recorded.get(id);
84
88
  return {
85
- sessionId: id,
89
+ host,
90
+ sessionId,
86
91
  hostSessionId: live?.hostSessionId ?? rec?.host ?? null,
87
- name: live?.name ?? rec?.name ?? `session ${id.slice(0, 8)}`,
92
+ name: live?.name ?? rec?.name ?? `session ${sessionId.slice(0, 8)}`,
88
93
  live: live !== null,
89
94
  };
90
95
  };
@@ -101,13 +106,13 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
101
106
  for (const run of runs) {
102
107
  const at = runActivity(run);
103
108
  const trail = trails.get(run.id) ?? [];
104
- const starter = run.meta.startedBy?.sessionId ?? null;
109
+ const starter = run.meta.startedBy ? sessionKey(run.meta.startedBy) : null;
105
110
  // the run lives on in its current owner: the last session that took it (a run can come back to its starter)
106
- const owner = trail.at(-1)?.sessionId ?? starter;
111
+ const owner = trail.at(-1) ? sessionKey(trail.at(-1)!) : starter;
107
112
  const moved = owner !== starter && owner !== null;
108
113
  // a run that lives on elsewhere counts, in a session it left, from when that session last took it
109
114
  const tookAt = (id: string | null): number =>
110
- Date.parse(trail.findLast((t) => t.sessionId === id)?.at ?? run.meta.createdAt) || 0;
115
+ Date.parse(trail.findLast((t) => sessionKey(t) === id)?.at ?? run.meta.createdAt) || 0;
111
116
  add(
112
117
  starter,
113
118
  {
@@ -118,7 +123,7 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
118
123
  },
119
124
  moved ? tookAt(starter) : at,
120
125
  );
121
- for (const id of new Set(trail.map((t) => t.sessionId)))
126
+ for (const id of new Set(trail.map(sessionKey)))
122
127
  if (id !== starter)
123
128
  add(
124
129
  id,
@@ -146,7 +151,7 @@ export function sessionFacts(
146
151
  run: Run,
147
152
  files: SessionFile[] = readSessionFiles(),
148
153
  ): { session: RunSession | null; continuedIn: string | null } {
149
- const starter = run.meta.startedBy?.sessionId ?? null;
150
- const own = groupRuns([run], files).find((g) => (g.session?.sessionId ?? null) === starter);
154
+ const starter = run.meta.startedBy ? sessionKey(run.meta.startedBy) : null;
155
+ const own = groupRuns([run], files).find((g) => (g.session ? sessionKey(g.session) : null) === starter);
151
156
  return { session: own?.session ?? null, continuedIn: own?.runs[0]?.continuedIn ?? null };
152
157
  }
@@ -1,3 +1,4 @@
1
+ import { sessionKey, type HostSessionRef } from "../domain/host.ts";
1
2
  import { z } from "zod";
2
3
  import { sessionFileFor } from "../infra/claude-session.ts";
3
4
  import { withFileLock } from "../infra/filelock.ts";
@@ -6,27 +7,34 @@ import type { Deps } from "./ports.ts";
6
7
  import { type Run, runPaths } from "./run-store.ts";
7
8
  import { readNotes } from "./state.ts";
8
9
 
9
- /** Spec §4: a Claude Code session as catherd records it. */
10
- export interface SessionRef {
11
- sessionId: string;
12
- hostSessionId: string | null;
13
- name: string | null;
14
- }
10
+ /** Spec §4: a host session as catherd records it. */
11
+ export type SessionRef = HostSessionRef;
15
12
 
16
13
  /**
17
14
  * The session this process serves (spec §3.3), read live: its registry file's id and name when it has one (the
18
- * id in the environment goes stale after /clear), else the environment's. Null outside Claude Code.
15
+ * id in the environment goes stale after /clear), else the environment's. Null without known, consistent host identity.
19
16
  */
20
17
  export function currentSession(deps: Deps): SessionRef | null {
18
+ if (deps.host.host === "unknown" || deps.host.conflict) return null;
19
+ if (deps.host.host === "codex") return deps.host.session;
21
20
  const env = deps.session;
22
- if (!env) return null;
21
+ if (!env) return deps.host.session;
23
22
  const file = sessionFileFor(env);
24
23
  const sessionId = file?.sessionId ?? env.sessionId;
25
24
  if (!sessionId) return null;
26
- return { sessionId, hostSessionId: file?.hostSessionId ?? env.hostSessionId, name: file?.name ?? null };
25
+ return {
26
+ host: "claude-code",
27
+ sessionId,
28
+ hostSessionId: file?.hostSessionId ?? env.hostSessionId,
29
+ name: file?.name ?? null,
30
+ };
27
31
  }
28
32
 
29
- const OwnerSchema = z.object({ sessionId: z.string(), since: z.string() });
33
+ const OwnerSchema = z.looseObject({
34
+ host: z.enum(["claude-code", "codex"]).default("claude-code"),
35
+ sessionId: z.string(),
36
+ since: z.string(),
37
+ });
30
38
  export type Owner = z.infer<typeof OwnerSchema>;
31
39
 
32
40
  /** The run's owner session (state.json `owner`), or null for a run no session has owned (1.0, a terminal). */
@@ -38,10 +46,12 @@ export function runOwner(run: Run): Owner | null {
38
46
  /** Whether this process serves a session and that session owns the run now. */
39
47
  export function ownsRun(deps: Deps, run: Run): boolean {
40
48
  const me = currentSession(deps);
41
- return me !== null && runOwner(run)?.sessionId === me.sessionId;
49
+ const owner = runOwner(run);
50
+ return me !== null && owner !== null && sessionKey(owner) === sessionKey(me);
42
51
  }
43
52
 
44
53
  const SessionRowSchema = z.looseObject({
54
+ host: z.enum(["claude-code", "codex"]).default("claude-code"),
45
55
  sessionId: z.string(),
46
56
  hostSessionId: z.string().nullable(),
47
57
  name: z.string().nullable(),
@@ -59,8 +69,7 @@ export function readSessionRows(run: Run): SessionRow[] {
59
69
 
60
70
  /**
61
71
  * Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
62
- * append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Outside
63
- * Claude Code it changes nothing. Returns whether the owner changed.
72
+ * append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing. Returns whether the owner changed.
64
73
  */
65
74
  export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
66
75
  const me = currentSession(deps);
@@ -70,6 +79,7 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
70
79
  const row = (since: string): void => {
71
80
  ensureJsonlHeader(p.sessions, "sessions");
72
81
  appendJsonl(p.sessions, {
82
+ host: me.host,
73
83
  sessionId: me.sessionId,
74
84
  hostSessionId: me.hostSessionId,
75
85
  name: me.name,
@@ -78,14 +88,20 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
78
88
  };
79
89
  // the trail row goes in under the same lock: the trail's last row is the owner (session-view reads it so)
80
90
  return withFileLock(p.stateJson, () => {
91
+ const live = currentSession(deps);
92
+ if (!live || sessionKey(live) !== sessionKey(me)) return false;
81
93
  const notes = readNotes(run);
82
94
  const owner = runOwner(run);
83
- if (owner?.sessionId === me.sessionId) {
95
+ if (owner && sessionKey(owner) === sessionKey(me)) {
84
96
  // a crash between the state.json write and the append left the trail without its owner: repaired here
85
- if (readSessionRows(run).at(-1)?.sessionId !== me.sessionId) row(owner.since);
97
+ const last = readSessionRows(run).at(-1);
98
+ if (!last || sessionKey(last) !== sessionKey(me)) row(owner.since);
86
99
  return false;
87
100
  }
88
- writeJsonAtomic(p.stateJson, { ...notes, owner: { sessionId: me.sessionId, since: at } });
101
+ writeJsonAtomic(p.stateJson, {
102
+ ...notes,
103
+ owner: { ...owner, host: me.host, sessionId: me.sessionId, since: at },
104
+ });
89
105
  row(at);
90
106
  return true;
91
107
  });
@@ -1,3 +1,6 @@
1
+ import { ADAPTER_IDS } from "../domain/ids.ts";
2
+ import { usedBackends } from "./doctor-backends.ts";
3
+ import type { HostContext } from "../domain/host.ts";
1
4
  import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
2
5
  import { basename, join } from "node:path";
3
6
  import type { Issue } from "../domain/profile-rules.ts";
@@ -5,7 +8,14 @@ import { configDir } from "../infra/paths.ts";
5
8
  import { type Refreshed, refreshDiscovery } from "./catalog-service.ts";
6
9
  import type { Synced } from "./agent-links.ts";
7
10
  import { activate, resetProfile, withProfilesLock } from "./profile-service.ts";
8
- import { configFile, profilesDir, projectsFile } from "./profile-store.ts";
11
+ import {
12
+ activeName,
13
+ configFile,
14
+ getProfile,
15
+ profilesDir,
16
+ projectsFile,
17
+ readProjects,
18
+ } from "./profile-store.ts";
9
19
  import { ensurePrivateDir } from "../infra/store.ts";
10
20
 
11
21
  /** True when profile `name` has a file (`default` exists as a name even before it has one). */
@@ -65,23 +75,34 @@ export interface InitResult {
65
75
  /**
66
76
  * Spec §8 `catherd init`, the setup half: moves 0.x files aside, writes the default profile (spec §7.2)
67
77
  * unless a 1.0 one exists and `overwrite` is not set, makes it active and links its agents, and lists
68
- * every backend's models. It never throws for a default profile that does not validate here: it writes
69
- * nothing, returns the errors, and activates the profile only when it has a file.
78
+ * backend models, skipping unused headless Claude on a Codex host. Defaults that do not validate return
79
+ * errors without a write; the profile becomes active only when it has a file.
70
80
  */
71
- export async function initSetup(
72
- o: { profile?: string; overwrite?: boolean; now?: Date } = {},
73
- ): Promise<InitResult> {
81
+ export async function initSetup(o: {
82
+ profile?: string;
83
+ repo?: string | null;
84
+ overwrite?: boolean;
85
+ now?: Date;
86
+ host: HostContext;
87
+ }): Promise<InitResult> {
74
88
  const moved = moveLegacy(o.now);
75
- const profile = o.profile ?? "default";
89
+ const profile = o.profile ?? activeName(o.repo);
76
90
  let created = false;
77
91
  let errors: Issue[] = [];
78
92
  if (!hasProfileFile(profile) || o.overwrite === true) {
79
- const saved = resetProfile(profile);
93
+ const saved = resetProfile(profile, o.host.host);
80
94
  created = saved.saved;
81
95
  errors = saved.errors;
82
96
  }
83
97
  const active = hasProfileFile(profile);
84
- const synced = active ? activate(profile) : null;
85
- const refreshed = await refreshDiscovery();
98
+ const repo =
99
+ o.profile === undefined && o.repo && readProjects().bindings[o.repo] !== undefined ? o.repo : null;
100
+ const synced = active ? activate(profile, repo, o.host.host) : null;
101
+ const used = usedBackends(active ? [getProfile(profile, o.host.host)] : []);
102
+ const backends =
103
+ o.host.host === "codex" && !used.has("claude-code")
104
+ ? ADAPTER_IDS.filter((id) => id !== "claude-code")
105
+ : undefined;
106
+ const refreshed = await refreshDiscovery({ backends });
86
107
  return { moved, profile, created, errors, active, synced, refreshed };
87
108
  }
@@ -85,14 +85,22 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
85
85
  const { id, effort } = splitSourceRung(row.rung);
86
86
  const family = map.family(id);
87
87
  if (!family) (unmatched[source] ??= new Set()).add(id);
88
- const e = effort ?? (family ? defaultEffortOf(ctx.sources, family) : "");
88
+ // spec 1.3 §7.1: a family with no effort (Composer) has one rung, #default, whatever effort a source names
89
+ const effortless = family !== null && familyEfforts(family).length === 0;
90
+ const e = effortless ? "default" : (effort ?? (family ? defaultEffortOf(ctx.sources, family) : ""));
89
91
  const key = `${map.key(family?.id ?? id)}#${e}`;
90
92
  const field = `${source}.${row.field}`;
91
93
  const at = table.get(field) ?? new Map<string, Keyed>();
92
94
  table.set(field, at);
93
95
  const had = at.get(key);
94
96
  if (!had || row.value > had.row.value)
95
- at.set(key, { key, family, effort: e, assumed: effort === null && family !== null, row });
97
+ at.set(key, {
98
+ key,
99
+ family,
100
+ effort: e,
101
+ assumed: effort === null && family !== null && !effortless,
102
+ row,
103
+ });
96
104
  }
97
105
  const valuesOf = (f: FieldRef) =>
98
106
  new Map([...(table.get(`${f.source}.${f.field}`)?.values() ?? [])].map((k) => [k.key, k.row.value]));
@@ -163,7 +171,9 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
163
171
  const family = map.family(id);
164
172
  if (family && effort !== null) shipped.add(`${family.id}#${effort}|${s.dim}`);
165
173
  }
166
- scores.push(...adjacent(ctx.models.families, scores, shipped, ctx.now));
174
+ scores.push(
175
+ ...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f)),
176
+ );
167
177
 
168
178
  const { facts, warnings } = factsOf(raw, ctx, map);
169
179
  return {
@@ -203,13 +213,16 @@ function aaFeatures(table: Map<string, Map<string, Keyed>>): Derived["features"]
203
213
  * Spec 1.2 §4.3 `adjacent`: for each family effort a dimension has no value at (neither one in `direct` nor
204
214
  * one `shipped` names, as `<family>#<effort>|<dim>`), the best value in `direct` at the nearest effort that
205
215
  * has one (the weaker on a tie). A sync spreads only its own values, and never onto a value the shipped file
206
- * carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`.
216
+ * carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`. A family that
217
+ * one backend runs without an effort (Cursor's bare slug, spec 1.3 §7.1) also gets `#default`, carried from the
218
+ * value nearest `defaultEffort(family)`.
207
219
  */
208
220
  export function adjacent(
209
221
  families: Family[],
210
222
  direct: Score[],
211
223
  shipped: ReadonlySet<string>,
212
224
  now: number,
225
+ defaultEffort: (f: Family) => string = () => "high",
213
226
  ): Score[] {
214
227
  const out: Score[] = [];
215
228
  for (const f of families)
@@ -222,9 +235,11 @@ export function adjacent(
222
235
  if (!had || outranks(s, had, now)) byEffort.set(effort, s);
223
236
  }
224
237
  if (byEffort.size === 0) continue;
225
- for (const e of familyEfforts(f)) {
238
+ const efforts = familyEfforts(f);
239
+ const bare = efforts.length > 0 && Object.values(f.on).some((o) => o && o.efforts.length === 0);
240
+ for (const e of bare ? [...efforts, "default"] : efforts) {
226
241
  if (byEffort.has(e) || shipped.has(`${f.id}#${e}|${dim}`)) continue;
227
- const near = nearestEffort(e, [...byEffort.keys()]);
242
+ const near = nearestEffort(e === "default" ? defaultEffort(f) : e, [...byEffort.keys()]);
228
243
  const from = near ? byEffort.get(near) : undefined;
229
244
  if (!near || !from) continue;
230
245
  out.push({
@@ -261,7 +276,8 @@ function factsOf(
261
276
  for (const f of ctx.models.families) {
262
277
  const x: FamilyFacts = { on: {}, speed: {} };
263
278
  const vendor = (f as { vendor?: unknown }).vendor;
264
- const own = md && typeof vendor === "string" ? modelsDevFacts(md, vendor, f.id) : null;
279
+ const own =
280
+ md && typeof vendor === "string" ? modelsDevFacts(md, vendor, modelsDevId(md, vendor, f, map)) : null;
265
281
  if (own?.price) x.price = own.price;
266
282
  if (own && own.toolUse !== null && own.imageIn !== null && own.reasoning !== null)
267
283
  x.capabilities = { toolUse: own.toolUse, imageIn: own.imageIn, reasoning: own.reasoning };
@@ -290,6 +306,15 @@ function factsOf(
290
306
  return { facts, warnings };
291
307
  }
292
308
 
309
+ /**
310
+ * The id of `vendor`'s models.dev model that is `f`: its own id, else the first that maps to it (spec 1.3 §7.1:
311
+ * `gemini-3-8-flash` is Google's `gemini-3.8-flash`, `gemini-3-1-pro` its aliased `gemini-3.1-pro-preview`).
312
+ */
313
+ function modelsDevId(md: unknown, vendor: string, f: Family, map: IdMapper): string {
314
+ const ids = Object.keys((md as Record<string, { models?: object }>)[vendor]?.models ?? {});
315
+ return ids.includes(f.id) ? f.id : (ids.find((id) => map.family(id) === f) ?? f.id);
316
+ }
317
+
293
318
  /** The first entry of `list` whose id maps to `f` (OpenRouter's `:batch` variants never do). */
294
319
  const entryFor = <T extends { id: string }>(list: T[], f: Family, map: IdMapper): T | undefined =>
295
320
  list.find((m) => map.family(m.id) === f);
@@ -1,3 +1,6 @@
1
+ import type { HostContext } from "../domain/host.ts";
2
+ import type { QueueCapability } from "../infra/codex-queue.ts";
3
+ import { inspectionHost, inspectDeliveries, type DeliveryInspection } from "./run-debug.ts";
1
4
  import { resolve } from "node:path";
2
5
  import { type BudgetStatus, budgetStatus } from "../domain/budget.ts";
3
6
  import { isCatherdError } from "../domain/errors.ts";
@@ -7,7 +10,7 @@ import { nonBlankLines, readJsonl } from "../infra/store.ts";
7
10
  import { spendOf } from "./budget.ts";
8
11
  import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
9
12
  import { type OpenQuestion, openQuestions } from "./questions.ts";
10
- import { type DispatchState, liveDispatches } from "./dispatches.ts";
13
+ import { type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
11
14
  import { type RunSession, sessionFacts } from "./session-view.ts";
12
15
  import type { Deps } from "./ports.ts";
13
16
  import {
@@ -21,6 +24,7 @@ import {
21
24
  } from "./run-store.ts";
22
25
 
23
26
  export interface RunSummary {
27
+ delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
24
28
  id: string;
25
29
  /** spec 1.1 §8: the owner questions not answered yet, listed first */
26
30
  questions: OpenQuestion[];
@@ -51,17 +55,28 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
51
55
  const now = deps.now();
52
56
  const { records, corrupt } = readRecords(run);
53
57
  const live = liveDispatches(run, now);
58
+ const recorded = new Set(records.map((r) => r.dispatchId));
54
59
  const agents = readAgentRuns(run);
55
60
  const jev = readJsonl<{ source?: string }>(runPaths(run.dir).jev).rows;
56
61
  const sum = (f: (t: Tokens) => number) => records.reduce((n, r) => n + f(r.tokens), 0);
57
62
  let budget: BudgetStatus | null = null;
58
63
  const warnings = corrupt ? [`runs.jsonl: skipped ${corrupt} unreadable row(s)`] : [];
59
64
  try {
60
- budget = budgetStatus(spendOf(run, records, live, now), deps.profiles.forRepo(run.meta.repo).budget);
65
+ budget = budgetStatus(
66
+ spendOf(run, records, live, now),
67
+ deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
68
+ );
61
69
  } catch (e) {
62
70
  warnings.push(`budget: ${isCatherdError(e) ? e.message : String(e)}`);
63
71
  }
64
72
  return {
73
+ delivery: listDispatches(run).flatMap((d) =>
74
+ inspectDeliveries(run, d, recorded.has(d.admit.dispatchId)).map((inspection) => ({
75
+ name: d.admit.name,
76
+ dispatchId: d.admit.dispatchId,
77
+ ...inspection,
78
+ })),
79
+ ),
65
80
  id: run.id,
66
81
  questions: openQuestions(run),
67
82
  title: run.meta.title,
@@ -110,13 +125,33 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
110
125
  export function status(
111
126
  deps: Deps,
112
127
  runId?: string,
113
- ): { version: string; runs: RunSummary[]; warnings: string[] } {
114
- if (runId) return { version: deps.version, runs: [summarizeRun(deps, findRun(runId))], warnings: [] };
128
+ queue: QueueCapability | null = null,
129
+ ): {
130
+ version: string;
131
+ runs: RunSummary[];
132
+ warnings: string[];
133
+ host: HostContext;
134
+ queue: QueueCapability | null;
135
+ } {
136
+ if (runId)
137
+ return {
138
+ host: inspectionHost(deps.host),
139
+ queue,
140
+ version: deps.version,
141
+ runs: [summarizeRun(deps, findRun(runId))],
142
+ warnings: [],
143
+ };
115
144
  const { runs, corrupt } = listRuns();
116
145
  const warnings = corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`);
117
146
  const all = runs.map((r) => summarizeRun(deps, r));
118
147
  const live = all.filter((s) => s.live.length > 0);
119
- return { version: deps.version, runs: live.length ? live : all.slice(0, 1), warnings };
148
+ return {
149
+ host: inspectionHost(deps.host),
150
+ queue,
151
+ version: deps.version,
152
+ runs: live.length ? live : all.slice(0, 1),
153
+ warnings,
154
+ };
120
155
  }
121
156
 
122
157
  export interface RungStats {
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import { type Dim, scoresOf, rungInfo } from "../domain/catalog.ts";
2
3
  import { CatherdError } from "../domain/errors.ts";
3
4
  import { tryParseRung } from "../domain/ids.ts";
@@ -21,7 +22,10 @@ export interface LeftOnStandIn {
21
22
  * inferred stand-in, or be left with no value at all, once the user's treat-likes for `removed` (canonical
22
23
  * rungs, or all of them) are gone. Never removed silently.
23
24
  */
24
- export function leftOnStandIns(removed: string[] | "all"): LeftOnStandIn[] {
25
+ export function leftOnStandIns(
26
+ removed: string[] | "all",
27
+ host: OrchestrationHost = "unknown",
28
+ ): LeftOnStandIn[] {
25
29
  const cur = readOverride();
26
30
  const treatLike = Object.fromEntries(
27
31
  Object.entries(cur.treatLike).filter(([r]) => removed !== "all" && !removed.includes(r)),
@@ -29,12 +33,18 @@ export function leftOnStandIns(removed: string[] | "all"): LeftOnStandIn[] {
29
33
  const gone = removed === "all" ? Object.keys(cur.treatLike) : removed;
30
34
  const c = loadCatalog({ timings: false, override: { ...cur, treatLike } });
31
35
  const out: LeftOnStandIn[] = [];
36
+ // an unknown host resolves no omitted architect/verifier rungs: name what either host would run
37
+ const hosts: OrchestrationHost[] = host === "unknown" ? ["claude-code", "codex"] : [host];
32
38
  for (const profile of listProfiles()) {
33
- const p = getProfile(profile);
34
- const rungs = new Set([
35
- ...ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs),
36
- ...Object.values(p.failover),
37
- ]);
39
+ const rungs = new Set(
40
+ hosts.flatMap((h) => {
41
+ const p = getProfile(profile, h);
42
+ return [
43
+ ...ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs),
44
+ ...Object.values(p.failover),
45
+ ];
46
+ }),
47
+ );
38
48
  for (const rung of rungs) {
39
49
  if (!tryParseRung(rung)) continue;
40
50
  const canonical = rungInfo(c, rung).canonical;
@@ -65,6 +75,7 @@ export function suggestFor(rung: string): { canonical: string; lacking: Dim[]; s
65
75
  */
66
76
  export async function clearTreatLike(
67
77
  rung: string,
78
+ host: OrchestrationHost = "unknown",
68
79
  ): Promise<{ rung: string; like: string; left: LeftOnStandIn[] }> {
69
80
  const canonical = canonicalRung(rung);
70
81
  const like = readOverride().treatLike[canonical];
@@ -72,13 +83,15 @@ export async function clearTreatLike(
72
83
  throw new CatherdError("E_INPUT_INVALID", `${canonical} has no treat-like of yours to clear`, {
73
84
  fix: "catherd catalog list shows each rung's treat-like; a shipped one is not yours to clear",
74
85
  });
75
- const left = leftOnStandIns([canonical]);
86
+ const left = leftOnStandIns([canonical], host);
76
87
  await removeTreatLikes([canonical]);
77
88
  return { rung: canonical, like, left };
78
89
  }
79
90
 
80
91
  /** Spec 1.2 §6.4 `--reset`: removes every treat-like of the user's, naming first the rungs left on a stand-in. */
81
- export async function resetTreatLikes(): Promise<{ removed: [string, string][]; left: LeftOnStandIn[] }> {
82
- const left = leftOnStandIns("all");
92
+ export async function resetTreatLikes(
93
+ host: OrchestrationHost = "unknown",
94
+ ): Promise<{ removed: [string, string][]; left: LeftOnStandIn[] }> {
95
+ const left = leftOnStandIns("all", host);
83
96
  return { removed: await removeTreatLikes("all"), left };
84
97
  }