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,4 +1,6 @@
1
+ import { assertNativeHost } from "./backends.ts";
1
2
  import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
3
5
  import { assertId, parseRung } from "../domain/ids.ts";
4
6
  import type { RunRecord } from "../domain/record.ts";
@@ -15,8 +17,9 @@ import {
15
17
  import { lockHeld, withFileLock } from "../infra/filelock.ts";
16
18
  import { log } from "../infra/log.ts";
17
19
  import { isAlive, isSurelyAlive, killGroup } from "../infra/proc.ts";
20
+ import { groupAlive, stopGroup } from "../infra/supervisor.ts";
18
21
  import { writeJsonAtomic } from "../infra/store.ts";
19
- import { admit, KILL_GRACE_MS, laneFile, launch } from "./admission.ts";
22
+ import { admit, KILL_GRACE_MS, laneFile, laneOwns, launch } from "./admission.ts";
20
23
  import { standInFor } from "./backends.ts";
21
24
  import {
22
25
  type Dispatch,
@@ -33,8 +36,11 @@ import {
33
36
  import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize.ts";
34
37
  import type { Deps } from "./ports.ts";
35
38
  import { route } from "./lane-service.ts";
36
- import { findRun, readRecords, readRoutes, type Run } from "./run-store.ts";
37
- import { claimRun, ownsRun } from "./sessions.ts";
39
+ import { currentRoute } from "../domain/route.ts";
40
+ import { appendRoute, findRun, readRecords, readRoleRoutes, readRoutes, type Run } from "./run-store.ts";
41
+ import { sessionKey } from "../domain/host.ts";
42
+ import { claimRun, currentSession, ownsRun, runOwner, type SessionRef } from "./sessions.ts";
43
+ import { pinChanges, runProfile } from "./run-pin.ts";
38
44
  import { type NotesPatch, refreshState } from "./state.ts";
39
45
 
40
46
  export interface DispatchInput {
@@ -42,7 +48,8 @@ export interface DispatchInput {
42
48
  role: Role;
43
49
  name: string;
44
50
  brief: string;
45
- rung: string;
51
+ /** spec 1.5 plan 24: optional with `lane` (the lane's current rung); required without one */
52
+ rung?: string;
46
53
  thread?: string;
47
54
  lane?: string;
48
55
  next?: string;
@@ -101,17 +108,21 @@ export interface Stalled {
101
108
  export const stallHooks = new Set<(s: Stalled) => void>();
102
109
 
103
110
  /** A watcher's poll: the first time the dispatch's stall.json is there, every stall hook hears of it. */
111
+ /** A stall's quiet time; 0 when stall.json is being written or damaged, so one bad file never blocks a notice. */
112
+ export function readStallQuietMs(dir: string): number {
113
+ try {
114
+ return Number(JSON.parse(readFileSync(dispatchPaths(dir).stall, "utf8")).quietMs) || 0;
115
+ } catch {
116
+ return 0;
117
+ }
118
+ }
119
+
104
120
  export function stallPoll(run: Run, d: Dispatch): () => void {
105
121
  let seen = false;
106
122
  return () => {
107
123
  if (seen || !existsSync(dispatchPaths(d.dir).stall)) return;
108
124
  seen = true;
109
- let quietMs = 0;
110
- try {
111
- quietMs = Number(JSON.parse(readFileSync(dispatchPaths(d.dir).stall, "utf8")).quietMs) || 0;
112
- } catch {
113
- // being written: the stall is reported without its length
114
- }
125
+ const quietMs = readStallQuietMs(d.dir);
115
126
  for (const hook of stallHooks) {
116
127
  try {
117
128
  hook({ run, d, quietMs });
@@ -169,9 +180,19 @@ export async function watchersSettled(): Promise<void> {
169
180
  */
170
181
  export function watch(deps: Deps, run: Run, d: Dispatch): void {
171
182
  watching.add(d.admit.dispatchId);
183
+ // the session this watcher started under, kept apart from its live identity: an MCP connection that closes
184
+ // invalidates the scoped host (no session), yet the watcher still came from that session (PR #47 P1)
185
+ const origin = currentSession(deps);
172
186
  const w = (async () => {
173
187
  await waitForFinish(d, { pollMs: deps.pollMs, now: deps.now, onPoll: stallPoll(run, d) });
174
- const s = await settle(deps, run, d, await finalizeDispatch(run, d));
188
+ const record = await finalizeDispatch(run, d);
189
+ // a session that lost the run since it dispatched leaves a limit to the owner's claim (1.1 push minors):
190
+ // only the run's owner starts a stand-in. A process with no session (a terminal) settles as before.
191
+ if (record.status === "limit" && !limitIsMine(deps, run, origin)) {
192
+ log("info", "dispatch", { run: run.id, name: d.admit.name, limit: "left to the run's owner" });
193
+ return;
194
+ }
195
+ const s = await settle(deps, run, d, record);
175
196
  if (s.stateHints[0]) log("warn", "dispatch", { run: run.id, name: d.admit.name, hint: s.stateHints[0] });
176
197
  })()
177
198
  .catch((e: unknown) =>
@@ -184,6 +205,18 @@ export function watch(deps: Deps, run: Run, d: Dispatch): void {
184
205
  watchers.add(w);
185
206
  }
186
207
 
208
+ /**
209
+ * Whether a watcher settles a limit (fails it over): a watcher with no session, then or now (a terminal, a
210
+ * test), always does; one that started under a session does only while that session, or its live one, owns
211
+ * the run.
212
+ */
213
+ function limitIsMine(deps: Deps, run: Run, origin: SessionRef | null): boolean {
214
+ if (origin === null && currentSession(deps) === null) return true;
215
+ if (ownsRun(deps, run)) return true;
216
+ const owner = runOwner(run);
217
+ return origin !== null && owner !== null && sessionKey(owner) === sessionKey(origin);
218
+ }
219
+
187
220
  /**
188
221
  * Spec §3.3: a run this session has just taken over may have roles another session's server launched. This
189
222
  * process watches each live one it is not already watching, so it settles them and its notifier announces them
@@ -209,6 +242,16 @@ export function adopt(deps: Deps, run: Run): void {
209
242
  */
210
243
  export async function claim(deps: Deps, run: Run, o: { background?: boolean } = {}): Promise<void> {
211
244
  if (!(await claimRun(deps, run)) && !ownsRun(deps, run)) return;
245
+ await recoverOwned(deps, run, o);
246
+ }
247
+
248
+ /**
249
+ * What `claim` does for a run this session already owns, without taking it: watch its live roles, record and
250
+ * settle what finished unrecorded, settle its unsettled limits and unannounced records. A server that does not
251
+ * lead the boot runs it for each run its session owns (PR #47 P1: a role that finished while that server was
252
+ * away has no live process to watch and no record to scan).
253
+ */
254
+ export async function recoverOwned(deps: Deps, run: Run, o: { background?: boolean } = {}): Promise<void> {
212
255
  adopt(deps, run);
213
256
  let r = recovering.get(run.id);
214
257
  if (!r) {
@@ -276,24 +319,140 @@ function unannounced(run: Run): { d: Dispatch; record: RunRecord }[] {
276
319
  });
277
320
  }
278
321
 
322
+ /** How long a dispatch waits for another dispatch's route of the same lane (Jev may take tens of seconds). */
323
+ const ROUTE_LOCK_MS = 120_000;
324
+
325
+ /**
326
+ * What admission would refuse anyway, refused before a lane is routed, so a refused dispatch asks Jev nothing
327
+ * and writes no route row: the role off, the lane file missing or with no Owns:, the name or the lane already
328
+ * running. Admission checks them again under its lock.
329
+ */
330
+ function refuseBeforeRouting(deps: Deps, run: Run, i: DispatchInput & { lane: string }): void {
331
+ const profile = runProfile(deps, run);
332
+ if (!profile.roles[i.role]?.enabled)
333
+ throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
334
+ fix: "skip the role, or turn it on with profile_set",
335
+ });
336
+ laneOwns(run, i.lane);
337
+ // live only: a finished, unrecorded dispatch is recorded by admission (finalizeFinished), not refused here
338
+ const pending = liveDispatches(run, deps.now());
339
+ const same = pending.find((d) => d.admit.name === i.name);
340
+ if (same)
341
+ throw new CatherdError("E_ADMIT_DUPLICATE", `${i.name} is already running on ${same.admit.rung}`, {
342
+ fix: `its record is announced when it finishes; or cancel(run, "${i.name}")`,
343
+ });
344
+ const lane = pending.find((d) => d.admit.lane === i.lane);
345
+ if (lane)
346
+ throw new CatherdError("E_ADMIT_OVERLAP", `lane ${i.lane} is already running as ${lane.admit.name}`, {
347
+ fix: `dispatch it after ${lane.admit.name} finishes`,
348
+ });
349
+ }
350
+
351
+ /**
352
+ * Plan 22: the thread a dispatch resumes. `"latest"` is the name's last recorded thread; any other id must be one
353
+ * the name's records in this run hold (`runs.jsonl`), in any case, so a wrong id is refused before a CLI starts
354
+ * and fails on it. Null for a fresh thread.
355
+ */
356
+ function threadFor(run: Run, name: string, thread: string | undefined): string | null {
357
+ if (thread === undefined) return null;
358
+ assertId("role name", name);
359
+ const mine = readRecords(run)
360
+ .records.filter((r) => r.name === name && r.thread !== null)
361
+ .map((r) => r.thread as string);
362
+ const last = mine.at(-1);
363
+ if (thread === "latest") {
364
+ if (last) return last;
365
+ throw new CatherdError("E_ADMIT_THREAD", `${name} has no earlier thread in this run`, {
366
+ fix: "omit thread for a fresh thread",
367
+ });
368
+ }
369
+ const known = mine.findLast((t) => t.toLowerCase() === thread.toLowerCase());
370
+ if (known) return known;
371
+ throw new CatherdError("E_ADMIT_THREAD", `${thread} is not a thread of ${name} in this run`, {
372
+ fix: last
373
+ ? `pass thread: "latest" for ${name}'s last thread (${last}), or omit thread for a fresh one`
374
+ : "omit thread for a fresh thread",
375
+ });
376
+ }
377
+
378
+ /**
379
+ * Plan 22, resume hygiene: what an earlier turn on `thread` left running in its process group (a server, a
380
+ * watcher, a background command) is stopped before the thread is resumed, so it never ends the resumed CLI
381
+ * (exit 143). Only a dispatch whose supervisor and group leader are both gone is touched (with or without
382
+ * exit.json: a supervisor killed before it stopped the group writes none): a live group with no process of the
383
+ * leader's pid can only be that dispatch's leftovers (a pid is never reused while its group lives), and a pid
384
+ * that answers belongs to someone else by now. Returns a hint per group stopped.
385
+ */
386
+ async function stopLeftovers(deps: Deps, run: Run, thread: string): Promise<string[]> {
387
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
388
+ const hints: string[] = [];
389
+ for (const d of listDispatches(run)) {
390
+ const on = d.admit.thread ?? records.get(d.admit.dispatchId)?.thread ?? null;
391
+ if (on?.toLowerCase() !== thread.toLowerCase()) continue;
392
+ const proc = readProc(d.dir);
393
+ const pgid = proc?.pgid ?? proc?.pid;
394
+ if (!proc || pgid === undefined || pgid !== proc.pid) continue;
395
+ // a live supervisor stops its own group when its worker ends; exit.json is not the test, since a supervisor
396
+ // killed (SIGKILL, OOM) before it stopped the group never writes one
397
+ if (isAlive(proc.supervisorPid, proc.supervisorStartTime)) continue;
398
+ if (isAlive(proc.pid, null) || !groupAlive(pgid)) continue;
399
+ await stopGroup(pgid, orphanLimits.killGraceMs, deps.pollMs);
400
+ log("info", "dispatch", { run: run.id, name: d.admit.name, thread, stoppedGroup: pgid });
401
+ hints.push(`resume: stopped what ${d.admit.name}'s earlier turn left running on thread ${thread}`);
402
+ }
403
+ return hints;
404
+ }
405
+
279
406
  /**
280
407
  * Spec §4.4, plan 9 ruling 1: admit and launch one role, start its watcher, then refresh state.md with
281
408
  * `next` (after the launch, so nothing delays it). Returns at once; catherd announces the record (spec §3).
282
409
  */
283
410
  export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchStarted> {
284
411
  const run = findRun(i.run);
412
+ // refused before the claim, so a wrong thread changes nothing
413
+ const thread = threadFor(run, i.name, i.thread);
285
414
  await claim(deps, run);
286
415
  const hints: string[] = [];
287
- let rung = i.rung;
288
- // spec 1.1 §6: a lane is routed before its first dispatch; a rung off the routed ladder starts at the routed one
289
- if (i.lane !== undefined && !readRoutes(run).some((r) => r.lane === i.lane)) {
290
- assertId("lane", i.lane);
291
- const routed = await route(deps, { run: i.run, laneFile: `lanes/${i.lane}.md`, role: i.role });
292
- if (!routed.ladder.includes(i.rung)) {
416
+ if (thread !== null) hints.push(...(await stopLeftovers(deps, run, thread)));
417
+ let rung: string;
418
+ if (i.lane !== undefined) {
419
+ const lane = i.lane;
420
+ assertId("lane", lane);
421
+ // spec 1.1 §6: a lane is routed before its first dispatch; a rung off the routed ladder starts at the routed one
422
+ let current = currentRoute(readRoutes(run), lane);
423
+ let routed: Awaited<ReturnType<typeof route>> | null = null;
424
+ if (!current) {
425
+ refuseBeforeRouting(deps, run, { ...i, lane });
426
+ // one route per lane: a second dispatch of the lane waits for the first's route and takes it
427
+ const got = await withFileLock(
428
+ join(run.dir, `route-${lane}`),
429
+ async () => {
430
+ const now = currentRoute(readRoutes(run), lane);
431
+ return now
432
+ ? { current: now, routed: null }
433
+ : {
434
+ current: null,
435
+ routed: await route(deps, { run: i.run, laneFile: `lanes/${lane}.md`, role: i.role }),
436
+ };
437
+ },
438
+ { timeoutMs: ROUTE_LOCK_MS },
439
+ );
440
+ current = got.current;
441
+ routed = got.routed;
442
+ }
443
+ // spec 1.5 plan 24: without a rung, a lane runs at its current rung (its route, after any climb)
444
+ rung = i.rung ?? routed?.rung ?? (current?.rung as string);
445
+ if (routed && i.rung !== undefined && !routed.ladder.includes(i.rung)) {
293
446
  rung = routed.rung;
294
- hints.push(`${i.rung} is not on ${i.lane}'s routed ladder: dispatched at ${routed.rung}`);
447
+ hints.push(`${i.rung} is not on ${lane}'s routed ladder: dispatched at ${routed.rung}`);
295
448
  }
296
- }
449
+ } else if (i.rung === undefined)
450
+ throw new CatherdError("E_INPUT_INVALID", `dispatch ${i.name}: a role outside a lane needs a rung`, {
451
+ fix: `route(run, role: "${i.role}") gives the role's rung; pass it as rung`,
452
+ });
453
+ else rung = i.rung;
454
+ // the rung that runs, after routing: an off-ladder native rung routing replaced is never launched
455
+ assertNativeHost(rung, deps.host.host);
297
456
  const { d, specPath } = await admit(
298
457
  deps,
299
458
  run,
@@ -302,7 +461,7 @@ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchSt
302
461
  name: i.name,
303
462
  brief: i.brief,
304
463
  rung,
305
- thread: i.thread ?? null,
464
+ thread,
306
465
  lane: i.lane ?? null,
307
466
  failoverFrom: null,
308
467
  },
@@ -317,10 +476,46 @@ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchSt
317
476
  // a launch that failed is still watched: its record (lost, after the start grace) is announced
318
477
  watch(deps, run, d);
319
478
  }
320
- await refresh(run, i.next ? { next: i.next } : {}, hints);
479
+ if (i.lane === undefined) recordRoleDispatch(deps, run, i.role, i.name, rung);
480
+ // spec 1.5: a change since the run's pin is logged in state.md each time a role starts
481
+ const changes = pinChanges(deps, run);
482
+ if (changes.length)
483
+ hints.push(
484
+ `dispatched on the run's pinned values: ${changes.join("; ")}; run_pin(run) re-pins to the repo's now`,
485
+ );
486
+ await refresh(run, { ...(i.next ? { next: i.next } : {}), pinChanges: changes }, hints);
321
487
  return { dispatched: dispatchedOf(d), hints };
322
488
  }
323
489
 
490
+ /**
491
+ * Spec 1.5 plan 24: a lane-less dispatch's rung in routes.jsonl, so every role's decision can be audited:
492
+ * `route` when it is the rung the role's last `route` gave (with that ladder), else the coordinator's own pick.
493
+ * A failed write is logged, never a failed dispatch: the role is already running.
494
+ */
495
+ function recordRoleDispatch(deps: Deps, run: Run, role: Role, name: string, rung: string): void {
496
+ try {
497
+ const last = readRoleRoutes(run).findLast((r) => r.role === role && r.source === "route");
498
+ const routed = last?.rung === rung;
499
+ appendRoute(run, {
500
+ at: new Date(deps.now()).toISOString(),
501
+ lane: null,
502
+ role,
503
+ name,
504
+ rung,
505
+ ladder: last?.ladder ?? [rung],
506
+ source: "dispatch",
507
+ decidedBy: routed ? last.decidedBy : "orchestrator",
508
+ why: routed
509
+ ? `the rung route gave the ${role}`
510
+ : last
511
+ ? `the coordinator's rung; route gave the ${role} ${last.rung}`
512
+ : `the coordinator's rung; the ${role} was never routed`,
513
+ });
514
+ } catch (e) {
515
+ log("warn", "dispatch", { run: run.id, name, routes: errorMessage(e) });
516
+ }
517
+ }
518
+
324
519
  /** How long a settle waits for another process's failover of the same dispatch (a test seam). */
325
520
  export const failoverLock = { timeoutMs: 120_000 };
326
521
 
@@ -491,8 +686,10 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
491
686
  return failedOver(already.admit.rung, already);
492
687
  }
493
688
  // else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
494
- const standIn = standInFor(deps.profiles.forRepo(run.meta.repo).failover, limited.rung, run.meta.repo);
689
+ const standIn = standInFor(runProfile(deps, run).failover, limited.rung, run.meta.repo);
495
690
  if (!standIn) return { hints, started: null, pause: paused };
691
+ // the stand-in answers to whoever owns the run now, which a claim from another host may have changed
692
+ assertNativeHost(standIn, runOwner(run)?.host ?? deps.host.host);
496
693
  if (parseRung(standIn).backend === "claude") {
497
694
  const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
498
695
  return {
@@ -517,6 +714,7 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
517
714
  failoverOf: d.admit.dispatchId,
518
715
  // the stand-in answers to the session that dispatched the limited role, whoever fails it over
519
716
  sessionId: d.admit.sessionId ?? null,
717
+ host: d.admit.host,
520
718
  });
521
719
  } catch (e) {
522
720
  if (!isCatherdError(e)) throw e;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import { z } from "zod";
4
+ import { CatherdError } from "../domain/errors.ts";
4
5
  import { dispatchHints } from "../domain/hints.ts";
5
6
  import { ACCESS, type RunRecord } from "../domain/record.ts";
6
7
  import { ROLES } from "../domain/roles.ts";
@@ -18,6 +19,11 @@ const AdmitSchema = z.looseObject({
18
19
  role: z.enum(ROLES),
19
20
  lane: z.string().nullable(),
20
21
  owns: z.array(z.string()),
22
+ /**
23
+ * spec 1.5 plan 21: a laneless writer's implicit Owns (its brief's Owns: line, else docs/** and *.md), so its
24
+ * edits are its own and never a concurrent lane's violation; admission never refuses an overlap with it
25
+ */
26
+ ownsImplicit: z.array(z.string()).optional(),
21
27
  rung: z.string(),
22
28
  backend: z.string(),
23
29
  thread: z.string().nullable(),
@@ -36,6 +42,7 @@ const AdmitSchema = z.looseObject({
36
42
  before: z.record(z.string(), z.string()),
37
43
  /** the Claude Code session that dispatched it (spec §3.3); absent on 1.0 dispatches and outside Claude Code */
38
44
  sessionId: z.string().optional(),
45
+ host: z.enum(["claude-code", "codex"]).optional(),
39
46
  });
40
47
  export type Admit = z.infer<typeof AdmitSchema>;
41
48
 
@@ -53,9 +60,11 @@ export const startLimits = { graceMs: STARTING_GRACE_MS };
53
60
  export const admitPath = (dir: string): string => join(dir, "admit.json");
54
61
  export const launchPath = (dir: string): string => join(dir, "launch.json");
55
62
  export const roleDir = (run: Run, name: string): string => join(runPaths(run.dir).roles, name);
63
+ /** Spec 1.5 plan 21: the role's TMPDIR, `<run>/scratch/<name>/`, removed with the run's scratch. */
64
+ export const scratchDir = (run: Run, name: string): string => join(runPaths(run.dir).scratch, name);
56
65
 
57
66
  /** Every admitted dispatch of the run, oldest first; a folder whose admit.json cannot be read is skipped. */
58
- export function listDispatches(run: Run): Dispatch[] {
67
+ export function listDispatches(run: Run, strict = false): Dispatch[] {
59
68
  const roles = runPaths(run.dir).roles;
60
69
  const out: Dispatch[] = [];
61
70
  if (!existsSync(roles)) return out;
@@ -65,8 +74,10 @@ export function listDispatches(run: Run): Dispatch[] {
65
74
  if (!id.isDirectory()) continue;
66
75
  const dir = join(roles, name.name, id.name);
67
76
  try {
68
- out.push({ dir, admit: readVersioned(admitPath(dir), AdmitSchema, 1) });
77
+ out.push({ dir, admit: decodeAdmit(readVersioned(admitPath(dir), AdmitSchema, 1)) });
69
78
  } catch {
79
+ if (strict && existsSync(admitPath(dir)))
80
+ throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has unreadable admission evidence: ${dir}`);
70
81
  // admission never finished writing this folder
71
82
  }
72
83
  }
@@ -123,9 +134,10 @@ export function pendingDispatches(
123
134
  run: Run,
124
135
  now = Date.now(),
125
136
  records: RunRecord[] = readRecords(run).records,
137
+ strict = false,
126
138
  ): LiveDispatch[] {
127
139
  const recorded = new Set(records.map((r) => r.dispatchId));
128
- return listDispatches(run)
140
+ return listDispatches(run, strict)
129
141
  .filter((d) => !recorded.has(d.admit.dispatchId))
130
142
  .map((d) => ({ ...d, state: dispatchState(d, now) }));
131
143
  }
@@ -133,6 +145,23 @@ export function pendingDispatches(
133
145
  export const liveDispatches = (run: Run, now = Date.now()): LiveDispatch[] =>
134
146
  pendingDispatches(run, now).filter((d) => d.state !== "finished");
135
147
 
148
+ /** The thread the supervisor saw the role's CLI name (thread.json), else null. */
149
+ export function readThread(dir: string): string | null {
150
+ const t = readJson<{ thread?: unknown }>(dispatchPaths(dir).thread)?.thread;
151
+ return typeof t === "string" && t ? t : null;
152
+ }
153
+
154
+ /**
155
+ * Spec 1.5 plan 21: the role whose thread `thread` is, in this run: a recorded thread, a resumed one, or the one
156
+ * a live role's supervisor wrote down. Null for any other thread, the orchestrator's among them.
157
+ */
158
+ export function roleThreadOf(run: Run, thread: string): string | null {
159
+ const recorded = readRecords(run).records.find((r) => r.thread === thread);
160
+ if (recorded) return recorded.name;
161
+ const d = listDispatches(run).find((x) => x.admit.thread === thread || readThread(x.dir) === thread);
162
+ return d ? d.admit.name : null;
163
+ }
164
+
136
165
  /** Spec §4.1: `roles/<name>/latest` names the newest dispatch of a role. */
137
166
  export function setLatest(run: Run, name: string, dispatchId: string): void {
138
167
  writeTextAtomic(join(roleDir(run, name), "latest"), dispatchId);
@@ -174,3 +203,7 @@ export function readFailover(dir: string): FailoverFile | null {
174
203
  export function recordHints(run: Run, d: Dispatch, r: RunRecord): string[] {
175
204
  return readFailover(d.dir)?.hints ?? dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
176
205
  }
206
+
207
+ function decodeAdmit(a: Admit): Admit {
208
+ return a.sessionId && !a.host ? { ...a, host: "claude-code" } : a;
209
+ }
@@ -172,9 +172,14 @@ export function workspaceWriteNetwork(profiles: Profile[]): Map<string, boolean>
172
172
 
173
173
  /**
174
174
  * The `sandbox:codex` row (which `codex sandbox` form runs) and one `access:<backend>` row per backend; a
175
- * backend not in `installed` (its CLI is not on PATH) is skipped without running a probe.
175
+ * backend not in `installed` (its CLI is not on PATH) is skipped without running a probe. `repos`: where
176
+ * workers run (doctor's repo and every bound one), for a sandbox set per repo.
176
177
  */
177
- export async function accessChecks(profiles: Profile[], installed: ReadonlySet<string>): Promise<Check[]> {
178
+ export async function accessChecks(
179
+ profiles: Profile[],
180
+ installed: ReadonlySet<string>,
181
+ repos: string[] = [],
182
+ ): Promise<Check[]> {
178
183
  const checks: Check[] = [];
179
184
  for (const [id, network] of workspaceWriteNetwork(profiles)) {
180
185
  const a = adapterFor(id);
@@ -190,7 +195,7 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
190
195
  continue;
191
196
  }
192
197
  const shell = a.accessShell
193
- ? await a.accessShell({ network }).catch((e: unknown) => String(e))
198
+ ? await a.accessShell({ network, repos }).catch((e: unknown) => String(e))
194
199
  : `no way to run a shell in ${id}'s sandbox without a model turn; the live kit (docs/dev/live-verification.md) runs the five probes as one worker turn`;
195
200
  if (id === "codex")
196
201
  checks.push(
@@ -1,12 +1,13 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import type { Probe } from "../adapters/backend.ts";
2
3
  import { adapterFor } from "../adapters/registry.ts";
3
4
  import "../adapters/all.ts";
4
5
  import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
5
6
  import type { Profile } from "../domain/profile.ts";
6
7
  import { ROLES, type Role } from "../domain/roles.ts";
7
- import { probeBackend } from "./backends.ts";
8
+ import { standInFor, probeBackend } from "./backends.ts";
8
9
  import { refreshDiscovery } from "./catalog-service.ts";
9
- import { type Check, errText, fixOf } from "./doctor-checks.ts";
10
+ import { type Check, errText, fixOf, hostFlag } from "./doctor-checks.ts";
10
11
 
11
12
  // The backend rows of `catherd doctor` (spec §10.3) and the backends the linked profiles use.
12
13
 
@@ -22,7 +23,12 @@ export function usedBackends(profiles: Profile[]): Map<string, "role" | "failove
22
23
  };
23
24
  for (const p of profiles) {
24
25
  for (const role of ROLES) if (p.roles[role].enabled) for (const r of p.roles[role].rungs) note(r, "role");
25
- for (const to of Object.values(p.failover)) note(to, "failover");
26
+ for (const role of ROLES)
27
+ if (p.roles[role].enabled)
28
+ for (const r of p.roles[role].rungs) {
29
+ const to = standInFor(p.failover, r);
30
+ if (to) note(to, "failover");
31
+ }
26
32
  }
27
33
  return used;
28
34
  }
@@ -52,10 +58,16 @@ const shellWord = (w: string): string => (/^[\w./:=@,+-]+$/.test(w) ? w : `'${w.
52
58
  * each linked profile, any default rung the new ladder would not hold reset first. The artist draws, which only Codex does, so
53
59
  * off Codex it is turned off instead.
54
60
  */
55
- export function moveRolesFix(install: string, id: string, to: string, profiles: Profile[]): string {
61
+ export function moveRolesFix(
62
+ install: string,
63
+ id: string,
64
+ to: string,
65
+ profiles: Profile[],
66
+ host: OrchestrationHost = "unknown",
67
+ ): string {
56
68
  const onIt = (r: string | undefined) => r !== undefined && backendOf(r) === id;
57
69
  const set = (p: Profile, path: string, v: string) =>
58
- `catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}`;
70
+ `catherd profile set ${path} ${shellWord(v)} --profile ${shellWord(p.name)}${hostFlag(host)}`;
59
71
  const stuck = new Set<Role>();
60
72
  const commands: string[] = [];
61
73
  for (const p of profiles)
@@ -87,10 +99,21 @@ export async function backendChecks(
87
99
  used: Map<string, "role" | "failover">,
88
100
  profiles: Profile[],
89
101
  installed?: Set<string>,
102
+ host: OrchestrationHost = "unknown",
90
103
  ): Promise<Check[]> {
91
104
  const checks: Check[] = [];
92
105
  const ready: string[] = [];
93
106
  for (const id of ADAPTER_IDS) {
107
+ if (host === "codex" && id === "claude-code" && !used.has(id)) {
108
+ checks.push({
109
+ id: `backend:${id}`,
110
+ label: id,
111
+ state: "skip",
112
+ word: "not required",
113
+ detail: "not probed: the selected profile has no headless Claude role or reachable failover",
114
+ });
115
+ continue;
116
+ }
94
117
  const a = adapterFor(id);
95
118
  if (!a) continue;
96
119
  const probe: Probe = await probeBackend(a).catch((e: unknown) => ({
@@ -124,7 +147,7 @@ export async function backendChecks(
124
147
  state: "warn",
125
148
  word: "billing",
126
149
  detail: `${detail} · profile ${billed.name} bills ${id} as ${billed.billing[id]}, but this login is ${probe.billing}`,
127
- fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}`,
150
+ fix: `catherd profile set billing.${id} ${probe.billing} --profile ${billed.name}${hostFlag(host)}`,
128
151
  }
129
152
  : { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
130
153
  );
@@ -147,7 +170,7 @@ export async function backendChecks(
147
170
  for (const c of checks) {
148
171
  const id = c.id.slice("backend:".length);
149
172
  if (to && c.state === "fail" && c.word === PROBLEM_WORD.E_BACKEND_MISSING && c.fix && id !== to)
150
- c.fix = moveRolesFix(c.fix, id, to, profiles);
173
+ c.fix = moveRolesFix(c.fix, id, to, profiles, host);
151
174
  }
152
175
  // spec §5.2: doctor refreshes discovery; a listing that fails keeps the last one
153
176
  let refreshed: Awaited<ReturnType<typeof refreshDiscovery>> = [];
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import { readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { errorMessage, isCatherdError } from "../domain/errors.ts";
@@ -131,9 +132,13 @@ export function locksCheck(): Check {
131
132
  }
132
133
  }
133
134
 
134
- export function agentsCheck(): Check {
135
+ export function agentsCheck(
136
+ host: OrchestrationHost,
137
+ selected?: string[],
138
+ relink = `catherd profile use ${activeName()}`,
139
+ ): Check {
135
140
  const base = { id: "agents", label: "Claude agents" };
136
- const links = agentLinkState();
141
+ const links = agentLinkState(host, selected);
137
142
  const broken = [...links.missing, ...links.stale];
138
143
  if (broken.length)
139
144
  return {
@@ -141,13 +146,19 @@ export function agentsCheck(): Check {
141
146
  state: "fail",
142
147
  word: links.missing.length ? "missing" : "stale",
143
148
  detail: broken.join(", "),
144
- fix: `catherd profile use ${activeName()}`,
149
+ fix: relink,
145
150
  };
146
151
  return links.ok.length
147
152
  ? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
148
153
  : { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
149
154
  }
150
155
 
156
+ /**
157
+ * ` --host <host>` for a fix doctor prints, when it judged on a known host: run from a plain terminal, the fix
158
+ * must judge the profile the same way (omitted architect/verifier rungs resolve per host).
159
+ */
160
+ export const hostFlag = (host: OrchestrationHost): string => (host === "unknown" ? "" : ` --host ${host}`);
161
+
151
162
  /** The browser CLI the ui-reviewer's prompt takes its screenshots with. */
152
163
  const UI_BROWSER = "agent-browser";
153
164
 
@@ -155,7 +166,11 @@ const UI_BROWSER = "agent-browser";
155
166
  * The ui-reviewer takes screenshots with agent-browser: a profile that turns it on, on a machine without it,
156
167
  * dispatches a role that cannot do its job. Nothing to say when it is on PATH or no profile turns the role on.
157
168
  */
158
- export function uiBrowserCheck(profiles: Profile[], onPath: (bin: string) => string | null): Check | null {
169
+ export function uiBrowserCheck(
170
+ profiles: Profile[],
171
+ onPath: (bin: string) => string | null,
172
+ host: OrchestrationHost = "unknown",
173
+ ): Check | null {
159
174
  const on = profiles.filter((p) => p.roles["ui-reviewer"].enabled).map((p) => p.name);
160
175
  if (!on.length || onPath(UI_BROWSER)) return null;
161
176
  return {
@@ -167,7 +182,7 @@ export function uiBrowserCheck(profiles: Profile[], onPath: (bin: string) => str
167
182
  fix: [
168
183
  `npm i -g ${UI_BROWSER}`,
169
184
  "or turn the role off:",
170
- ...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}`),
185
+ ...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}${hostFlag(host)}`),
171
186
  ].join("\n"),
172
187
  };
173
188
  }