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
@@ -1,17 +1,23 @@
1
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
1
+ import type { KnownHost } from "../domain/host.ts";
2
+ import { appendFileSync, existsSync, readFileSync, realpathSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import type { BackendAdapter } from "../adapters/backend.ts";
4
5
  import { budgetStatus, formatBudget } from "../domain/budget.ts";
5
6
  import { CatherdError, errorMessage } from "../domain/errors.ts";
6
7
  import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
7
8
  import { assertLaneHeader, overlaps } from "../domain/lane.ts";
9
+ import { roleTimeouts } from "../domain/profile.ts";
8
10
  import type { RunRecord } from "../domain/record.ts";
9
- import { withReplyContract } from "../domain/role-prompts.ts";
11
+ import { composeBrief } from "../domain/brief.ts";
12
+ import { briefOwns, DOCS_OWNS } from "../domain/changes.ts";
13
+ import { grantsScratch, ROLE_SERVER_BACKENDS } from "../domain/role-tools.ts";
10
14
  import type { Role } from "../domain/roles.ts";
15
+ import { formatRoleScope, ROLE_ENV } from "../domain/role-scope.ts";
11
16
  import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
12
17
  import { withFileLock } from "../infra/filelock.ts";
13
18
  import { statusSnapshot } from "../infra/git.ts";
14
19
  import { launchSupervisor } from "../infra/launch.ts";
20
+ import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
15
21
  import { log } from "../infra/log.ts";
16
22
  import { processStartTime } from "../infra/proc.ts";
17
23
  import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
@@ -26,12 +32,20 @@ import {
26
32
  listDispatches,
27
33
  pendingDispatches,
28
34
  roleDir,
35
+ scratchDir,
29
36
  setLatest,
30
37
  } from "./dispatches.ts";
31
38
  import { finalizeDispatch } from "./finalize.ts";
39
+ import { gateEnvParts, readGateEnv } from "./gate-env.ts";
40
+ import { assertNotPaused } from "./pause.ts";
41
+ import { currentOwns } from "./lane-edit.ts";
42
+ import { unfinishedAfter } from "./protocol.ts";
43
+ import { runProfile } from "./run-pin.ts";
32
44
  import type { Deps } from "./ports.ts";
33
- import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
45
+ import { readRecords, recordsOnThread, type Run, runPaths, supersededBy } from "./run-store.ts";
34
46
  import { currentSession } from "./sessions.ts";
47
+ import { verifierBrief } from "./verifier-brief.ts";
48
+ import { withRunAdmission } from "./workspace-admission.ts";
35
49
 
36
50
  export interface AdmitInput {
37
51
  role: Role;
@@ -45,6 +59,7 @@ export interface AdmitInput {
45
59
  failoverOf?: string;
46
60
  /** the dispatching session (spec §3.3); absent means the session this process serves */
47
61
  sessionId?: string | null;
62
+ host?: KnownHost;
48
63
  }
49
64
 
50
65
  /** Spec §3.3: SIGTERM, then SIGKILL this long after. */
@@ -52,7 +67,15 @@ export const KILL_GRACE_MS = 10_000;
52
67
 
53
68
  export const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
54
69
 
55
- function laneOwns(run: Run, lane: string): string[] {
70
+ /** The role's scratch folder, created (0700) and by its real path, on a backend that grants it; else null. */
71
+ function roleScratch(run: Run, name: string, backend: string, isolated: boolean): string | null {
72
+ if (!grantsScratch(backend, isolated)) return null;
73
+ const dir = scratchDir(run, name);
74
+ ensurePrivateDir(dir);
75
+ return realpathSync(dir);
76
+ }
77
+
78
+ export function laneOwns(run: Run, lane: string): string[] {
56
79
  const file = laneFile(run, lane);
57
80
  if (!existsSync(file))
58
81
  throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
@@ -122,6 +145,15 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
122
145
  }
123
146
  }
124
147
 
148
+ /** Refuses a dispatch into a run `runs supersede` closed, naming the run that took over. */
149
+ function assertNotSuperseded(run: Run): void {
150
+ const closed = supersededBy(run);
151
+ if (closed)
152
+ throw new CatherdError("E_RUN_NOT_LIVE", `run ${run.id} is superseded by ${closed.by}`, {
153
+ fix: `dispatch in run ${closed.by}`,
154
+ });
155
+ }
156
+
125
157
  /**
126
158
  * Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
127
159
  * happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
@@ -134,8 +166,12 @@ export async function admit(
134
166
  ): Promise<{ d: Dispatch; specPath: string }> {
135
167
  assertId("role name", i.name);
136
168
  if (i.lane !== null) assertId("lane", i.lane);
169
+ // a machine or workspace pause refuses every dispatch it covers, with its reason (spec 1.5 "Group pause")
170
+ assertNotPaused(run);
171
+ assertNotSuperseded(run);
137
172
  const rung = parseRung(i.rung);
138
- const profile = deps.profiles.forRepo(run.meta.repo);
173
+ // spec 1.5: the run's pinned profile, access and isolation, whatever the repo runs on now
174
+ const profile = runProfile(deps, run);
139
175
  const rc = profile.roles[i.role];
140
176
  if (!rc?.enabled)
141
177
  throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
@@ -181,10 +217,23 @@ export async function admit(
181
217
  { fix: "dispatch a fresh thread (omit `thread`)" },
182
218
  );
183
219
  const owns = i.lane === null ? [] : laneOwns(run, i.lane);
220
+ // spec 1.5 plan 21: the writer's implicit docs lane, for attribution only
221
+ const ownsImplicit = i.role === "writer" && i.lane === null ? (briefOwns(i.brief) ?? DOCS_OWNS) : null;
222
+ // spec 1.5 "Lane editing": an After: line orders lanes that compile against each other
223
+ const before = i.lane === null ? [] : unfinishedAfter(run, i.lane);
224
+ if (before.length)
225
+ throw new CatherdError(
226
+ "E_ADMIT_ORDER",
227
+ `${i.lane} runs after ${before.join(", ")} (its After: line), which ${before.length > 1 ? "have" : "has"} not finished`,
228
+ { fix: `dispatch ${i.lane} once ${before.join(", ")} ${before.length > 1 ? "end" : "ends"} ok` },
229
+ );
184
230
  const id = newDispatchId();
185
231
  const dir = join(roleDir(run, i.name), id);
186
232
  const p = dispatchPaths(dir);
187
233
  const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
234
+ // spec 1.5 plan 21 (#42 findings 1, 2): the role server goes in whether the harness is isolated or not, so no
235
+ // role is refused for isolation, and a thread an isolated run started resumes as it started
236
+ const roleServer = ROLE_SERVER_BACKENDS.includes(rung.backend);
188
237
  await prepared(adapter, {
189
238
  rung,
190
239
  access: rc.access,
@@ -192,21 +241,27 @@ export async function admit(
192
241
  repo: run.meta.repo,
193
242
  network,
194
243
  });
195
- const plan = adapter.plan({
196
- rung,
197
- access: rc.access,
198
- network,
199
- thread: i.thread,
200
- isolated,
201
- repo: run.meta.repo,
202
- briefPath: p.brief,
203
- replyPath: p.reply,
204
- dispatchDir: dir,
205
- });
206
244
 
207
245
  await finalizeFinished(run, deps.now(), onRecorded);
208
- const sessionId = i.sessionId !== undefined ? i.sessionId : (currentSession(deps)?.sessionId ?? null);
209
- return withFileLock(runPaths(run.dir).admission, async () => {
246
+ return withRunAdmission(run, deps.now, async () => {
247
+ // again under the lock `runs supersede` writes under: a supersede that landed since the check above wins
248
+ assertNotSuperseded(run);
249
+ // the scratch is created under the admission lock `catherd runs clean` takes too, so cleanup never removes
250
+ // the scratch of a dispatch it does not see live yet (PR #46)
251
+ const scratch = roleScratch(run, i.name, rung.backend, isolated);
252
+ const plan = adapter.plan({
253
+ rung,
254
+ access: rc.access,
255
+ network,
256
+ thread: i.thread,
257
+ isolated,
258
+ repo: run.meta.repo,
259
+ briefPath: p.brief,
260
+ replyPath: p.reply,
261
+ dispatchDir: dir,
262
+ ...(roleServer ? { roleMcp: { run: run.id, role: i.role } } : {}),
263
+ ...(scratch ? { scratch } : {}),
264
+ });
210
265
  // A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
211
266
  // tree after the exit, so a later dispatch's writes must not land in between. A finished one here
212
267
  // could not be recorded just now. The records and the pending dispatches are one snapshot, taken
@@ -229,7 +284,9 @@ export async function admit(
229
284
  },
230
285
  );
231
286
  for (const d of pending) {
232
- const shared = overlaps(owns, d.admit.owns);
287
+ // a running lane holds what owns_add granted it since its admission, too
288
+ const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
289
+ const shared = overlaps(owns, held);
233
290
  if (shared.length)
234
291
  throw new CatherdError(
235
292
  "E_ADMIT_OVERLAP",
@@ -245,6 +302,9 @@ export async function admit(
245
302
  throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
246
303
  fix: "ask the user to raise profile.budget, or finish the run with what landed",
247
304
  });
305
+ const before = await statusSnapshot(run.meta.repo);
306
+ const session = currentSession(deps);
307
+ const sessionId = i.sessionId !== undefined ? i.sessionId : (session?.sessionId ?? null);
248
308
  const admitted: Admit = {
249
309
  schema: 1,
250
310
  runId: run.id,
@@ -253,6 +313,7 @@ export async function admit(
253
313
  role: i.role,
254
314
  lane: i.lane,
255
315
  owns,
316
+ ...(ownsImplicit ? { ownsImplicit } : {}),
256
317
  rung: formatRung(rung),
257
318
  backend: rung.backend,
258
319
  thread: i.thread,
@@ -265,12 +326,27 @@ export async function admit(
265
326
  cliVersion: probe.version,
266
327
  admittedAt: new Date(deps.now()).toISOString(),
267
328
  repo: run.meta.repo,
268
- before: await statusSnapshot(run.meta.repo),
269
- ...(sessionId ? { sessionId } : {}),
329
+ before,
330
+ ...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
270
331
  };
271
332
  ensurePrivateDir(dir);
272
- // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included
273
- writeTextAtomic(p.brief, withReplyContract(i.role, i.brief));
333
+ // plan 23: the verifier runs with the repo's gate environment (DOCKER_HOST, a proxy, …)
334
+ const gate =
335
+ i.role === "verifier" ? gateEnvParts(await readGateEnv(run.meta.repo)) : { values: {}, refs: {} };
336
+ // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included; spec 1.5 plan 21:
337
+ // before it, the lane file as it stands now, and who the role is, its scratch and its catherd tools; plan 23:
338
+ // a verifier's brief also carries the gate's rules and the run's recorded items
339
+ writeTextAtomic(
340
+ p.brief,
341
+ composeBrief(await verifierBrief(run, i.role, i.name, i.brief), {
342
+ run: run.id,
343
+ name: i.name,
344
+ role: i.role,
345
+ lane: i.lane === null ? null : { id: i.lane, text: readFileSync(laneFile(run, i.lane), "utf8") },
346
+ scratch,
347
+ roleServer,
348
+ }),
349
+ );
274
350
  // Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
275
351
  // time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
276
352
  writeJsonAtomic(
@@ -281,11 +357,26 @@ export async function admit(
281
357
  dispatchDir: dir,
282
358
  cmd: plan.cmd,
283
359
  args: plan.args,
284
- env: { ...plan.env, PWD: plan.cwd },
360
+ // spec 1.5 plan 21: the supervisor gives the role its identity and, where granted, its scratch TMPDIR;
361
+ // plan 23: a `catherd lock` in the role reports to this dispatch, so a long gate keeps its wall alive
362
+ // plan 23: each dispatch its own testcontainers session, so parallel lanes never share a reaper;
363
+ // the gate env never overrides the role's identity, scratch, dispatch id, PWD or isolated home
364
+ env: {
365
+ ...gate.values,
366
+ ...plan.env,
367
+ [ROLE_ENV]: formatRoleScope({ run: run.id, name: i.name }),
368
+ ...(scratch ? { TMPDIR: scratch } : {}),
369
+ [DISPATCH_ID_ENV]: id,
370
+ TESTCONTAINERS_SESSION_ID: id,
371
+ PWD: plan.cwd,
372
+ },
373
+ // a secret of the gate env by reference only: the supervisor reads it from its own env at spawn
374
+ ...(Object.keys(gate.refs).length ? { envFrom: gate.refs } : {}),
285
375
  cwd: plan.cwd,
286
376
  stdinPath: plan.stdinPath,
287
- idleMs: profile.timeouts.idleMin * 60_000,
288
- wallMs: profile.timeouts.wallMin * 60_000,
377
+ // plan 23: a role's own timeouts win over the profile's (a verifier's long gate)
378
+ idleMs: roleTimeouts(profile, rc).idleMin * 60_000,
379
+ wallMs: roleTimeouts(profile, rc).wallMin * 60_000,
289
380
  killGraceMs: KILL_GRACE_MS,
290
381
  graceAfterFinalMs: adapter.graceAfterFinalMs,
291
382
  pollMs: deps.pollMs,
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import {
2
3
  existsSync,
3
4
  lstatSync,
@@ -44,10 +45,10 @@ export interface Planned {
44
45
  links: Map<string, string>;
45
46
  }
46
47
 
47
- export function plan(extra: string[] = []): Planned {
48
+ export function plan(host: OrchestrationHost, extra: string[] = [], selected?: string[]): Planned {
48
49
  const files = new Map<string, AgentFile[]>();
49
- for (const name of new Set([...linkedProfiles(), ...extra.filter(profileExists)]))
50
- files.set(name, agentFiles(getProfile(name), VERSION));
50
+ for (const name of new Set(selected ?? [...linkedProfiles(), ...extra.filter(profileExists)]))
51
+ files.set(name, agentFiles(getProfile(name, host), VERSION));
51
52
  const links = new Map<string, string>();
52
53
  for (const name of linkedProfiles())
53
54
  for (const f of files.get(name) ?? [])
@@ -71,6 +72,27 @@ export interface Synced {
71
72
  newSessionNeededFor: string[];
72
73
  }
73
74
 
75
+ export function pruneProfileAgents(name: string): Synced {
76
+ const dir = join(agentsRoot(), name);
77
+ const pruned: string[] = [];
78
+ const target = claudeAgentsDir();
79
+ if (existsSync(target))
80
+ for (const entry of readdirSync(target)) {
81
+ const link = join(target, entry);
82
+ if (isOurLink(link) && readlinkSync(link).startsWith(`${dir}${sep}`)) {
83
+ rmSync(link);
84
+ pruned.push(entry);
85
+ }
86
+ }
87
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
88
+ pruned.sort();
89
+ return {
90
+ linked: [],
91
+ pruned,
92
+ newSessionNeededFor: pruned.map((entry) => basename(entry, ".md")),
93
+ };
94
+ }
95
+
74
96
  /**
75
97
  * Writes the planned agent files and links, prunes catherd's stale ones, and says what changed. The
76
98
  * ownership check runs before the first write, so no caller can replace a file catherd does not own.
@@ -122,8 +144,11 @@ export function apply(p: Planned, removed: string[] = []): Synced {
122
144
  }
123
145
 
124
146
  /** What `doctor` compares: each link that should exist, and whether it and its file are current. */
125
- export function agentLinkState(): { missing: string[]; stale: string[]; ok: string[] } {
126
- const p = plan();
147
+ export function agentLinkState(
148
+ host: OrchestrationHost,
149
+ selected?: string[],
150
+ ): { missing: string[]; stale: string[]; ok: string[] } {
151
+ const p = plan(host, [], selected);
127
152
  const out = { missing: [] as string[], stale: [] as string[], ok: [] as string[] };
128
153
  for (const [name, files] of p.files)
129
154
  for (const f of files) {
@@ -1,3 +1,5 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
2
+ import { nativeClaudeIssue } from "../domain/profile-rules.ts";
1
3
  import type { BackendAdapter, Probe } from "../adapters/backend.ts";
2
4
  import { adapterFor } from "../adapters/registry.ts";
3
5
  import "../adapters/all.ts";
@@ -80,3 +82,8 @@ export function standInFor(failover: Record<string, string>, rung: string, repo?
80
82
  const byAdapter = adapterFor(r.backend)?.failoverFor?.(r, repo) ?? null;
81
83
  return byAdapter && formatRung(byAdapter);
82
84
  }
85
+
86
+ export function assertNativeHost(rung: string, host: OrchestrationHost): void {
87
+ const issue = nativeClaudeIssue(rung, host, "rung");
88
+ if (issue) throw new CatherdError("E_CONFIG_INVALID", `${issue.message}; ${issue.fix}`, { fix: issue.fix });
89
+ }
@@ -2,13 +2,14 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { adapterFor } from "../adapters/registry.ts";
3
3
  import "../adapters/all.ts";
4
4
  import { type Budget, type BudgetStatus, budgetStatus, type Spend } from "../domain/budget.ts";
5
+ import { CatherdError } from "../domain/errors.ts";
5
6
  import type { RunRecord } from "../domain/record.ts";
6
7
  import { dispatchPaths } from "../infra/dispatch-dir.ts";
7
8
  import { type Dispatch, liveDispatches } from "./dispatches.ts";
8
9
  import { readAgentRuns, readRecords, type Run } from "./run-store.ts";
9
10
 
10
11
  /** Tokens a running dispatch has used so far, summed from the events its CLI has written. */
11
- export function liveTokens(d: Dispatch): number {
12
+ export function liveTokens(d: Dispatch, strict = false): number {
12
13
  const a = adapterFor(d.admit.backend);
13
14
  const file = dispatchPaths(d.dir).events;
14
15
  if (!a || !existsSync(file)) return 0;
@@ -17,8 +18,13 @@ export function liveTokens(d: Dispatch): number {
17
18
  if (!line.trim()) continue;
18
19
  try {
19
20
  const t = a.parse(line).tokens;
20
- if (t) n += t.input + t.output;
21
- } catch {
21
+ if (t) {
22
+ if (strict && Object.values(t).some((value) => !Number.isFinite(value) || value < 0))
23
+ throw new CatherdError("E_RUN_CORRUPT", `dispatch ${d.admit.dispatchId} has invalid token usage`);
24
+ n += t.input + t.output;
25
+ }
26
+ } catch (error) {
27
+ if (strict && error instanceof CatherdError) throw error;
22
28
  // a line the adapter cannot read adds nothing
23
29
  }
24
30
  }
@@ -29,15 +35,29 @@ export function liveTokens(d: Dispatch): number {
29
35
  * Spec §4.6: minutes are wall clock since run_start; tokens and dollars sum every record (every
30
36
  * backend), every native subagent run the orchestrator reported, and what live roles used so far.
31
37
  */
32
- export function spendOf(run: Run, records: RunRecord[], live: Dispatch[], now: number): Spend {
33
- const agents = readAgentRuns(run);
38
+ export function spendOf(
39
+ run: Run,
40
+ records: RunRecord[],
41
+ live: Dispatch[],
42
+ now: number,
43
+ strict = false,
44
+ ): Spend {
45
+ const agents = readAgentRuns(run, strict);
46
+ if (strict) {
47
+ const invalid = (value: number | null) => value !== null && (!Number.isFinite(value) || value < 0);
48
+ if (
49
+ records.some((record) => Object.values(record.tokens).some(invalid) || invalid(record.costUsd)) ||
50
+ agents.some((agent) => invalid(agent.totalTokens) || invalid(agent.costUsd))
51
+ )
52
+ throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has invalid cost or token usage`);
53
+ }
34
54
  const sum = <T>(xs: T[], f: (x: T) => number) => xs.reduce((n, x) => n + f(x), 0);
35
55
  return {
36
56
  minutes: Math.max(0, (now - Date.parse(run.meta.createdAt)) / 60_000),
37
57
  tokens:
38
58
  sum(records, (r) => r.tokens.input + r.tokens.output) +
39
59
  sum(agents, (a) => a.totalTokens) +
40
- sum(live, liveTokens),
60
+ sum(live, (dispatch) => liveTokens(dispatch, strict)),
41
61
  usd: sum(records, (r) => r.costUsd ?? 0) + sum(agents, (a) => a.costUsd ?? 0),
42
62
  };
43
63
  }
@@ -99,18 +99,23 @@ export function measuredSecs(base: Catalog): Catalog["secs"] {
99
99
  if (kind) add(`${canonical}|${kind}`, secs);
100
100
  };
101
101
  for (const run of listRuns().runs) {
102
- const routes = readRoutes(run);
103
- for (const r of readRecords(run).records)
104
- if (r.status === "ok") count(routes, r.rung, r.secs, r.lane, r.startedAt);
105
- for (const a of readAgentRuns(run)) {
106
- if (a.status !== "ok" || a.secs === null) continue;
107
- // a row is written when the subagent ends; it started `secs` earlier
108
- const lane = typeof a.lane === "string" ? a.lane : null;
109
- const end = Date.parse(a.at);
110
- // a duration past the Date range (record_agent_run takes any) keeps the end, not a RangeError
111
- const start = new Date(end - a.secs * 1000);
112
- const startedAt = Number.isNaN(start.getTime()) ? a.at : start.toISOString();
113
- count(routes, a.rung, a.secs, lane, startedAt);
102
+ // a run whose files cannot be read gives no timings; it never fails a route (1.2 minor)
103
+ try {
104
+ const routes = readRoutes(run);
105
+ for (const r of readRecords(run).records)
106
+ if (r.status === "ok") count(routes, r.rung, r.secs, r.lane, r.startedAt);
107
+ for (const a of readAgentRuns(run)) {
108
+ if (a.status !== "ok" || a.secs === null) continue;
109
+ // a row is written when the subagent ends; it started `secs` earlier
110
+ const lane = typeof a.lane === "string" ? a.lane : null;
111
+ const end = Date.parse(a.at);
112
+ // a duration past the Date range (record_agent_run takes any) keeps the end, not a RangeError
113
+ const start = new Date(end - a.secs * 1000);
114
+ const startedAt = Number.isNaN(start.getTime()) ? a.at : start.toISOString();
115
+ count(routes, a.rung, a.secs, lane, startedAt);
116
+ }
117
+ } catch (e) {
118
+ log("debug", "catalog", { run: run.id, timings: errorMessage(e) });
114
119
  }
115
120
  }
116
121
  const secs: Catalog["secs"] = {};
@@ -340,6 +345,8 @@ function rungRows(
340
345
  date: string;
341
346
  /** spec 1.2 §5.3: the rung a borrowed or inferred value belongs to */
342
347
  from?: string;
348
+ /** what lent it: a treat-like's mapping (`like X`) or an inferred stand-in's guess (1.2 minor) */
349
+ lent?: "treat-like" | "stand-in";
343
350
  }
344
351
  > = {};
345
352
  for (const v of valuesUsed(c, info.canonical))
@@ -351,6 +358,7 @@ function rungRows(
351
358
  source: v.source,
352
359
  date: v.date,
353
360
  ...(v.from ? { from: v.from } : {}),
361
+ ...(v.lent ? { lent: v.lent } : {}),
354
362
  };
355
363
  const like = c.treatLike[info.canonical] ?? null;
356
364
  return {
@@ -1,7 +1,8 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { CatherdError, isCatherdError } from "../domain/errors.ts";
5
+ import { withFileLockSync } from "../infra/filelock.ts";
5
6
  import { log } from "../infra/log.ts";
6
7
  import { configDir } from "../infra/paths.ts";
7
8
  import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
@@ -45,13 +46,17 @@ export function savedCredential(field: CredentialField): {
45
46
 
46
47
  /**
47
48
  * Keeps every other credential, and the file at mode 600 (spec §10.4). A missing file starts empty; an
48
- * unreadable or newer-schema one is refused (it throws) rather than overwritten.
49
+ * unreadable or newer-schema one is refused (it throws) rather than overwritten. The read and the write hold
50
+ * the file's lock (1.2 minor), so two concurrent `init`s never lose a key.
49
51
  */
50
52
  export function saveCredential(field: CredentialField, key: string): void {
51
- const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
52
- ? readCredentials()
53
- : { schema: 1 };
54
- writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, [field]: key.trim() }, { mode: 0o600 });
53
+ mkdirSync(configDir(), { recursive: true });
54
+ withFileLockSync(credentialsPath(), () => {
55
+ const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
56
+ ? readCredentials()
57
+ : { schema: 1 };
58
+ writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, [field]: key.trim() }, { mode: 0o600 });
59
+ });
55
60
  }
56
61
 
57
62
  /**