catherd-cli 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -0,0 +1,56 @@
1
+ import { CatherdError } from "../domain/errors.ts";
2
+ import type { HostContext } from "../domain/host.ts";
3
+ import { resolveHost } from "../infra/host-context.ts";
4
+
5
+ export const HOST_ARG = {
6
+ host: {
7
+ type: "enum" as const,
8
+ options: ["auto", "codex", "claude-code"],
9
+ description: "orchestration host (auto uses session evidence)",
10
+ },
11
+ };
12
+
13
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+
15
+ /**
16
+ * Plan 22, `doctor --test-push --thread <uuid>`: Codex does not export its thread id to the commands it runs, so a
17
+ * shell names the thread the smoke goes to. It makes the host that Codex thread; a Claude Code session refuses it.
18
+ */
19
+ export function withThread(host: HostContext, thread: string | undefined): HostContext {
20
+ if (thread === undefined) return host;
21
+ if (!UUID.test(thread))
22
+ throw new CatherdError("E_INPUT_INVALID", `--thread ${thread} is not a Codex thread id`, {
23
+ fix: "pass the thread's UUID, as the first line of a catherd message names it (thread: <uuid>)",
24
+ });
25
+ if (host.host === "claude-code")
26
+ throw new CatherdError(
27
+ "E_INPUT_INVALID",
28
+ "--thread names a Codex thread, and this is a Claude Code session",
29
+ {
30
+ fix: "run catherd doctor --test-push without --thread from Claude Code",
31
+ },
32
+ );
33
+ const sessionId = thread.toLowerCase();
34
+ return {
35
+ host: "codex",
36
+ session: { host: "codex", sessionId, hostSessionId: null, name: null },
37
+ conflict: null,
38
+ };
39
+ }
40
+
41
+ export function terminalHost(value: string | undefined, env = process.env): HostContext {
42
+ if (value === undefined || value === "auto") return resolveHost({ env });
43
+ if (value !== "codex" && value !== "claude-code")
44
+ throw new CatherdError("E_INPUT_INVALID", `unknown host ${value}`, {
45
+ fix: "use --host auto, codex or claude-code",
46
+ });
47
+ const resolved = resolveHost({ env, terminalHost: value });
48
+ if (resolved.conflict) return resolved;
49
+ if (resolved.host !== value)
50
+ return {
51
+ host: "unknown",
52
+ session: null,
53
+ conflict: "Terminal host and launcher session evidence disagree; remove the other host’s environment",
54
+ };
55
+ return { ...resolved, session: null };
56
+ }
@@ -1,3 +1,5 @@
1
+ import { gitToplevel } from "../infra/git.ts";
2
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
1
3
  import { defineCommand } from "citty";
2
4
  import { assertProfileName } from "../domain/profile.ts";
3
5
  import { errorMessage, isCatherdError } from "../domain/errors.ts";
@@ -11,8 +13,9 @@ import { type SyncReport, syncSources } from "../services/source-sync.ts";
11
13
  import { reinstallCommand } from "../services/doctor-checks.ts";
12
14
  import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../services/global-install.ts";
13
15
  import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
16
+ import { activeName } from "../services/profile-store.ts";
14
17
  import { formatRefreshed, syncLines } from "./catalog-command.ts";
15
- import { mark as markOf } from "./cli-kit.ts";
18
+ import { mark as markOf, refuseInRole } from "./cli-kit.ts";
16
19
  import { formatReport } from "./doctor-command.ts";
17
20
  import { mcpHandshake } from "./mcp/handshake.ts";
18
21
  import type { State } from "./glyphs.ts";
@@ -195,6 +198,7 @@ export const initCommand = defineCommand({
195
198
  "First run: the global catherd command, the Jev key, the Artificial Analysis key, a sync of the public model sources, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the Artificial Analysis key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
196
199
  },
197
200
  args: {
201
+ ...HOST_ARG,
198
202
  // citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
199
203
  "no-input": { type: "boolean", description: "ask nothing: keep what exists, else write the defaults" },
200
204
  // read as global: false, like no-input above
@@ -202,10 +206,14 @@ export const initCommand = defineCommand({
202
206
  type: "boolean",
203
207
  description: "do not install the global catherd command (bun add -g catherd-cli@<this version>)",
204
208
  },
205
- profile: { type: "string", description: "the profile to set up and make active (default: default)" },
209
+ profile: {
210
+ type: "string",
211
+ description: "the profile to set up and make active (default: current repo profile)",
212
+ },
206
213
  plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
207
214
  },
208
215
  async run({ args }) {
216
+ refuseInRole(process.env, "catherd init");
209
217
  // a bad --profile is refused before any question is asked
210
218
  if (args.profile !== undefined) assertProfileName(args.profile);
211
219
  const ask = (args as { input?: boolean }).input === false ? null : await prompter();
@@ -218,19 +226,21 @@ export const initCommand = defineCommand({
218
226
  await jevStep(ask, {}, plain);
219
227
  await aaStep(ask, {}, plain);
220
228
  await syncStep({ plain });
221
- if (args.profile !== undefined) ask?.skip?.();
222
- const name = assertProfileName(
223
- args.profile ?? (ask ? (await ask.ask("Profile to set up [default]: ")) || "default" : "default"),
224
- );
229
+ const repo = await gitToplevel(process.cwd());
225
230
  // 0.x files go first, so a legacy profile about to be moved aside is never asked about
226
231
  const moved = moveLegacy();
232
+ const current = activeName(repo);
233
+ if (args.profile !== undefined) ask?.skip?.();
234
+ const selected =
235
+ args.profile ?? (ask ? (await ask.ask(`Profile to set up [${current}]: `)) || undefined : undefined);
236
+ const name = assertProfileName(selected ?? current);
227
237
  const exists = hasProfileFile(name);
228
238
  if (!exists) ask?.skip?.();
229
239
  const overwrite =
230
240
  ask !== null &&
231
241
  exists &&
232
242
  /^y(es)?$/i.test(await ask.ask(`Replace profile ${name} with the default profile? [y/N] `));
233
- const r = await initSetup({ profile: name, overwrite });
243
+ const r = await initSetup({ profile: selected, repo, overwrite, host: terminalHost(args.host) });
234
244
  for (const f of [...moved, ...r.moved]) console.log(`${mark("ok")} moved a 0.x file aside: ${f}`);
235
245
  for (const l of profileLines(r, plain)) console.log(l);
236
246
  if (r.synced?.linked.length)
@@ -238,13 +248,15 @@ export const initCommand = defineCommand({
238
248
  for (const x of r.refreshed) console.log(formatRefreshed(x, plain));
239
249
  console.log("");
240
250
  const report = await doctor({
251
+ host: terminalHost(args.host),
252
+ repo,
241
253
  bunVersion: Bun.version,
242
254
  version: VERSION,
243
255
  handshake: () => mcpHandshake(),
244
256
  });
245
257
  for (const l of formatReport(report, plain)) console.log(l);
246
258
  console.log("");
247
- for (const l of PLUGIN_STEPS) console.log(l);
259
+ for (const l of terminalHost(args.host).host === "claude-code" ? PLUGIN_STEPS : []) console.log(l);
248
260
  } finally {
249
261
  ask?.close();
250
262
  }
@@ -1,7 +1,9 @@
1
1
  import { resolve } from "node:path";
2
2
  import { defineCommand } from "citty";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import { gateEnvLines, readGateEnv, removeGateEnv, setGateEnv } from "../services/gate-env.ts";
3
5
  import { addKnowledge, knowledgeLines, knowledgePath, readKnowledge } from "../services/run-service.ts";
4
- import { JSON_ARG, printJson } from "./cli-kit.ts";
6
+ import { JSON_ARG, printJson, refuseInRole } from "./cli-kit.ts";
5
7
 
6
8
  /** `--repo`, else the repository the command runs in, as run_start and read_knowledge resolve it. */
7
9
  const REPO_ARG = {
@@ -29,6 +31,7 @@ const add = defineCommand({
29
31
  ...REPO_ARG,
30
32
  },
31
33
  async run({ args }) {
34
+ refuseInRole(process.env, "catherd knowledge add");
32
35
  console.log(await addKnowledge(repoOf(args.repo), args.line));
33
36
  },
34
37
  });
@@ -43,8 +46,80 @@ const path = defineCommand({
43
46
  },
44
47
  });
45
48
 
49
+ /** `NAME=value` as name and value; null when it has no `=`. */
50
+ function assignment(raw: string): { name: string; value: string } | null {
51
+ const eq = raw.indexOf("=");
52
+ return eq > 0 ? { name: raw.slice(0, eq), value: raw.slice(eq + 1) } : null;
53
+ }
54
+
55
+ const envSet = defineCommand({
56
+ meta: {
57
+ name: "set",
58
+ description:
59
+ "Set one variable of the repo's gate environment, which the verifier and preflight run with: NAME=value, or NAME --from ENV_VAR for a secret (stored by reference, read when a role starts)",
60
+ },
61
+ args: {
62
+ variable: { type: "positional", required: true, description: "NAME=value, or NAME with --from" },
63
+ from: { type: "string", description: "the env var that holds the secret value" },
64
+ ...REPO_ARG,
65
+ ...JSON_ARG,
66
+ },
67
+ async run({ args }) {
68
+ refuseInRole(process.env, "catherd knowledge env set");
69
+ const set = assignment(args.variable);
70
+ if (args.from !== undefined && set)
71
+ throw new CatherdError("E_INPUT_INVALID", "pass NAME=value or NAME --from ENV_VAR, not both", {
72
+ fix: "catherd knowledge env set DOCKER_HOST=unix:///var/run/docker.sock",
73
+ });
74
+ if (args.from === undefined && !set)
75
+ throw new CatherdError("E_INPUT_INVALID", `"${args.variable}" has no value`, {
76
+ fix: "catherd knowledge env set NAME=value, or catherd knowledge env set NAME --from ENV_VAR",
77
+ });
78
+ const r = set
79
+ ? await setGateEnv(repoOf(args.repo), set.name, { value: set.value })
80
+ : await setGateEnv(repoOf(args.repo), args.variable, { from: args.from as string });
81
+ if (args.json) return printJson(r);
82
+ for (const line of gateEnvLines(r.vars)) console.log(line);
83
+ },
84
+ });
85
+
86
+ const envRm = defineCommand({
87
+ meta: { name: "rm", description: "Remove one variable from the repo's gate environment" },
88
+ args: { name: { type: "positional", required: true }, ...REPO_ARG, ...JSON_ARG },
89
+ async run({ args }) {
90
+ refuseInRole(process.env, "catherd knowledge env rm");
91
+ const r = await removeGateEnv(repoOf(args.repo), args.name);
92
+ if (args.json) return printJson(r);
93
+ for (const line of gateEnvLines(r.vars)) console.log(line);
94
+ },
95
+ });
96
+
97
+ const envList = defineCommand({
98
+ meta: { name: "list", description: "Print the repo's gate environment (a secret shows as $ENV_VAR)" },
99
+ args: { ...REPO_ARG, ...JSON_ARG },
100
+ async run({ args }) {
101
+ const at = await knowledgePath(repoOf(args.repo));
102
+ const vars = await readGateEnv(at.repo);
103
+ if (args.json) return printJson({ repo: at.repo, vars });
104
+ const lines = gateEnvLines(vars);
105
+ console.log(lines.length ? lines.join("\n") : "catherd: no gate environment set for this repo");
106
+ },
107
+ });
108
+
109
+ /** Plan 23: the repo's gate environment, beside its knowledge.md. */
110
+ const env = defineCommand({
111
+ meta: {
112
+ name: "env",
113
+ description: "The repo's gate environment, which the verifier and preflight run with: set, rm or list",
114
+ },
115
+ subCommands: { set: envSet, rm: envRm, list: envList },
116
+ });
117
+
46
118
  /** What past runs of a repo learned, which the read_knowledge tool gives every new run (spec §4.7). */
47
119
  export const knowledgeCommand = defineCommand({
48
- meta: { name: "knowledge", description: "What catherd knows about a repo: show, add or path" },
49
- subCommands: { show, add, path },
120
+ meta: {
121
+ name: "knowledge",
122
+ description: "What catherd knows about a repo: show, add or path, and its gate environment (env)",
123
+ },
124
+ subCommands: { show, add, path, env },
50
125
  });
@@ -1,10 +1,12 @@
1
+ import { HOST_ARG } from "./host-arg.ts";
1
2
  import { defineCommand } from "citty";
2
3
  import { CatherdError } from "../domain/errors.ts";
3
4
  import { scrubSecrets } from "../infra/env.ts";
4
5
  import { gitToplevel } from "../infra/git.ts";
5
- import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
6
+ import { heavySlots, withHeavySlot, withRoleLock } from "../infra/heavy-lock.ts";
7
+ import { activityReporter, DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
6
8
  import { killGroup } from "../infra/proc.ts";
7
- import { profileFor } from "../services/profile-store.ts";
9
+ import { activeName, readProfileDoc } from "../services/profile-store.ts";
8
10
  import { printError } from "./cli-kit.ts";
9
11
 
10
12
  /**
@@ -43,14 +45,26 @@ export const DOUBLE_INTERRUPT_MS = 2_000;
43
45
  * a terminal's Ctrl-C reaches catherd only, so the command sees it exactly once, and so does every
44
46
  * process it started. A second Ctrl-C within 2 s kills the group. Resolves to the command's exit code.
45
47
  */
46
- export async function runForwarding(argv: string[]): Promise<number> {
48
+ export async function runForwarding(
49
+ argv: string[],
50
+ o: { onOutput?: () => void; extraEnv?: Record<string, string> } = {},
51
+ ): Promise<number> {
52
+ // plan 23: inside a dispatch the output passes through catherd, which notes when the command last wrote
53
+ const piped = o.onOutput !== undefined;
47
54
  const child = Bun.spawn(argv, {
48
55
  stdin: "inherit",
49
- stdout: "inherit",
50
- stderr: "inherit",
51
- env: scrubSecrets(process.env),
56
+ stdout: piped ? "pipe" : "inherit",
57
+ stderr: piped ? "pipe" : "inherit",
58
+ env: { ...scrubSecrets(process.env), ...o.extraEnv },
52
59
  detached: true,
53
60
  });
61
+ const pumps =
62
+ piped && o.onOutput
63
+ ? [
64
+ pump(child.stdout as ReadableStream<Uint8Array>, process.stdout, o.onOutput),
65
+ pump(child.stderr as ReadableStream<Uint8Array>, process.stderr, o.onOutput),
66
+ ]
67
+ : [];
54
68
  let lastInt = 0;
55
69
  const handlers: [NodeJS.Signals, () => void][] = [
56
70
  [
@@ -71,6 +85,7 @@ export async function runForwarding(argv: string[]): Promise<number> {
71
85
  const watchdog = Bun.spawn(
72
86
  ["sh", "-c", 'read _ || kill -s KILL -- "-$1" 2>/dev/null', "sh", String(child.pid)],
73
87
  {
88
+ env: scrubSecrets(process.env),
74
89
  stdin: "pipe",
75
90
  stdout: "ignore",
76
91
  stderr: "ignore",
@@ -78,15 +93,53 @@ export async function runForwarding(argv: string[]): Promise<number> {
78
93
  },
79
94
  );
80
95
  try {
81
- return await child.exited;
96
+ const code = await child.exited;
97
+ // a process the command left behind may hold the pipes open: what it wrote by then is passed on
98
+ await Promise.race([Promise.all(pumps), Bun.sleep(PIPE_DRAIN_MS)]);
99
+ return code;
82
100
  } finally {
83
101
  watchdog.kill("SIGKILL");
84
102
  for (const [sig, h] of handlers) process.off(sig, h);
85
103
  }
86
104
  }
87
105
 
106
+ /** How long the pipes may stay open after the command exits. */
107
+ const PIPE_DRAIN_MS = 500;
108
+
109
+ /** Copies a piped stream to `out`, calling `tick` for each chunk; a broken pipe ends the copy. */
110
+ async function pump(
111
+ stream: ReadableStream<Uint8Array>,
112
+ out: NodeJS.WriteStream,
113
+ tick: () => void,
114
+ ): Promise<void> {
115
+ try {
116
+ for await (const chunk of stream) {
117
+ out.write(chunk);
118
+ tick();
119
+ }
120
+ } catch {
121
+ // the command's end closes the pipe
122
+ }
123
+ }
124
+
88
125
  /** Spec §8: what `--help` and the usage error show; the command runs after `--`. */
89
- export const LOCK_USAGE = "catherd lock [--slots N] -- <command> [args...]";
126
+ export const LOCK_USAGE = "catherd lock [--slots N] [--role verifier [--run <run>]] -- <command> [args...]";
127
+
128
+ /** The roles that take a machine-wide role lock besides their slot (spec 1.5 "Cross-run verifier contention"). */
129
+ export const ROLE_LOCKS = ["verifier"] as const;
130
+
131
+ /**
132
+ * Whose a role lock is: `--run`, else the run in `CATHERD_ROLE` (`<run>/<role-name>`, which the supervisor
133
+ * sets in every role's env), else this process alone. Commands of one run share the lock.
134
+ */
135
+ export function roleLockOwnerOf(run: string | undefined, env: NodeJS.ProcessEnv): string {
136
+ if (run) return `run:${run}`;
137
+ const fromRole = env.CATHERD_ROLE?.split("/")[0];
138
+ return fromRole ? `run:${fromRole}` : `process:${process.pid}`;
139
+ }
140
+
141
+ /** Set in a locked command's env: a `catherd lock` inside it runs at once, holding nothing more. */
142
+ export const LOCK_HELD_ENV = "CATHERD_LOCK_HELD";
90
143
 
91
144
  export const lockCommand = defineCommand({
92
145
  meta: {
@@ -95,10 +148,20 @@ export const lockCommand = defineCommand({
95
148
  "Run a heavy command behind the machine-wide semaphore, in its own process group and session: it gets no /dev/tty and no job control",
96
149
  },
97
150
  args: {
151
+ ...HOST_ARG,
98
152
  slots: {
99
153
  type: "string",
100
154
  description: "Slots (default: CATHERD_LOCK_SLOTS, else the profile's lock.heavy, else half the cores)",
101
155
  },
156
+ role: {
157
+ type: "string",
158
+ description:
159
+ "verifier: also hold the machine-wide verifier lock, shared by one run's commands, so two runs' verifiers never overlap",
160
+ },
161
+ run: {
162
+ type: "string",
163
+ description: "with --role: the run whose commands share it (default: CATHERD_ROLE's)",
164
+ },
102
165
  },
103
166
  async run({ args, rawArgs }) {
104
167
  const sep = rawArgs.indexOf("--");
@@ -112,12 +175,50 @@ export const lockCommand = defineCommand({
112
175
  process.exitCode = 2;
113
176
  return;
114
177
  }
178
+ if (args.role !== undefined && !(ROLE_LOCKS as readonly string[]).includes(args.role)) {
179
+ printError(
180
+ new CatherdError("E_INPUT_INVALID", `no role lock "${args.role}"`, {
181
+ fix: "catherd lock --role verifier -- <command>",
182
+ }),
183
+ );
184
+ process.exitCode = 2;
185
+ return;
186
+ }
187
+ // already inside a locked command: run at once, so a nested lock never waits on its own parent's slot
188
+ if (process.env[LOCK_HELD_ENV] === "1") {
189
+ process.exitCode = await runForwarding(argv);
190
+ return;
191
+ }
192
+ // plan 23: a role's lock reports its output, so the role's wall timeout counts from its last line; it
193
+ // reports while it waits for the role lock or a slot too, so a gate queued behind another run's is not
194
+ // killed before its first command
195
+ const activity = activityReporter(process.env[DISPATCH_ID_ENV]);
196
+ const waiting = activity ? { onWait: activity.tick } : {};
197
+ const run = () =>
198
+ runForwarding(argv, {
199
+ extraEnv: { [LOCK_HELD_ENV]: "1" },
200
+ ...(activity ? { onOutput: activity.tick } : {}),
201
+ });
202
+ const role = args.role;
115
203
  const repo = await gitToplevel(process.cwd());
116
204
  const slots = resolveSlots(
117
205
  args.slots,
118
- () => profileFor(repo).lock.heavy,
206
+ // lock.heavy is host-independent: read it stored, so a worker (whose host identity is scrubbed) and an
207
+ // unknown terminal host still get the profile's value instead of the cpus/2 fallback
208
+ () => readProfileDoc(activeName(repo)).lock?.heavy ?? "cpus/2",
119
209
  (m) => console.error(m),
120
210
  );
121
- process.exitCode = await withHeavySlot(slots, () => runForwarding(argv));
211
+ try {
212
+ process.exitCode = role
213
+ ? await withRoleLock(
214
+ role,
215
+ roleLockOwnerOf(args.run, process.env),
216
+ () => withHeavySlot(slots, run, waiting),
217
+ waiting,
218
+ )
219
+ : await withHeavySlot(slots, run, waiting);
220
+ } finally {
221
+ activity?.done();
222
+ }
122
223
  },
123
224
  });
@@ -12,13 +12,13 @@ export function registerDispatchTools(server: McpServer, deps: Deps): void {
12
12
  "dispatch",
13
13
  {
14
14
  description:
15
- "Start one role on a process backend (codex, claude-code, opencode) and return at launch, in about a second, with { dispatched: { name, role, rung, dispatchId, admittedAt }, hints }; the role runs on. When it finishes, catherd sends this session a <cross-session-message from-name=\"catherd\"> naming the run, the role and its status, and result(run, name) reads the record. Dispatch every independent role one after another, then end your turn. The brief is the text itself; dispatch appends the role's reply contract (reply length and the STATUS line), so do not write it. With lane, the lane file's Owns: paths guard against overlapping lanes, and a lane not yet routed is routed first (a rung off its routed ladder starts at the routed rung, with a hint). thread resumes that role's thread for its own fix round. Refused with a structured error (E_ADMIT_*, E_RUN_BUDGET, E_BACKEND_*) before anything starts. Call it from the main thread.",
15
+ "Start one role on a process backend (codex, claude-code, opencode) and return at launch, in about a second, with { dispatched: { name, role, rung, dispatchId, admittedAt }, hints }; the role runs on. When it finishes, catherd sends this session a <cross-session-message from-name=\"catherd\"> naming the run, the role and its status, and result(run, name) reads the record. Dispatch every independent role one after another, then end your turn. The brief is the text itself; dispatch appends the role's reply contract (reply length and the STATUS line), so do not write it. With lane, the lane file's Owns: paths guard against overlapping lanes, a lane not yet routed is routed first (a rung off its routed ladder starts at the routed rung, with a hint), and rung may be left out: the lane runs at its current rung (its route, or the rung its last climb gave). Without lane, rung is required (route(run, role) gives it), and the choice is recorded in routes.jsonl. thread resumes that role's thread for its own fix round: the thread its catherd message names, or \"latest\" for the name's last thread; a thread the name never ran on in this run is refused (E_ADMIT_THREAD). Refused with a structured error (E_ADMIT_*, E_RUN_BUDGET, E_BACKEND_*) before anything starts. Call it from the main thread.",
16
16
  inputSchema: {
17
17
  run: z.string(),
18
18
  role: z.enum(ROLES),
19
19
  name: z.string().regex(ID_PATTERN),
20
20
  brief: z.string().min(1),
21
- rung: z.string().min(3),
21
+ rung: z.string().min(3).optional(),
22
22
  thread: z.string().optional(),
23
23
  lane: z.string().regex(ID_PATTERN).optional(),
24
24
  next: z.string().optional(),
@@ -31,7 +31,7 @@ export function registerDispatchTools(server: McpServer, deps: Deps): void {
31
31
  "peek",
32
32
  {
33
33
  description:
34
- "A look at the runs, without waiting: with run, that run (and this session becomes its owner); without, every run this session owns, else the newest. Per run, first the open owner questions, which are the owner's questions: push them to the owner, relay the owner's answer with answer(run, milestone, answer), and go on with the work that does not depend on them. Then each live role with its rung, seconds since it started and its last event (the last command, file edit or message line); every finished record not yet read, as the first line of catherd's message; the latest native Claude run; the run's next step; protocol, the milestone loop's next step and its six-line checklist; and verifier, the verifier's latest gate step. name narrows it to one role. It never marks a record read: result(run, name) does. Call it when the user asks how it is going, when a decision needs the other roles' state, or once after run_start on a resumed run; never in a loop.",
34
+ "A look at the runs, without waiting: with run, that run (and this session becomes its owner); without, every run this session owns, else the newest. Per run, first the open owner questions, which are the owner's questions: push them to the owner, relay the owner's answer with answer(run, milestone, answer), and go on with the work that does not depend on them. Then each live role with its rung, seconds since it started and its last event (the last command, file edit or message line); every finished record not yet read, as the first line of catherd's message; the latest native Claude run; the run's next step; protocol, the milestone loop's next step and its six-line checklist; and verifier, the verifier's latest gate step. name narrows it to one role. It never marks a record read: result(run, name) does. Call it when the user asks how it is going, when a decision needs the other roles' state, or once after run_start on a resumed run; never in a loop. actionable is false, with its reason, when nothing is yours to do (only roles are live on the step, or the owner's answer comes first): end the turn with no tool call, and the next catherd message wakes you.",
35
35
  inputSchema: {
36
36
  run: z.string().optional(),
37
37
  name: z.string().regex(ID_PATTERN).optional(),
@@ -12,10 +12,16 @@ export const LAUNCHER = fileURLToPath(new URL("../../../plugin/bin/catherd-mcp",
12
12
  const TIMEOUT_MS = 60_000;
13
13
  const STDERR_TAIL = 4_000;
14
14
 
15
- /** The env the doctor's MCP server starts with: catherd's own secrets scrubbed, as for every process it starts. */
15
+ /**
16
+ * The env the doctor's MCP server starts with: catherd's own secrets scrubbed, as for every process it starts,
17
+ * and no boot sync (1.2 minor): the server lives for one tools/list, so a sync there spends fetches (AA's
18
+ * included) and records nothing.
19
+ */
16
20
  export const handshakeEnv = (
17
21
  base: Record<string, string | undefined> = process.env,
18
- ): Record<string, string> => scrubSecrets(base);
22
+ ): Record<string, string> => {
23
+ return { ...scrubSecrets(base), CATHERD_NO_SYNC: "1" };
24
+ };
19
25
 
20
26
  /**
21
27
  * Spec §10.3 and 1.1 §12: starts the MCP server the way the plugin does, `sh <launcher>` over stdio, and
@@ -2,8 +2,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { ID_PATTERN } from "../../domain/ids.ts";
4
4
  import { ROLES } from "../../domain/roles.ts";
5
+ import { CatherdError } from "../../domain/errors.ts";
5
6
  import { CLIMB_REASONS } from "../../domain/route.ts";
6
- import { ask, climb, LAND_SKIPS, land, route } from "../../services/lane-service.ts";
7
+ import { LANE_FIELDS, type LaneField, laneSet, ownsAdd } from "../../services/lane-edit.ts";
8
+ import { ask, climb, LAND_SKIPS, land, route, routeLanes } from "../../services/lane-service.ts";
7
9
  import type { Deps } from "../../services/ports.ts";
8
10
  import { preflight } from "../../services/preflight.ts";
9
11
  import { handle } from "./result.ts";
@@ -13,31 +15,76 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
13
15
  "route",
14
16
  {
15
17
  description:
16
- "The rung for a lane (from Jev, else the lane file's Kind/Difficulty lines, else the profile default) or, without a lane file, a role's default rung, with the ladder above it. Rungs are backend:model#effort; a claude: rung comes with the agent to run it as. `provenance` says why: each threshold of the lane's bar with the value used against it, that value's confidence, source and date (`inferred: true` when a treat-like or a stand-in lent it), the rung's speed and cost facts, and catherd's own run evidence for it (shown, never used to route). A lane whose Kind: or Difficulty: the catalog does not know is refused with E_LANE_INVALID.",
18
+ "The rung for a lane (from the lane file's Kind/Difficulty lines when it declares both, else Jev, else the profile default), or with lanes (several lane files) for each of them in one call, Jev asked about all at once ({ routes: [...] }, in order), or, without a lane file, a role's default rung, with the ladder above it: only rungs at least as strong as the start. Rungs are backend:model#effort; a claude: rung comes with the agent to run it as. Returns rung, ladder, backend, agent and why: one line naming the decision, where Jev disagreed with the lane's header, when no rung clears the bar (and the closest), and when a tie between quotas decided. Every decision, with its provenance (each threshold, the value used, its source and date, the rung's cost and run evidence), is written to the run's routes.jsonl. A lane whose Kind: or Difficulty: the catalog does not know is refused with E_LANE_INVALID.",
17
19
  inputSchema: {
18
20
  run: z.string(),
19
21
  lane_file: z.string().optional(),
22
+ lanes: z.array(z.string()).min(1).optional(),
20
23
  role: z.enum(ROLES).default("worker"),
21
24
  },
22
25
  },
23
- (a) => handle(() => route(deps, { run: a.run, laneFile: a.lane_file, role: a.role })),
26
+ (a) =>
27
+ handle(async () => {
28
+ if (a.lanes && a.lane_file !== undefined)
29
+ throw new CatherdError("E_INPUT_INVALID", "route takes lane_file or lanes, not both", {
30
+ fix: "pass every lane file in lanes",
31
+ });
32
+ return a.lanes
33
+ ? { routes: await routeLanes(deps, { run: a.run, laneFiles: a.lanes, role: a.role }) }
34
+ : route(deps, { run: a.run, laneFile: a.lane_file, role: a.role });
35
+ }),
36
+ );
37
+
38
+ const lane = z.string().regex(ID_PATTERN);
39
+ server.registerTool(
40
+ "lane_set",
41
+ {
42
+ description:
43
+ "Set one header line of a lane file (owns, fast_check, kind, difficulty, after, allow), validated as write_run_file validates a lane: an unknown Kind or Difficulty, an Owns path outside the repo, or a malformed After:/Allow: is refused with E_LANE_INVALID, and an Owns that a running lane holds with E_ADMIT_OVERLAP. after names lanes that must finish first; allow lists path or path:line hits the lane's absence check may report. Returns the line and the header as it now reads. Orchestrator only.",
44
+ inputSchema: {
45
+ run: z.string(),
46
+ lane,
47
+ field: z.enum(Object.keys(LANE_FIELDS) as [LaneField, ...LaneField[]]),
48
+ value: z.string(),
49
+ },
50
+ },
51
+ (a) => handle(() => laneSet(deps, a)),
52
+ );
53
+
54
+ server.registerTool(
55
+ "owns_add",
56
+ {
57
+ description:
58
+ "Grow a lane's Owns mid-lane, with why (kept in the lane file): a running lane that owns one of the paths refuses it with E_ADMIT_OVERLAP; another lane file that lists one comes back as a hint. A dispatch of the lane still running is held to the new list when it finishes, so its edits there are not violations. Orchestrator only.",
59
+ inputSchema: {
60
+ run: z.string(),
61
+ lane,
62
+ paths: z.array(z.string().min(1)).min(1).max(100),
63
+ why: z.string().min(1),
64
+ },
65
+ },
66
+ (a) => handle(() => ownsAdd(deps, a)),
24
67
  );
25
68
 
26
69
  server.registerTool(
27
70
  "preflight",
28
71
  {
29
72
  description:
30
- "Run each lane's fast check once, on the base tree, behind the heavy-command lock, with a 120 s timeout. Each lane is pass, fails-as-expected, skipped (it checks a file the lane creates) or cannot-start; only cannot-start blocks. When the profile asks for confirmation, the first call returns the commands to show the user; call again with confirmed: true. Refused with E_LANE_INVALID, running nothing, while any lane's Kind: or Difficulty: is not one the catalog knows.",
31
- inputSchema: { run: z.string(), confirmed: z.boolean().optional() },
73
+ "Run each lane's fast check once, on the base tree, behind the heavy-command lock, with a 120 s timeout, in the user's login environment plus the repo's gate environment (catherd knowledge env). By default only the lanes of milestones not landed yet; milestone names one. Each lane is pass, fails-as-expected, skipped (it checks a file the lane creates, or a pnpm filter that matches no package yet), cannot-start (a missing command, a timeout, or an environment error: no Docker, DNS, a denied permission; its note says which) or lock-busy (no heavy slot came free within 60 s: run it again); only cannot-start blocks. warnings names each lane whose fast check runs no linter while the repo has one. When the profile asks for confirmation, the first call returns the commands to show the user; call again with confirmed: true. Refused with E_LANE_INVALID, running nothing, while any lane's Kind: or Difficulty: is not one the catalog knows.",
74
+ inputSchema: {
75
+ run: z.string(),
76
+ confirmed: z.boolean().optional(),
77
+ milestone: z.string().regex(ID_PATTERN).optional(),
78
+ },
32
79
  },
33
- (a) => handle(() => preflight(deps, { run: a.run, confirmed: a.confirmed })),
80
+ (a) => handle(() => preflight(deps, { run: a.run, confirmed: a.confirmed, milestone: a.milestone })),
34
81
  );
35
82
 
36
83
  server.registerTool(
37
84
  "climb",
38
85
  {
39
86
  description:
40
- "Move a routed lane one rung up its ladder and record why. Returns the next rung, or top: true, and any hints. Dispatch the lane again at that rung on a fresh thread. Pass env: true when the environment caused it (a missing service, a broken tool, a usage limit), not the rung. Refused with E_CLIMB_DESIGN when the evidence is a design question (Jev's finding answer is design, or a blocked climb's evidence is about lane ownership): send it to the architect instead.",
87
+ "Move a routed lane one rung up its ladder and record why. Returns the next rung, or top: true, and any hints. Dispatch the lane again at that rung on a fresh thread. Pass env: true when the environment caused it (a missing service, a broken tool, a usage limit), not the rung. Refused with E_CLIMB_DESIGN when the evidence is a design question (Jev's finding answer is design, or a blocked climb's evidence is about lane ownership): send it to the architect instead. Refused with E_CLIMB_ENV when the lane's last reply named the environment on an ENV: line: fix the environment or park the milestone.",
41
88
  inputSchema: {
42
89
  run: z.string(),
43
90
  lane: z.string().regex(ID_PATTERN),
@@ -67,7 +114,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
67
114
  "land",
68
115
  {
69
116
  description:
70
- "Record a landed milestone after you commit it: its five-column ledger row (with the minutes it took) and state.md's last check and next step, with any hints. learned, when given, goes to this repo's knowledge.md. Returns digest: the milestone's digest (R/digests/<milestone>.md: A-lines, commit, lanes with rungs and climbs, reviewer findings, the verifier's verdict with carried items, minutes and tokens), for the milestone push to link. Refused with E_LAND_GATE unless, since the milestone's lanes started, a reviewer named reviewer-<milestone> ended ok (a dispatch, or a Claude subagent recorded with record_agent_run, role reviewer) and a verifier verdict naming the milestone was recorded ok (record_agent_run, role verifier; a headless verifier's reply opening VERDICT: PASS). A skip over an empty commit range is refused: commit first. skip: docs-only lands a milestone whose commit range changed only docs; skip: no-code one that changed no source file (put the evidence, e.g. a green pipeline, in evidence).",
117
+ "Record a landed milestone after you commit it: its five-column ledger row (with the minutes it took) and state.md's last check and next step, with any hints. learned, when given, goes to this repo's knowledge.md. Returns digest: the milestone's digest (R/digests/<milestone>.md: A-lines, the commits, lanes with rungs and climbs, the reviewer's BLOCKER and BUG lines, the owner questions still open, the verifier's verdict with carried items, minutes and tokens), and digestPath, its full path, for the milestone push to link. Refused with E_LAND_GATE unless, since the milestone's lanes started, a reviewer named reviewer-<milestone> ended ok (a dispatch, or a Claude subagent recorded with record_agent_run, role reviewer) and a verifier verdict was recorded ok under exactly verifier-<milestone> (record_agent_run, role verifier; a headless verifier's reply opening VERDICT: PASS; verifier-M1-pre does not count). minutes leave out the time the milestone was parked and any machine or workspace pause. A skip over an empty commit range is refused: commit first. skip: docs-only lands a milestone whose commit range changed only docs; skip: no-code one that changed no source file (put the evidence, e.g. a green pipeline, in evidence).",
71
118
  inputSchema: {
72
119
  run: z.string(),
73
120
  milestone: z.string().regex(ID_PATTERN),
@@ -1,7 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { ID_PATTERN } from "../../domain/ids.ts";
4
- import { gateCheck, gatePass } from "../../services/gate-service.ts";
4
+ import { gateCheckOrList, gatePass } from "../../services/gate-service.ts";
5
5
  import { answer, park } from "../../services/questions.ts";
6
6
  import type { Deps } from "../../services/ports.ts";
7
7
  import { handle } from "./result.ts";
@@ -19,10 +19,16 @@ export function registerProtocolTools(server: McpServer, deps: Deps): void {
19
19
  "gate_check",
20
20
  {
21
21
  description:
22
- "The verifier, before running a gate item: { carried: true, passedAt, commit } when this repo already has a pass of the same command on the same content of paths (repo-relative; . for the whole repo: HEAD plus uncommitted not-ignored changes; a git-ignored input such as .env or a build output is hashed only when named as a path), so it reports the item as carried over from that commit instead of running it; else { carried: false }, and it runs the item. Either way the call is recorded as the verifier's current step, which status and peek show. Pass milestone (the M the verifier checks) so the milestone's digest lists only its own carried items.",
23
- inputSchema: { ...gate, milestone: z.string().regex(ID_PATTERN).optional() },
22
+ "The verifier, before running a gate item: { carried: true, passedAt, commit } when this repo already has a pass of the same command on the same content of paths (repo-relative; . for the whole repo: the tracked files plus uncommitted not-ignored changes; the repo's lockfiles always count, so never name node_modules; a git-ignored input such as .env or a build output is hashed only when named as a path, and as absent while it is not built), so it reports the item as carried over from that commit instead of running it; else { carried: false }, and it runs the item. Either way the call is recorded as the verifier's current step, which status and peek show. Pass milestone (the M the verifier checks): the milestone's digest then lists only its own carried items, and the answer adds recorded, the items already checked for that milestone in this run, each with its command and whether it passed. Call it first with only run and milestone to get that list without checking anything: reuse those item names, and re-check the failed ones first.",
23
+ inputSchema: {
24
+ run: z.string(),
25
+ item: gate.item.optional(),
26
+ command: gate.command.optional(),
27
+ paths: gate.paths.optional(),
28
+ milestone: z.string().regex(ID_PATTERN).optional(),
29
+ },
24
30
  },
25
- (a) => handle(() => gateCheck(deps, a)),
31
+ (a) => handle(() => gateCheckOrList(deps, a)),
26
32
  );
27
33
 
28
34
  server.registerTool(
@@ -0,0 +1,16 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { probePush } from "../../services/doctor-push.ts";
3
+ import type { Deps } from "../../services/ports.ts";
4
+ import { handle } from "./result.ts";
5
+
6
+ export function registerPushTools(server: McpServer, deps: Deps): void {
7
+ server.registerTool(
8
+ "test_push",
9
+ {
10
+ description:
11
+ "Send this session one labeled smoke message through catherd's push (Claude Code's peer inbox, or the native Codex queue for this thread) and return the receipt: { outcome, enqueue, processing, msgId, detail }. A receipt proves the host accepted it, never that the model read it. For a check from inside a session, where a shell cannot name the Codex thread; catherd doctor --test-push --thread <uuid> is the shell's form.",
12
+ inputSchema: {},
13
+ },
14
+ () => handle(() => probePush(deps.host, process.env)),
15
+ );
16
+ }