catherd-cli 1.4.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 (139) hide show
  1. package/README.md +93 -30
  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 +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +110 -45
  9. package/plugin/skills/catherd-setup/SKILL.md +2 -2
  10. package/src/adapters/antigravity/index.ts +18 -3
  11. package/src/adapters/backend.ts +18 -2
  12. package/src/adapters/claude-code/index.ts +126 -14
  13. package/src/adapters/codex/events.ts +10 -0
  14. package/src/adapters/codex/index.ts +17 -5
  15. package/src/adapters/cursor/index.ts +7 -1
  16. package/src/adapters/grok/home.ts +75 -20
  17. package/src/adapters/grok/index.ts +27 -8
  18. package/src/adapters/opencode/index.ts +38 -1
  19. package/src/cli.ts +5 -0
  20. package/src/domain/agents.ts +3 -1
  21. package/src/domain/brief.ts +91 -0
  22. package/src/domain/calibration.ts +7 -0
  23. package/src/domain/catalog.ts +12 -1
  24. package/src/domain/changes.ts +23 -1
  25. package/src/domain/cost.ts +8 -3
  26. package/src/domain/errors.ts +4 -0
  27. package/src/domain/failover.ts +17 -5
  28. package/src/domain/gate-brief.ts +52 -0
  29. package/src/domain/hints.ts +13 -2
  30. package/src/domain/lane.ts +99 -3
  31. package/src/domain/notice.ts +9 -2
  32. package/src/domain/profile-rules.ts +112 -16
  33. package/src/domain/profile.ts +24 -0
  34. package/src/domain/record.ts +16 -2
  35. package/src/domain/reply.ts +29 -0
  36. package/src/domain/role-prompts.ts +56 -16
  37. package/src/domain/role-scope.ts +106 -0
  38. package/src/domain/role-tools.ts +34 -0
  39. package/src/domain/route.ts +73 -0
  40. package/src/domain/select.ts +177 -25
  41. package/src/domain/state.ts +5 -0
  42. package/src/domain/util.ts +22 -0
  43. package/src/domain/workspace.ts +130 -0
  44. package/src/entry/catalog-command.ts +14 -6
  45. package/src/entry/cli-kit.ts +17 -1
  46. package/src/entry/deps.ts +2 -0
  47. package/src/entry/doctor-command.ts +20 -2
  48. package/src/entry/host-arg.ts +28 -0
  49. package/src/entry/init-command.ts +2 -1
  50. package/src/entry/knowledge-command.ts +78 -3
  51. package/src/entry/lock-command.ts +104 -8
  52. package/src/entry/mcp/dispatch-tools.ts +3 -3
  53. package/src/entry/mcp/handshake.ts +6 -2
  54. package/src/entry/mcp/lane-tools.ts +55 -8
  55. package/src/entry/mcp/protocol-tools.ts +10 -4
  56. package/src/entry/mcp/push-tools.ts +16 -0
  57. package/src/entry/mcp/role-bin.ts +20 -0
  58. package/src/entry/mcp/role-server.ts +116 -0
  59. package/src/entry/mcp/run-tools.ts +20 -4
  60. package/src/entry/mcp/server.ts +98 -7
  61. package/src/entry/mcp/setup-tools.ts +4 -3
  62. package/src/entry/mcp/workspace-tools.ts +142 -0
  63. package/src/entry/pause-command.ts +67 -0
  64. package/src/entry/profile-command.ts +23 -4
  65. package/src/entry/role-cli-command.ts +115 -0
  66. package/src/entry/runs-command.ts +78 -8
  67. package/src/entry/supervise-command.ts +30 -3
  68. package/src/entry/tui/effects.ts +4 -0
  69. package/src/entry/tui/state.ts +37 -10
  70. package/src/entry/tui/views/profiles.tsx +1 -1
  71. package/src/entry/tui/views/runs.tsx +66 -20
  72. package/src/entry/tui/views/save-dialog.tsx +6 -2
  73. package/src/entry/tui/views/status.tsx +6 -2
  74. package/src/entry/workspace-command.ts +44 -0
  75. package/src/infra/boot-lock.ts +25 -0
  76. package/src/infra/delivery.ts +3 -0
  77. package/src/infra/dispatch-dir.ts +2 -0
  78. package/src/infra/env.ts +11 -1
  79. package/src/infra/git.ts +6 -0
  80. package/src/infra/heavy-lock.ts +93 -4
  81. package/src/infra/host-probe.ts +16 -0
  82. package/src/infra/lock-activity.ts +88 -0
  83. package/src/infra/login-env.ts +48 -0
  84. package/src/infra/paths.ts +35 -0
  85. package/src/infra/role-mcp.ts +69 -0
  86. package/src/infra/sources/arena.ts +11 -3
  87. package/src/infra/sources/artificial-analysis.ts +8 -1
  88. package/src/infra/sources/cache.ts +2 -0
  89. package/src/infra/supervisor.ts +107 -16
  90. package/src/services/admission.ts +110 -23
  91. package/src/services/budget.ts +26 -6
  92. package/src/services/catalog-service.ts +20 -12
  93. package/src/services/credentials.ts +11 -6
  94. package/src/services/dispatch-service.ts +204 -16
  95. package/src/services/dispatches.ts +30 -2
  96. package/src/services/doctor-access.ts +8 -3
  97. package/src/services/doctor-docker.ts +221 -0
  98. package/src/services/doctor-role-mcp.ts +65 -0
  99. package/src/services/doctor.ts +68 -6
  100. package/src/services/end-push.ts +157 -0
  101. package/src/services/finalize.ts +12 -2
  102. package/src/services/gate-env.ts +218 -0
  103. package/src/services/gate-service.ts +216 -45
  104. package/src/services/global-install.ts +46 -13
  105. package/src/services/lane-edit.ts +154 -0
  106. package/src/services/lane-service.ts +257 -52
  107. package/src/services/milestones.ts +137 -26
  108. package/src/services/notifier.ts +56 -3
  109. package/src/services/orchestrator-wait.ts +82 -0
  110. package/src/services/pause.ts +161 -0
  111. package/src/services/peek.ts +79 -17
  112. package/src/services/ports.ts +31 -2
  113. package/src/services/preflight.ts +153 -19
  114. package/src/services/profile-service.ts +1 -0
  115. package/src/services/profile-store.ts +10 -3
  116. package/src/services/protocol.ts +119 -25
  117. package/src/services/provenance.ts +16 -12
  118. package/src/services/questions.ts +15 -0
  119. package/src/services/reconcile.ts +22 -1
  120. package/src/services/reentry.ts +7 -6
  121. package/src/services/role-access.ts +27 -0
  122. package/src/services/routing-service.ts +144 -36
  123. package/src/services/run-pin.ts +125 -0
  124. package/src/services/run-service.ts +151 -19
  125. package/src/services/run-store.ts +109 -15
  126. package/src/services/runs-page.ts +42 -7
  127. package/src/services/scratch.ts +60 -0
  128. package/src/services/sessions.ts +3 -1
  129. package/src/services/source-derive.ts +28 -5
  130. package/src/services/source-sync.ts +9 -2
  131. package/src/services/standins.ts +40 -6
  132. package/src/services/state.ts +21 -0
  133. package/src/services/summary.ts +49 -9
  134. package/src/services/treat-likes.ts +19 -3
  135. package/src/services/verifier-brief.ts +23 -0
  136. package/src/services/verifier-step.ts +48 -0
  137. package/src/services/workspace-admission.ts +267 -0
  138. package/src/services/workspace-service.ts +254 -0
  139. package/src/services/workspace-store.ts +95 -0
@@ -1,19 +1,21 @@
1
- import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { z } from "zod";
4
4
  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
- import type { OutcomeRow, RouteRow } from "../domain/route.ts";
7
+ import type { OutcomeRouteRow, OutcomeRow, RoleRouteRow, RouteRow } from "../domain/route.ts";
8
8
  import { cell, slug } from "../domain/util.ts";
9
9
  import { withFileLock } from "../infra/filelock.ts";
10
- import { dataDir, repoDir, runsDir } from "../infra/paths.ts";
10
+ import { gitOrigin } from "../infra/git.ts";
11
+ import { dataDir, originDir, repoDir, runsDir } from "../infra/paths.ts";
11
12
  import {
12
13
  appendJsonl,
13
14
  ensureJsonlHeader,
14
15
  ensurePrivateDir,
15
16
  PRIVATE_DIR,
16
17
  appendPrivate,
18
+ nonBlankLines,
17
19
  readJsonl,
18
20
  readVersioned,
19
21
  writeJsonAtomic,
@@ -38,6 +40,9 @@ const RunMetaSchema = z.looseObject({
38
40
  createdAt: z.string(),
39
41
  catherdVersion: z.string(),
40
42
  startedBy: StartedBySchema.nullable().optional(),
43
+ workspace: z.strictObject({ id: z.string(), step: z.string() }).optional(),
44
+ /** the repo's HEAD when run_start ran: where the first milestone's commit range starts (absent before 1.5) */
45
+ startHead: z.string().optional(),
41
46
  });
42
47
  type RunMeta = z.infer<typeof RunMetaSchema>;
43
48
 
@@ -66,6 +71,8 @@ export function runPaths(dir: string) {
66
71
  sessions: join(dir, "sessions.jsonl"),
67
72
  roles: join(dir, "roles"),
68
73
  shots: join(dir, "shots"),
74
+ /** per dispatch name, the role's TMPDIR (spec 1.5 plan 21): `scratch/<name>/` */
75
+ scratch: join(dir, "scratch"),
69
76
  /** the admission lock's target: `admission.lock` guards dispatch admission */
70
77
  admission: join(dir, "admission"),
71
78
  };
@@ -83,6 +90,9 @@ export function createRun(o: {
83
90
  aLines: string[];
84
91
  version: string;
85
92
  now?: Date;
93
+ workspace?: { id: string; step: string };
94
+ /** the repo's full HEAD at the start, when it has a commit */
95
+ startHead?: string | null;
86
96
  startedBy?: {
87
97
  host?: StartedBy["host"];
88
98
  sessionId: string;
@@ -90,6 +100,9 @@ export function createRun(o: {
90
100
  name: string | null;
91
101
  } | null;
92
102
  }): Run {
103
+ const workspace = o.workspace
104
+ ? { id: assertId("workspace", o.workspace.id), step: assertId("workspace step", o.workspace.step) }
105
+ : undefined;
93
106
  const now = o.now ?? new Date();
94
107
  const root = runsDir(o.repo);
95
108
  ensurePrivateDir(root);
@@ -130,6 +143,8 @@ export function createRun(o: {
130
143
  createdAt: now.toISOString(),
131
144
  catherdVersion: o.version,
132
145
  ...(o.startedBy ? { startedBy: StartedBySchema.parse(o.startedBy) } : {}),
146
+ ...(workspace ? { workspace } : {}),
147
+ ...(o.startHead ? { startHead: o.startHead } : {}),
133
148
  };
134
149
  writeJsonAtomic(p.meta, meta);
135
150
  return { id, dir, meta };
@@ -141,14 +156,18 @@ export interface RunListing {
141
156
  corrupt: { id: string; dir: string; reason: string }[];
142
157
  }
143
158
 
144
- /** Every run of every repo, newest first. */
145
- export function listRuns(): RunListing {
159
+ /** Every run, newest first; an explicit repo scope avoids reading unrelated histories. */
160
+ export function listRuns(repos?: readonly string[]): RunListing {
146
161
  const root = join(dataDir(), "repos");
147
162
  const out: RunListing = { runs: [], corrupt: [] };
148
163
  if (!existsSync(root)) return out;
149
- for (const repo of readdirSync(root, { withFileTypes: true })) {
150
- const runs = join(root, repo.name, "runs");
151
- if (!repo.isDirectory() || !existsSync(runs)) continue;
164
+ const directories = repos
165
+ ? [...new Set(repos.map(runsDir))]
166
+ : readdirSync(root, { withFileTypes: true })
167
+ .filter((repo) => repo.isDirectory())
168
+ .map((repo) => join(root, repo.name, "runs"));
169
+ for (const runs of directories) {
170
+ if (!existsSync(runs)) continue;
152
171
  for (const e of readdirSync(runs, { withFileTypes: true })) {
153
172
  if (!e.isDirectory()) continue;
154
173
  const dir = join(runs, e.name);
@@ -220,13 +239,32 @@ export function appendRecord(run: Run, r: RunRecord): Promise<RunRecord> {
220
239
  });
221
240
  }
222
241
 
242
+ /** The run's lane rows (routes and climbs); a role's rows outside a lane are left out (`readRoleRoutes`). */
223
243
  export function readRoutes(run: Run): RouteRow[] {
224
244
  return readJsonl<RouteRow>(runPaths(run.dir).routes).rows.filter(
225
- (r) => typeof r?.lane === "string" && Array.isArray(r.ladder) && typeof r.rung === "string",
245
+ (r) =>
246
+ typeof r?.lane === "string" &&
247
+ (r.source === "route" || r.source === "climb") &&
248
+ Array.isArray(r.ladder) &&
249
+ typeof r.rung === "string",
226
250
  );
227
251
  }
228
252
 
229
- export function appendRoute(run: Run, row: RouteRow): void {
253
+ /** Spec 1.5 plan 24: the lanes' outcome rows of routes.jsonl, oldest first (the last per lane wins). */
254
+ export function readOutcomeRoutes(run: Run): OutcomeRouteRow[] {
255
+ return readJsonl<OutcomeRouteRow>(runPaths(run.dir).routes).rows.filter(
256
+ (r) => typeof r?.lane === "string" && r.source === "outcome",
257
+ );
258
+ }
259
+
260
+ /** Spec 1.5 plan 24: the run's role decisions outside a lane, oldest first. */
261
+ export function readRoleRoutes(run: Run): RoleRouteRow[] {
262
+ return readJsonl<RoleRouteRow>(runPaths(run.dir).routes).rows.filter(
263
+ (r) => r?.lane === null && typeof r.role === "string" && typeof r.rung === "string",
264
+ );
265
+ }
266
+
267
+ export function appendRoute(run: Run, row: RouteRow | RoleRouteRow | OutcomeRouteRow): void {
230
268
  const file = runPaths(run.dir).routes;
231
269
  ensureJsonlHeader(file, "routes");
232
270
  appendJsonl(file, row);
@@ -255,13 +293,22 @@ const AgentRunSchema = z.looseObject({
255
293
  status: z.enum(["ok", "failed", "cancelled"]),
256
294
  /** the lane the subagent worked, so catalog timings count it under that lane's kind; rows before it lack it */
257
295
  lane: z.string().nullable().optional(),
296
+ /** plan 23: a native verifier's verdict line (VERDICT: BLOCKED: environment — <probe>); rows before it lack it */
297
+ verdict: z.string().optional(),
298
+ /** plan 23: the STATUS word of the subagent's reply (a reviewer's partial is no review); rows before it lack it */
299
+ replyStatus: z.enum(["complete", "partial", "blocked", "refused", "flaky"]).optional(),
258
300
  });
259
301
  export type AgentRun = z.infer<typeof AgentRunSchema>;
260
302
 
261
303
  /** Native Claude subagent runs, as the orchestrator reported them through record_agent_run. */
262
- export function readAgentRuns(run: Run): AgentRun[] {
263
- return readJsonl<unknown>(runPaths(run.dir).agents).rows.flatMap((row) => {
304
+ export function readAgentRuns(run: Run, strict = false): AgentRun[] {
305
+ const { rows, corrupt } = readJsonl<unknown>(runPaths(run.dir).agents);
306
+ if (strict && corrupt)
307
+ throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has unreadable native cost records`);
308
+ return rows.flatMap((row) => {
264
309
  const r = AgentRunSchema.safeParse(row);
310
+ if (strict && !r.success)
311
+ throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has invalid native cost records`);
265
312
  return r.success ? [r.data] : [];
266
313
  });
267
314
  }
@@ -276,20 +323,67 @@ export function appendLedger(run: Run, row: string): void {
276
323
  appendPrivate(runPaths(run.dir).ledger, `${row}\n`);
277
324
  }
278
325
 
279
- /** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
326
+ /** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel (before 1.5, and with no origin). */
280
327
  export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
281
328
 
329
+ /**
330
+ * Spec 1.5 "Knowledge keyed by git origin": the knowledge.md every worktree and clone of one repository
331
+ * shares, keyed by its `origin` remote, else by its toplevel. A toplevel-keyed file this worktree left before
332
+ * is migrated on read: its lines not there yet are appended, and it is renamed `knowledge.md.migrated`.
333
+ */
334
+ export async function knowledgeFor(toplevel: string): Promise<string> {
335
+ const origin = await gitOrigin(toplevel);
336
+ const legacy = knowledgeFile(toplevel);
337
+ if (!origin) return legacy;
338
+ const file = join(originDir(origin), "knowledge.md");
339
+ if (!existsSync(legacy)) return file;
340
+ ensurePrivateDir(dirname(file));
341
+ await withFileLock(file, () => {
342
+ if (!existsSync(legacy)) return;
343
+ const have = new Set(existsSync(file) ? nonBlankLines(file) : []);
344
+ const add = nonBlankLines(legacy).filter((l) => !have.has(l));
345
+ if (add.length) appendPrivate(file, `${add.join("\n")}\n`);
346
+ renameSync(legacy, `${legacy}.migrated`);
347
+ });
348
+ return file;
349
+ }
350
+
282
351
  /**
283
352
  * Appends one line to the repo's knowledge.md, `- <date> <source>: <text>` with `text` on one line, and returns
284
353
  * it. The single writer of knowledge.md: `land`'s `learned` and `catherd knowledge add` both come through here.
285
354
  */
286
- export function appendKnowledge(toplevel: string, at: Date, source: string, text: string): string {
355
+ export async function appendKnowledge(
356
+ toplevel: string,
357
+ at: Date,
358
+ source: string,
359
+ text: string,
360
+ ): Promise<string> {
287
361
  const line = `- ${at.toISOString().slice(0, 10)} ${source}: ${cell(text)}`;
288
- appendPrivate(knowledgeFile(toplevel), `${line}\n`);
362
+ appendPrivate(await knowledgeFor(toplevel), `${line}\n`);
289
363
  return line;
290
364
  }
291
365
 
366
+ /** Spec 1.5 "runs supersede": the run that took over a closed one, and when. */
367
+ const SupersededSchema = z.looseObject({ schema: z.literal(1), by: z.string(), at: z.string() });
368
+ export type Superseded = z.infer<typeof SupersededSchema>;
369
+
370
+ export const supersededFile = (run: Run): string => join(run.dir, "superseded.json");
371
+
372
+ /** The pointer `runs supersede` left, or null: an open run, or a pointer that cannot be read. */
373
+ export function supersededBy(run: Run): Superseded | null {
374
+ const file = supersededFile(run);
375
+ if (!existsSync(file)) return null;
376
+ try {
377
+ return readVersioned(file, SupersededSchema, 1);
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
292
383
  const SERVER_OWNED = new Set([
384
+ "superseded.json",
385
+ "pin.json",
386
+ "workspace-contract.md",
293
387
  "meta.json",
294
388
  "state.md",
295
389
  "state.json",
@@ -12,6 +12,7 @@ import { lastActivity } from "./finalize.ts";
12
12
  import type { Deps } from "./ports.ts";
13
13
  import { findRun, listRuns, readRecords, type Run, runPaths } from "./run-store.ts";
14
14
  import { groupRuns, type SessionGroup } from "./session-view.ts";
15
+ import { orchestratorWait, type OrchestratorWait } from "./orchestrator-wait.ts";
15
16
 
16
17
  /**
17
18
  * Spec §4: what the Runs tab shows. The top level is the sessions; a session's screen holds its runs, each with its
@@ -51,11 +52,12 @@ export interface RoleRow {
51
52
  export interface Milestone {
52
53
  name: string;
53
54
  landed: boolean;
54
- /** the ledger's "what" once landed */
55
+ /** the ledger's "what" once landed; before that, plan.md's line for the milestone ("" when it has none) */
55
56
  what: string;
56
57
  }
57
58
 
58
59
  export interface SessionRun {
60
+ waiting?: OrchestratorWait | null;
59
61
  id: string;
60
62
  title: string;
61
63
  repo: string;
@@ -126,18 +128,50 @@ export function sessionRows(deps: Deps): { rows: SessionRow[]; warnings: string[
126
128
  };
127
129
  }
128
130
 
129
- /** The ledger's landed milestones, then each milestone a lane file names that has not landed. */
131
+ /**
132
+ * A milestone's line in plan.md: a heading, list item or plain line that opens with its id and a separator
133
+ * (`## M1 — the jobs list`, `- **M2:** export`). The id stops at the separator, so `M1.L1 — …` is a lane's.
134
+ */
135
+ const PLAN_LINE =
136
+ /^\s*(?:#{1,6}\s+|[-*]\s+|\d+[.)]\s+)?(?:\*\*)?([A-Za-z0-9_-]+)(?::\*\*|(?:\*\*)?\s*(?:—|–|:|-))\s*(.+?)\s*$/;
137
+
138
+ /** The description plan.md gives each of `names`, from the first line that opens with it; none for the rest. */
139
+ function planWhat(run: Run, names: string[]): Map<string, string> {
140
+ const out = new Map<string, string>();
141
+ const file = runPaths(run.dir).plan;
142
+ if (names.length === 0 || !existsSync(file)) return out;
143
+ for (const line of readFileSync(file, "utf8").split("\n")) {
144
+ const m = PLAN_LINE.exec(line);
145
+ const name = m?.[1];
146
+ if (m && name && names.includes(name) && !out.has(name)) out.set(name, (m[2] ?? "").trim());
147
+ }
148
+ return out;
149
+ }
150
+
151
+ const byNumber = (a: string, b: string) => a.localeCompare(b, "en", { numeric: true });
152
+
153
+ /**
154
+ * The ledger's landed milestones, then each milestone a lane file names that has not landed, whatever its id:
155
+ * lane `<milestone>.<lane>` belongs to the milestone before its first dot, as protocol.ts reads it. An open
156
+ * milestone's description is plan.md's line for it, when there is one.
157
+ */
130
158
  function milestonesOf(run: Run): Milestone[] {
131
159
  const landed = nonBlankLines(runPaths(run.dir).ledger)
132
160
  .slice(1)
133
161
  .map((l) => l.split(" | "))
134
162
  .map(([name, what]) => ({ name: (name ?? "").trim(), landed: true, what: (what ?? "").trim() }));
135
163
  const lanes = existsSync(runPaths(run.dir).lanes) ? readdirSync(runPaths(run.dir).lanes) : [];
136
- const open = [...new Set(lanes.map((f) => /^(M\d+)\./.exec(f)?.[1]).filter((m): m is string => !!m))]
137
- .filter((m) => !landed.some((l) => l.name === m))
138
- .map((name) => ({ name, landed: false, what: "" }));
139
- const n = (m: string) => Number(/\d+/.exec(m)?.[0] ?? 0);
140
- return [...landed, ...open.sort((a, b) => n(a.name) - n(b.name))];
164
+ const names = [
165
+ ...new Set(
166
+ lanes
167
+ .filter((f) => f.endsWith(".md") && f.slice(0, -".md".length).includes("."))
168
+ .map((f) => f.split(".")[0] as string),
169
+ ),
170
+ ]
171
+ .filter((m) => m !== "" && !landed.some((l) => l.name === m))
172
+ .sort(byNumber);
173
+ const what = planWhat(run, names);
174
+ return [...landed, ...names.map((name) => ({ name, landed: false, what: what.get(name) ?? "" }))];
141
175
  }
142
176
 
143
177
  /** Each role's latest dispatch, live ones first, then the newest finished first. */
@@ -183,6 +217,7 @@ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
183
217
  }
184
218
  return {
185
219
  id: run.id,
220
+ waiting: orchestratorWait(run, deps.now()),
186
221
  title: run.meta.title,
187
222
  repo: run.meta.repo,
188
223
  createdAt: run.meta.createdAt,
@@ -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
+ }
@@ -69,9 +69,11 @@ export function readSessionRows(run: Run): SessionRow[] {
69
69
 
70
70
  /**
71
71
  * Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
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.
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.
73
73
  */
74
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;
75
77
  const me = currentSession(deps);
76
78
  if (!me) return false;
77
79
  const p = runPaths(run.dir);
@@ -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.