catherd-cli 1.3.0 → 1.5.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 (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -0,0 +1,60 @@
1
+ import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tryLock } from "../infra/filelock.ts";
4
+ import { liveDispatches } from "./dispatches.ts";
5
+ import { findRun, listRuns, type Run, runPaths } from "./run-store.ts";
6
+
7
+ export interface ScratchCleaned {
8
+ /** the runs whose scratch folder was removed, with what it held */
9
+ removed: { run: string; bytes: number }[];
10
+ /** the runs left alone, and why: a role still writes there */
11
+ kept: { run: string; why: string }[];
12
+ }
13
+
14
+ /** The bytes under `dir`, links not followed; 0 for what cannot be read. */
15
+ function sizeOf(dir: string): number {
16
+ let total = 0;
17
+ try {
18
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
19
+ const p = join(dir, e.name);
20
+ total += e.isDirectory() ? sizeOf(p) : (lstatSync(p, { throwIfNoEntry: false })?.size ?? 0);
21
+ }
22
+ } catch {
23
+ // gone or unreadable: counts as nothing
24
+ }
25
+ return total;
26
+ }
27
+
28
+ /**
29
+ * Spec 1.5 plan 21: `catherd runs clean [run]` removes the roles' scratch folders (`<run>/scratch/`, each role's
30
+ * TMPDIR) of a run with no live role, or of every such run. The run's own files, records and replies stay. A run
31
+ * whose admission is in progress is kept: that admission may have just created a role's scratch.
32
+ */
33
+ export function cleanScratch(o: { run?: string; now?: number } = {}): ScratchCleaned {
34
+ const runs: Run[] = o.run ? [findRun(o.run)] : listRuns().runs;
35
+ const out: ScratchCleaned = { removed: [], kept: [] };
36
+ for (const run of runs) {
37
+ const dir = runPaths(run.dir).scratch;
38
+ if (!existsSync(dir)) continue;
39
+ // admission creates a role's scratch and admits its dispatch under this lock, so while cleanup holds it no
40
+ // scratch is half-admitted, and one an admission holds now belongs to a dispatch about to go live
41
+ const release = tryLock(runPaths(run.dir).admission);
42
+ if (!release) {
43
+ out.kept.push({ run: run.id, why: "a dispatch is being admitted" });
44
+ continue;
45
+ }
46
+ try {
47
+ const live = liveDispatches(run, o.now ?? Date.now());
48
+ if (live.length) {
49
+ out.kept.push({ run: run.id, why: `${live.map((d) => d.admit.name).join(", ")} still running` });
50
+ continue;
51
+ }
52
+ const bytes = sizeOf(dir);
53
+ rmSync(dir, { recursive: true, force: true });
54
+ out.removed.push({ run: run.id, bytes });
55
+ } finally {
56
+ release();
57
+ }
58
+ }
59
+ return out;
60
+ }
@@ -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,10 +69,11 @@ 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, and neither does a role's process (`deps.role`, spec 1.5 plan 21). Returns whether the owner changed.
64
73
  */
65
74
  export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
75
+ // spec 1.5 plan 21: a role's process never becomes the run's owner, whatever host identity it has
76
+ if (deps.role) return false;
66
77
  const me = currentSession(deps);
67
78
  if (!me) return false;
68
79
  const p = runPaths(run.dir);
@@ -70,6 +81,7 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
70
81
  const row = (since: string): void => {
71
82
  ensureJsonlHeader(p.sessions, "sessions");
72
83
  appendJsonl(p.sessions, {
84
+ host: me.host,
73
85
  sessionId: me.sessionId,
74
86
  hostSessionId: me.hostSessionId,
75
87
  name: me.name,
@@ -78,14 +90,20 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
78
90
  };
79
91
  // the trail row goes in under the same lock: the trail's last row is the owner (session-view reads it so)
80
92
  return withFileLock(p.stateJson, () => {
93
+ const live = currentSession(deps);
94
+ if (!live || sessionKey(live) !== sessionKey(me)) return false;
81
95
  const notes = readNotes(run);
82
96
  const owner = runOwner(run);
83
- if (owner?.sessionId === me.sessionId) {
97
+ if (owner && sessionKey(owner) === sessionKey(me)) {
84
98
  // 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);
99
+ const last = readSessionRows(run).at(-1);
100
+ if (!last || sessionKey(last) !== sessionKey(me)) row(owner.since);
86
101
  return false;
87
102
  }
88
- writeJsonAtomic(p.stateJson, { ...notes, owner: { sessionId: me.sessionId, since: at } });
103
+ writeJsonAtomic(p.stateJson, {
104
+ ...notes,
105
+ owner: { ...owner, host: me.host, sessionId: me.sessionId, since: at },
106
+ });
89
107
  row(at);
90
108
  return true;
91
109
  });
@@ -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
  }
@@ -8,9 +8,11 @@ import {
8
8
  type ModelsFile,
9
9
  outranks,
10
10
  type Score,
11
+ ScoreSchema,
11
12
  type ScoresFile,
12
13
  } from "../domain/catalog.ts";
13
14
  import {
15
+ EFFORT_ORDER,
14
16
  defaultEffortOf,
15
17
  type Derived,
16
18
  familyEfforts,
@@ -55,6 +57,18 @@ interface Keyed {
55
57
  row: SourceRow;
56
58
  }
57
59
 
60
+ /**
61
+ * (1.2 minor) the fields where less is better: when two of a source's rows land on one rung, the lower one
62
+ * stands for it. Every other field keeps the higher.
63
+ */
64
+ export const LOWER_IS_BETTER: ReadonlySet<string> = new Set([
65
+ "cost_per_task",
66
+ "median_time_to_first_token_seconds",
67
+ ]);
68
+
69
+ /** Whether `a` should stand for a rung over `b` on `field`. */
70
+ const better = (field: string, a: number, b: number): boolean => (LOWER_IS_BETTER.has(field) ? a < b : a > b);
71
+
58
72
  /** The score sources' rows, by source (spec 1.2 §3.3). */
59
73
  function scoreRows(raw: RawAnswers): [SourceId, SourceRow[]][] {
60
74
  const out: [SourceId, SourceRow[]][] = [];
@@ -93,7 +107,7 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
93
107
  const at = table.get(field) ?? new Map<string, Keyed>();
94
108
  table.set(field, at);
95
109
  const had = at.get(key);
96
- if (!had || row.value > had.row.value)
110
+ if (!had || better(row.field, row.value, had.row.value))
97
111
  at.set(key, {
98
112
  key,
99
113
  family,
@@ -171,15 +185,19 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
171
185
  const family = map.family(id);
172
186
  if (family && effort !== null) shipped.add(`${family.id}#${effort}|${s.dim}`);
173
187
  }
188
+ // (1.2 minor) a sync carries a value only up to a stronger effort: a low effort never takes a high one's
174
189
  scores.push(
175
- ...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f)),
190
+ ...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f), {
191
+ upwardOnly: true,
192
+ }),
176
193
  );
177
194
 
178
195
  const { facts, warnings } = factsOf(raw, ctx, map);
179
196
  return {
180
197
  schema: 1,
181
198
  builtAt: new Date(ctx.now).toISOString(),
182
- scores,
199
+ // (1.2 minor) readDerived validates every score: one bad row would make the whole file unreadable
200
+ scores: scores.filter((s) => ScoreSchema.safeParse(s).success),
183
201
  facts,
184
202
  fits,
185
203
  unmatched: Object.fromEntries(Object.entries(unmatched).map(([s, ids]) => [s, [...ids].sort()])),
@@ -212,7 +230,8 @@ function aaFeatures(table: Map<string, Map<string, Keyed>>): Derived["features"]
212
230
  /**
213
231
  * Spec 1.2 §4.3 `adjacent`: for each family effort a dimension has no value at (neither one in `direct` nor
214
232
  * one `shipped` names, as `<family>#<effort>|<dim>`), the best value in `direct` at the nearest effort that
215
- * has one (the weaker on a tie). A sync spreads only its own values, and never onto a value the shipped file
233
+ * has one (the weaker on a tie); with `upwardOnly` (a sync's values, 1.2 minor), only a weaker effort's value
234
+ * is carried, so Luna none never takes Luna max's. A sync spreads only its own values, and never onto a value the shipped file
216
235
  * carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`. A family that
217
236
  * one backend runs without an effort (Cursor's bare slug, spec 1.3 §7.1) also gets `#default`, carried from the
218
237
  * value nearest `defaultEffort(family)`.
@@ -223,7 +242,9 @@ export function adjacent(
223
242
  shipped: ReadonlySet<string>,
224
243
  now: number,
225
244
  defaultEffort: (f: Family) => string = () => "high",
245
+ o: { upwardOnly?: boolean } = {},
226
246
  ): Score[] {
247
+ const rank = (e: string) => (EFFORT_ORDER as readonly string[]).indexOf(e);
227
248
  const out: Score[] = [];
228
249
  for (const f of families)
229
250
  for (const dim of DIMS) {
@@ -239,7 +260,9 @@ export function adjacent(
239
260
  const bare = efforts.length > 0 && Object.values(f.on).some((o) => o && o.efforts.length === 0);
240
261
  for (const e of bare ? [...efforts, "default"] : efforts) {
241
262
  if (byEffort.has(e) || shipped.has(`${f.id}#${e}|${dim}`)) continue;
242
- const near = nearestEffort(e === "default" ? defaultEffort(f) : e, [...byEffort.keys()]);
263
+ const target = e === "default" ? defaultEffort(f) : e;
264
+ const from_ = [...byEffort.keys()].filter((x) => !o.upwardOnly || rank(x) <= rank(target));
265
+ const near = nearestEffort(target, from_);
243
266
  const from = near ? byEffort.get(near) : undefined;
244
267
  if (!near || !from) continue;
245
268
  out.push({
@@ -49,6 +49,8 @@ export interface SyncOptions {
49
49
  force?: boolean;
50
50
  /** the MCP server's boot sync: skips when another sync runs, and gives a failing source an hour's rest */
51
51
  background?: boolean;
52
+ /** false: answer `busy` at once when another sync holds the lock (the catalog_sync tool), never wait */
53
+ wait?: boolean;
52
54
  /** how every source is fetched; tests replace it */
53
55
  transport?: SourceTransport;
54
56
  /** the Artificial Analysis key; default: the env's, else the saved one */
@@ -129,7 +131,7 @@ export function cachedAnswers(): RawAnswers {
129
131
  */
130
132
  export async function syncSources(o: SyncOptions = {}): Promise<SyncReport> {
131
133
  const now = o.now ?? Date.now;
132
- const release = await takeLock(o.background === true);
134
+ const release = await takeLock(o.background === true || o.wait === false);
133
135
  if (!release)
134
136
  return {
135
137
  busy: true,
@@ -177,16 +179,19 @@ export async function syncSources(o: SyncOptions = {}): Promise<SyncReport> {
177
179
  lastAttemptAt: iso(at),
178
180
  error: null,
179
181
  rateLimitRemaining: limit,
182
+ rateLimitAt: limit === null ? null : iso(at),
180
183
  };
181
184
  outcomes.set(id, { source: id, state: "fetched", fetchedAt: iso(at) });
182
185
  } catch (e) {
183
186
  const error = errorMessage(e);
184
187
  const limit = e instanceof SourceError ? rateLimitRemaining(e.headers) : null;
188
+ // (1.2 minor) a failure with no header keeps the last count with the time that count was read
185
189
  state.sources[id] = {
186
190
  fetchedAt: prev?.fetchedAt ?? null,
187
191
  lastAttemptAt: iso(at),
188
192
  error,
189
193
  rateLimitRemaining: limit ?? prev?.rateLimitRemaining ?? null,
194
+ rateLimitAt: limit === null ? (prev?.rateLimitAt ?? null) : iso(at),
190
195
  };
191
196
  outcomes.set(id, { source: id, state: "failed", fetchedAt: prev?.fetchedAt ?? null, error });
192
197
  log(o.background ? "debug" : "warn", "sources", { source: id, error });
@@ -300,6 +305,8 @@ export function sourcesStatus(): {
300
305
  })),
301
306
  aaKey: aaKey() !== null,
302
307
  rateLimitRemaining: aa?.rateLimitRemaining ?? null,
303
- rateLimitAt: aa?.rateLimitRemaining === null || aa === undefined ? null : aa.lastAttemptAt,
308
+ // a state written before 1.5 has no rateLimitAt: its last attempt is the best guess
309
+ rateLimitAt:
310
+ aa?.rateLimitRemaining === null || aa === undefined ? null : (aa.rateLimitAt ?? aa.lastAttemptAt),
304
311
  };
305
312
  }
@@ -22,6 +22,13 @@ export type Features = Record<string, number | string>;
22
22
  /** Spec 1.2 §6.3: a pair sharing fewer features than this is not suggested. */
23
23
  export const MIN_SHARED_FEATURES = 3;
24
24
 
25
+ /**
26
+ * Spec 1.5 plan 24: a rung stands in only when it has values of its own on at least this many dimensions. A
27
+ * sparse row (GPT-6.1 Sol's one honesty figure) ranks near everything, for it has few features to differ on,
28
+ * and would lend that one value to unrelated rungs.
29
+ */
30
+ export const MIN_STANDIN_DIMS = 3;
31
+
25
32
  export interface Suggestion {
26
33
  /** the canonical rung that would stand in */
27
34
  like: string;
@@ -132,7 +139,11 @@ export function missingDims(c: Catalog, canonical: string, dims: readonly Dim[]
132
139
  return dims.filter((d) => !own[d] && !lent[d]);
133
140
  }
134
141
 
135
- /** Every dimension some bar of the catalog (the defaults, with the user's override) has a threshold on. */
142
+ /**
143
+ * Every dimension some bar of the catalog (the defaults, with the user's override) has a threshold on: the
144
+ * dimensions a stand-in fills. No shipped bar uses `steer`, so it is never inferred by default; a user's steer
145
+ * bar makes it one (plan 14 Ruling 7, as the code has always read it; spec 1.5 plan 24 aligns the words).
146
+ */
136
147
  export function barDimsIn(c: Catalog): Dim[] {
137
148
  const used = new Set<string>();
138
149
  for (const kind of Object.values(c.bars))
@@ -148,13 +159,17 @@ class Ranker {
148
159
  for (const r of canonicalRungs(c)) this.all.set(r, featuresOf(c, r));
149
160
  this.z = scales([...this.all.values()]);
150
161
  }
151
- /** The rungs with an own value on any of `dims`, nearest first; each with what it would lend. */
162
+ /**
163
+ * The rungs with an own value on any of `dims` (and on at least MIN_STANDIN_DIMS dimensions in all), nearest
164
+ * first; each with what it would lend.
165
+ */
152
166
  rank(canonical: string, dims: readonly Dim[]): Suggestion[] {
153
167
  const mine = this.all.get(canonical) ?? featuresOf(this.c, canonical);
154
168
  const out: Suggestion[] = [];
155
169
  for (const [other, f] of this.all) {
156
170
  if (other === canonical) continue;
157
171
  const own = ownValues(this.c, other);
172
+ if (Object.keys(own).length < MIN_STANDIN_DIMS) continue;
158
173
  const lends = dims.filter((d) => own[d] !== undefined);
159
174
  if (lends.length === 0) continue;
160
175
  const got = distance(mine, f, this.z);
@@ -173,10 +188,24 @@ export function suggestStandIns(c: Catalog, canonical: string, limit = 3): Sugge
173
188
  return new Ranker(c).rank(canonical, lacking.length ? lacking : DIMS).slice(0, limit);
174
189
  }
175
190
 
191
+ /**
192
+ * Spec 1.5 plan 24: the same effort of the family's predecessor (`Family.predecessor`), when it has a value on
193
+ * `d` of its own; null otherwise.
194
+ */
195
+ function predecessorOf(c: Catalog, canonical: string, d: Dim): { like: string; value: number } | null {
196
+ const pred = family(c, canonical)?.predecessor;
197
+ if (!pred) return null;
198
+ const like = `${pred}${canonical.slice(canonical.lastIndexOf("#"))}`;
199
+ const s = c.scores[like]?.[d];
200
+ return s ? { like, value: s.value } : null;
201
+ }
202
+
176
203
  /**
177
204
  * Spec 1.2 §6.1: for every rung the catalog can name, per dimension the bars use that it has no value for
178
205
  * (of its own or through a treat-like), its nearest stand-in with a value there. A rung no rung is near
179
- * enough to (fewer than MIN_SHARED_FEATURES shared) gets none on that dimension.
206
+ * enough to (fewer than MIN_SHARED_FEATURES shared) gets none on that dimension. Spec 1.5 plan 24: a new
207
+ * release of a family line takes at least its predecessor's value at the same effort, so a guess never puts it
208
+ * below the model it replaces (the identity run's GPT-6.1 Sol at 37.2, under GPT-6 Luna).
180
209
  */
181
210
  export function inferStandIns(c: Catalog): Catalog["inferred"] {
182
211
  const dims = barDimsIn(c);
@@ -186,9 +215,14 @@ export function inferStandIns(c: Catalog): Catalog["inferred"] {
186
215
  const lacking = missingDims(c, canonical, dims);
187
216
  for (const d of lacking) {
188
217
  const best = ranker.rank(canonical, [d])[0];
189
- if (!best) continue;
190
- const entry: InferredStandIn = { like: best.like, distance: best.distance, features: best.features };
191
- (out[canonical] ??= {})[d] = entry;
218
+ const pred = predecessorOf(c, canonical, d);
219
+ const nearest = best ? c.scores[best.like]?.[d]?.value : undefined;
220
+ let entry: InferredStandIn | null = best
221
+ ? { like: best.like, distance: best.distance, features: best.features }
222
+ : null;
223
+ if (pred && (nearest === undefined || pred.value >= nearest))
224
+ entry = { like: pred.like, distance: 0, features: ["predecessor"] };
225
+ if (entry) (out[canonical] ??= {})[d] = entry;
192
226
  }
193
227
  }
194
228
  return out;
@@ -19,6 +19,8 @@ const NotesSchema = z.looseObject({
19
19
  lastLandedAt: z.string().nullable(),
20
20
  /** spec 1.1 §8: the milestones waiting on the owner */
21
21
  parked: z.array(z.string()).optional(),
22
+ /** spec 1.5: what changed since the run's pin, as dispatch last saw it */
23
+ pinChanges: z.array(z.string()).optional(),
22
24
  });
23
25
  export type Notes = z.infer<typeof NotesSchema>;
24
26
  export type NotesPatch = Partial<Omit<Notes, "schema">>;
@@ -68,6 +70,7 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
68
70
  brief: relative(run.dir, dispatchPaths(d.dir).brief),
69
71
  })),
70
72
  lastCheck: next.lastCheck,
73
+ pinChanges: next.pinChanges ?? [],
71
74
  next: next.next,
72
75
  protocol: protocolNext(run, next.parked ?? []),
73
76
  });
@@ -76,6 +79,24 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
76
79
  });
77
80
  }
78
81
 
82
+ /**
83
+ * Writes state.json's notes only, with no git, under the state lock; for a writer that must not wait on git
84
+ * while it holds another lock (`land` in a workspace, #43 finding 5). It waits out a refresh's git (15 s).
85
+ */
86
+ export function patchNotes(run: Run, change: (n: Notes) => NotesPatch): Promise<Notes> {
87
+ const stateJson = runPaths(run.dir).stateJson;
88
+ return withFileLock(
89
+ stateJson,
90
+ () => {
91
+ const notes = readNotes(run);
92
+ const next = parkedNext({ ...notes, ...change(notes) });
93
+ writeJsonAtomic(stateJson, next);
94
+ return next;
95
+ },
96
+ { timeoutMs: 30_000 },
97
+ );
98
+ }
99
+
79
100
  /**
80
101
  * Ruling (b): a failed state.md refresh (git broken) never fails the call. The notes still go to state.json
81
102
  * under the state lock (state.md stays as it was), and the message comes back as a hint.