catherd-cli 0.2.1 → 1.1.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 (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,123 @@
1
+ import { defineCommand } from "citty";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { scrubSecrets } from "../infra/env.ts";
4
+ import { gitToplevel } from "../infra/git.ts";
5
+ import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
6
+ import { killGroup } from "../infra/proc.ts";
7
+ import { profileFor } from "../services/profile-store.ts";
8
+ import { printError } from "./cli-kit.ts";
9
+
10
+ /**
11
+ * --slots, then CATHERD_LOCK_SLOTS, then the profile's lock.heavy, then half the cores. `warn` hears why
12
+ * the profile could not say, so the fallback is never silent (plan-2 review m10).
13
+ */
14
+ export function resolveSlots(
15
+ flag: string | undefined,
16
+ profile: () => number | "cpus/2",
17
+ warn: (message: string) => void = () => {},
18
+ ): number {
19
+ const raw = flag ?? process.env.CATHERD_LOCK_SLOTS;
20
+ if (raw) {
21
+ if (raw === "cpus/2") return heavySlots("cpus/2");
22
+ const n = Number(raw);
23
+ if (!Number.isFinite(n) || n < 1)
24
+ throw new CatherdError("E_INPUT_INVALID", `slots must be a number ≥ 1 or cpus/2, not "${raw}"`, {
25
+ fix: "catherd lock --slots 2 -- <command>",
26
+ });
27
+ return heavySlots(n);
28
+ }
29
+ try {
30
+ return heavySlots(profile());
31
+ } catch (e) {
32
+ const slots = heavySlots("cpus/2");
33
+ warn(`catherd lock: no profile to read lock.heavy from (${(e as Error).message}); using ${slots} slots`);
34
+ return slots;
35
+ }
36
+ }
37
+
38
+ /** How long a second Ctrl-C has to follow the first to kill the command outright. */
39
+ export const DOUBLE_INTERRUPT_MS = 2_000;
40
+
41
+ /**
42
+ * Runs `argv` in its own process group and forwards SIGINT, SIGTERM and SIGHUP to the whole group once:
43
+ * a terminal's Ctrl-C reaches catherd only, so the command sees it exactly once, and so does every
44
+ * process it started. A second Ctrl-C within 2 s kills the group. Resolves to the command's exit code.
45
+ */
46
+ export async function runForwarding(argv: string[]): Promise<number> {
47
+ const child = Bun.spawn(argv, {
48
+ stdin: "inherit",
49
+ stdout: "inherit",
50
+ stderr: "inherit",
51
+ env: scrubSecrets(process.env),
52
+ detached: true,
53
+ });
54
+ let lastInt = 0;
55
+ const handlers: [NodeJS.Signals, () => void][] = [
56
+ [
57
+ "SIGINT",
58
+ () => {
59
+ const now = Date.now();
60
+ killGroup(child.pid, now - lastInt < DOUBLE_INTERRUPT_MS ? "SIGKILL" : "SIGINT");
61
+ lastInt = now;
62
+ },
63
+ ],
64
+ ["SIGTERM", () => killGroup(child.pid, "SIGTERM")],
65
+ ["SIGHUP", () => killGroup(child.pid, "SIGHUP")],
66
+ ];
67
+ for (const [sig, h] of handlers) process.on(sig, h);
68
+ // The command's own session is out of reach of a signal to catherd's group, and a SIGKILL (a
69
+ // supervisor's escalation) cannot be forwarded: the watchdog reads EOF when catherd dies, however it
70
+ // dies, and kills the command's group then.
71
+ const watchdog = Bun.spawn(
72
+ ["sh", "-c", 'read _ || kill -s KILL -- "-$1" 2>/dev/null', "sh", String(child.pid)],
73
+ {
74
+ stdin: "pipe",
75
+ stdout: "ignore",
76
+ stderr: "ignore",
77
+ detached: true,
78
+ },
79
+ );
80
+ try {
81
+ return await child.exited;
82
+ } finally {
83
+ watchdog.kill("SIGKILL");
84
+ for (const [sig, h] of handlers) process.off(sig, h);
85
+ }
86
+ }
87
+
88
+ /** Spec §8: what `--help` and the usage error show; the command runs after `--`. */
89
+ export const LOCK_USAGE = "catherd lock [--slots N] -- <command> [args...]";
90
+
91
+ export const lockCommand = defineCommand({
92
+ meta: {
93
+ name: "lock",
94
+ description:
95
+ "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
+ },
97
+ args: {
98
+ slots: {
99
+ type: "string",
100
+ description: "Slots (default: CATHERD_LOCK_SLOTS, else the profile's lock.heavy, else half the cores)",
101
+ },
102
+ },
103
+ async run({ args, rawArgs }) {
104
+ const sep = rawArgs.indexOf("--");
105
+ const argv = sep < 0 ? [] : rawArgs.slice(sep + 1);
106
+ if (argv.length === 0) {
107
+ printError(
108
+ new CatherdError("E_INPUT_INVALID", "no command to run", {
109
+ fix: LOCK_USAGE,
110
+ }),
111
+ );
112
+ process.exitCode = 2;
113
+ return;
114
+ }
115
+ const repo = await gitToplevel(process.cwd());
116
+ const slots = resolveSlots(
117
+ args.slots,
118
+ () => profileFor(repo).lock.heavy,
119
+ (m) => console.error(m),
120
+ );
121
+ process.exitCode = await withHeavySlot(slots, () => runForwarding(argv));
122
+ },
123
+ });
@@ -1,7 +1,11 @@
1
1
  import { defineCommand } from "citty";
2
+ import { restoreTmpdir } from "../../infra/env.ts";
2
3
  import { startMcpServer } from "./server.ts";
3
4
 
4
5
  export const mcpCommand = defineCommand({
5
6
  meta: { name: "mcp", description: "Run the catherd MCP server over stdio" },
6
- run: () => startMcpServer(),
7
+ run: () => {
8
+ restoreTmpdir(process.env);
9
+ return startMcpServer();
10
+ },
7
11
  });
@@ -0,0 +1,52 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ID_PATTERN } from "../../domain/ids.ts";
4
+ import { ROLES } from "../../domain/roles.ts";
5
+ import { cancel, dispatch } from "../../services/dispatch-service.ts";
6
+ import { peek } from "../../services/peek.ts";
7
+ import type { Deps } from "../../services/ports.ts";
8
+ import { handle } from "./result.ts";
9
+
10
+ export function registerDispatchTools(server: McpServer, deps: Deps): void {
11
+ server.registerTool(
12
+ "dispatch",
13
+ {
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.",
16
+ inputSchema: {
17
+ run: z.string(),
18
+ role: z.enum(ROLES),
19
+ name: z.string().regex(ID_PATTERN),
20
+ brief: z.string().min(1),
21
+ rung: z.string().min(3),
22
+ thread: z.string().optional(),
23
+ lane: z.string().regex(ID_PATTERN).optional(),
24
+ next: z.string().optional(),
25
+ },
26
+ },
27
+ (a) => handle(() => dispatch(deps, a)),
28
+ );
29
+
30
+ server.registerTool(
31
+ "peek",
32
+ {
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.",
35
+ inputSchema: {
36
+ run: z.string().optional(),
37
+ name: z.string().regex(ID_PATTERN).optional(),
38
+ },
39
+ },
40
+ (a) => handle(() => peek(deps, a)),
41
+ );
42
+
43
+ server.registerTool(
44
+ "cancel",
45
+ {
46
+ description:
47
+ "Stop a live dispatch (interrupt, then SIGTERM, then SIGKILL) and return its record, marked cancelled and read, and hints.",
48
+ inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
49
+ },
50
+ (a) => handle(() => cancel(deps, a.run, a.name, { read: true })),
51
+ );
52
+ }
@@ -0,0 +1,58 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import { errorMessage } from "../../domain/errors.ts";
5
+ import { scrubSecrets } from "../../infra/env.ts";
6
+ import { VERSION } from "../../infra/version.ts";
7
+ import type { Handshake } from "../../services/doctor.ts";
8
+
9
+ /** The plugin's MCP launcher, shipped in the package beside src/ (spec 1.1 §12). */
10
+ export const LAUNCHER = fileURLToPath(new URL("../../../plugin/bin/catherd-mcp", import.meta.url));
11
+ /** A cold `bunx catherd-cli@<version>` resolve took about 30 s in the 1.0.0 fresh install. */
12
+ const TIMEOUT_MS = 60_000;
13
+ const STDERR_TAIL = 4_000;
14
+
15
+ /** The env the doctor's MCP server starts with: catherd's own secrets scrubbed, as for every process it starts. */
16
+ export const handshakeEnv = (
17
+ base: Record<string, string | undefined> = process.env,
18
+ ): Record<string, string> => scrubSecrets(base);
19
+
20
+ /**
21
+ * Spec §10.3 and 1.1 §12: starts the MCP server the way the plugin does, `sh <launcher>` over stdio, and
22
+ * asks it for tools/list. What it writes to stderr comes back too, so doctor can say what broke.
23
+ */
24
+ export async function mcpHandshake(o: { launcher?: string } = {}): Promise<Handshake> {
25
+ const client = new Client({ name: "catherd-doctor", version: VERSION });
26
+ const transport = new StdioClientTransport({
27
+ command: "sh",
28
+ args: [o.launcher ?? LAUNCHER],
29
+ env: handshakeEnv(),
30
+ stderr: "pipe",
31
+ });
32
+ let stderr = "";
33
+ transport.stderr?.on("data", (b: Buffer) => {
34
+ stderr = (stderr + b.toString()).slice(-STDERR_TAIL);
35
+ });
36
+ let timer: ReturnType<typeof setTimeout> | undefined;
37
+ const late = new Promise<Handshake>((resolve) => {
38
+ timer = setTimeout(
39
+ () => resolve({ ok: false, tools: [], error: `no answer within ${TIMEOUT_MS / 1000} s`, stderr }),
40
+ TIMEOUT_MS,
41
+ );
42
+ });
43
+ try {
44
+ return await Promise.race([
45
+ (async (): Promise<Handshake> => {
46
+ await client.connect(transport);
47
+ const r = await client.listTools();
48
+ return { ok: true, tools: r.tools.map((t) => t.name) };
49
+ })(),
50
+ late,
51
+ ]);
52
+ } catch (e) {
53
+ return { ok: false, tools: [], error: errorMessage(e), stderr };
54
+ } finally {
55
+ clearTimeout(timer);
56
+ await client.close().catch(() => {}); // the answer (or the failure) is already in hand
57
+ }
58
+ }
@@ -0,0 +1,84 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ID_PATTERN } from "../../domain/ids.ts";
4
+ import { ROLES } from "../../domain/roles.ts";
5
+ import { CLIMB_REASONS } from "../../domain/route.ts";
6
+ import { ask, climb, LAND_SKIPS, land, route } from "../../services/lane-service.ts";
7
+ import type { Deps } from "../../services/ports.ts";
8
+ import { preflight } from "../../services/preflight.ts";
9
+ import { handle } from "./result.ts";
10
+
11
+ export function registerLaneTools(server: McpServer, deps: Deps): void {
12
+ server.registerTool(
13
+ "route",
14
+ {
15
+ 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. A lane whose Kind: or Difficulty: the catalog does not know is refused with E_LANE_INVALID.",
17
+ inputSchema: {
18
+ run: z.string(),
19
+ lane_file: z.string().optional(),
20
+ role: z.enum(ROLES).default("worker"),
21
+ },
22
+ },
23
+ (a) => handle(() => route(deps, { run: a.run, laneFile: a.lane_file, role: a.role })),
24
+ );
25
+
26
+ server.registerTool(
27
+ "preflight",
28
+ {
29
+ 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() },
32
+ },
33
+ (a) => handle(() => preflight(deps, { run: a.run, confirmed: a.confirmed })),
34
+ );
35
+
36
+ server.registerTool(
37
+ "climb",
38
+ {
39
+ 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.",
41
+ inputSchema: {
42
+ run: z.string(),
43
+ lane: z.string().regex(ID_PATTERN),
44
+ reason: z.enum(CLIMB_REASONS),
45
+ evidence: z.string().optional(),
46
+ env: z.boolean().optional(),
47
+ },
48
+ },
49
+ (a) => handle(() => climb(deps, a)),
50
+ );
51
+
52
+ server.registerTool(
53
+ "ask",
54
+ {
55
+ description:
56
+ "Ask Jev one fixed question. finding: state { lane_file, finding } → design | code | unclear. same-defect: state { before, after } → yes | no. Falls back to the default when Jev is unsure or absent. Keep the state short, in English, and free of secrets.",
57
+ inputSchema: {
58
+ run: z.string(),
59
+ question: z.enum(["finding", "same-defect"]),
60
+ state: z.record(z.string(), z.string()),
61
+ },
62
+ },
63
+ (a) => handle(() => ask(deps, a)),
64
+ );
65
+
66
+ server.registerTool(
67
+ "land",
68
+ {
69
+ 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).",
71
+ inputSchema: {
72
+ run: z.string(),
73
+ milestone: z.string().regex(ID_PATTERN),
74
+ what: z.string().min(1),
75
+ commit: z.string().regex(/^[0-9a-f]{7,40}$/),
76
+ evidence: z.string().min(1),
77
+ next: z.string().min(1),
78
+ learned: z.string().min(1).optional(),
79
+ skip: z.enum(LAND_SKIPS).optional(),
80
+ },
81
+ },
82
+ (a) => handle(() => land(deps, a)),
83
+ );
84
+ }
@@ -0,0 +1,57 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ID_PATTERN } from "../../domain/ids.ts";
4
+ import { gateCheck, gatePass } from "../../services/gate-service.ts";
5
+ import { answer, park } from "../../services/questions.ts";
6
+ import type { Deps } from "../../services/ports.ts";
7
+ import { handle } from "./result.ts";
8
+
9
+ // Spec 1.1 §7 and §8: the tools the enforced protocol adds.
10
+ export function registerProtocolTools(server: McpServer, deps: Deps): void {
11
+ const gate = {
12
+ run: z.string(),
13
+ item: z.string().min(1),
14
+ command: z.string().min(1),
15
+ paths: z.array(z.string().min(1)).min(1),
16
+ };
17
+
18
+ server.registerTool(
19
+ "gate_check",
20
+ {
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() },
24
+ },
25
+ (a) => handle(() => gateCheck(deps, a)),
26
+ );
27
+
28
+ server.registerTool(
29
+ "gate_pass",
30
+ {
31
+ description:
32
+ "The verifier, after a gate item passed: records the command, the content hash of paths and the commit, with the evidence (a log path or the decisive line), so a later gate_check on unchanged content carries it over.",
33
+ inputSchema: { ...gate, evidence: z.string().min(1) },
34
+ },
35
+ (a) => handle(() => gatePass(deps, a)),
36
+ );
37
+
38
+ server.registerTool(
39
+ "park",
40
+ {
41
+ description:
42
+ "An owner question only the user can answer: parks the milestone (land refuses it until answered) and puts it in front of state.md's next step, instead of stopping the run. Returns the parked milestones and hints: push the full question to the user with PushNotification, and go on with the milestones and runs that do not depend on it.",
43
+ inputSchema: { run: z.string(), milestone: z.string().regex(ID_PATTERN), question: z.string().min(1) },
44
+ },
45
+ (a) => handle(() => park(deps, a)),
46
+ );
47
+
48
+ server.registerTool(
49
+ "answer",
50
+ {
51
+ description:
52
+ "The owner answered a parked milestone's question in this session: records the answer and unparks the milestone. status, peek and run_start list the questions still open.",
53
+ inputSchema: { run: z.string(), milestone: z.string().regex(ID_PATTERN), answer: z.string().min(1) },
54
+ },
55
+ (a) => handle(() => answer(deps, a)),
56
+ );
57
+ }
@@ -0,0 +1,51 @@
1
+ import { type CallToolResult, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
2
+ import { CatherdError, errorMessage, isCatherdError } from "../../domain/errors.ts";
3
+
4
+ /** A service's value as a tool result; its `hints`, when it has any, pass through as they are. */
5
+ const ok = (v: unknown): CallToolResult => ({
6
+ content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) }],
7
+ });
8
+
9
+ /** Spec §4.8, §10.1: every failure is a structured tool error `{ code, message, fix }`. */
10
+ function fail(e: unknown): CallToolResult {
11
+ const err = isCatherdError(e)
12
+ ? { fix: "", ...e.toJSON() }
13
+ : {
14
+ code: "E_IO_UNEXPECTED",
15
+ message: errorMessage(e),
16
+ fix: "this is a catherd bug: report it with the message",
17
+ };
18
+ return { isError: true, content: [{ type: "text", text: JSON.stringify(err) }], structuredContent: err };
19
+ }
20
+
21
+ /** Runs one service call and turns its value, or its error, into a tool result. */
22
+ export async function handle(f: () => unknown): Promise<CallToolResult> {
23
+ try {
24
+ return ok(await f());
25
+ } catch (e) {
26
+ return fail(e);
27
+ }
28
+ }
29
+
30
+ const UNKNOWN_TOOL = /Tool (\S+) (?:not found|disabled)$/;
31
+
32
+ /** The tool an SDK error message names ("Tool x not found", "… arguments for tool x: …"), else null. */
33
+ export const toolOf = (message: string): string | null =>
34
+ (UNKNOWN_TOOL.exec(message) ?? /for tool (\S+?):/.exec(message))?.[1] ?? null;
35
+
36
+ /**
37
+ * The SDK's own tool errors, raised before `handle` runs, in catherd's shape (spec §4.8): a call to a tool
38
+ * this server does not have, or input a tool's schema rejects, is E_INPUT_INVALID; anything else is
39
+ * unexpected.
40
+ */
41
+ export function sdkToolError(message: string): CallToolResult {
42
+ if (!message.startsWith(`MCP error ${ErrorCode.InvalidParams}:`)) return fail(new Error(message));
43
+ const unknown = UNKNOWN_TOOL.exec(message)?.[1];
44
+ return fail(
45
+ new CatherdError("E_INPUT_INVALID", message, {
46
+ fix: unknown
47
+ ? `call a tool this server lists (tools/list); a skill that needs ${unknown} needs a newer catherd: claude plugin update catherd@catherd`
48
+ : "correct the argument the message names, then call again",
49
+ }),
50
+ );
51
+ }
@@ -0,0 +1,145 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ID_PATTERN } from "../../domain/ids.ts";
4
+ import { ROLES } from "../../domain/roles.ts";
5
+ import { redact } from "../../infra/log.ts";
6
+ import { registerSavedSecrets } from "../../services/jev-service.ts";
7
+ import type { Deps } from "../../services/ports.ts";
8
+ import {
9
+ readKnowledge,
10
+ readRunFile,
11
+ recordAgentRun,
12
+ result,
13
+ setNext,
14
+ startRun,
15
+ writeRunFile,
16
+ } from "../../services/run-service.ts";
17
+ import { runsSummary, status } from "../../services/summary.ts";
18
+ import { handle } from "./result.ts";
19
+
20
+ export function registerRunTools(server: McpServer, deps: Deps): void {
21
+ server.registerTool(
22
+ "run_start",
23
+ {
24
+ description:
25
+ "Start a catherd run for a git repository: creates its run folder (outside the repo) and state.md. Returns the run id, the folder, protocol (the next step of the milestone loop and its six-line checklist), and hints when state.md could not be written yet.",
26
+ inputSchema: {
27
+ repo: z.string().min(1),
28
+ title: z.string().min(1),
29
+ a_lines: z.array(z.string().min(1)).min(1),
30
+ },
31
+ },
32
+ (a) => handle(() => startRun(deps, { repo: a.repo, title: a.title, aLines: a.a_lines })),
33
+ );
34
+
35
+ server.registerTool(
36
+ "write_run_file",
37
+ {
38
+ description:
39
+ "Write a file in the run folder: plan.md, lanes/Mx.Ly.md, a dossier or notes. The path is relative to the run folder; catherd's own files are refused.",
40
+ inputSchema: { run: z.string(), path: z.string().min(1), content: z.string() },
41
+ },
42
+ (a) => handle(() => writeRunFile(a)),
43
+ );
44
+
45
+ server.registerTool(
46
+ "read_run_file",
47
+ {
48
+ description: "Read a file in the run folder, by a path relative to it.",
49
+ inputSchema: { run: z.string(), path: z.string().min(1) },
50
+ },
51
+ (a) => handle(() => readRunFile(a)),
52
+ );
53
+
54
+ server.registerTool(
55
+ "status",
56
+ {
57
+ description:
58
+ "catherd's version, and one screen per run: the state.md tail, live roles, totals, reported Claude subagents, Jev fallbacks, per backend how many dispatches ran native and isolated, budget and landed milestones. Without a run: every run with live roles, else the newest. Reads only.",
59
+ inputSchema: { run: z.string().optional() },
60
+ },
61
+ (a) =>
62
+ handle(() => {
63
+ registerSavedSecrets();
64
+ return redact(status(deps, a.run));
65
+ }),
66
+ );
67
+
68
+ server.registerTool(
69
+ "result",
70
+ {
71
+ description:
72
+ "A role's latest dispatch: its state, its record once finished, its capped reply, and hints (climb, violation, failed, limit and failover lines). Reading a finished record marks it read: catherd's messages announce records, and this is how you read one.",
73
+ inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
74
+ },
75
+ (a) => handle(() => result(deps, a)),
76
+ );
77
+
78
+ server.registerTool(
79
+ "set_next",
80
+ {
81
+ description:
82
+ "Record the run's next step, the last line of state.md: when pausing, or when the plan changes. dispatch, climb and land write state.md themselves. Returns { state } (state.md's text), and hints ['state.md not refreshed: …'] with state null when git fails (the step is still recorded).",
83
+ inputSchema: { run: z.string(), next: z.string().min(1) },
84
+ },
85
+ (a) => handle(() => setNext(a)),
86
+ );
87
+
88
+ server.registerTool(
89
+ "record_agent_run",
90
+ {
91
+ description:
92
+ "After every native Claude subagent (Agent tool) of a run, record what the Agent result reported: total_tokens and duration_ms. The budget counts it. Pass lane (e.g. M1.L1) when the subagent worked a lane, so its time counts toward that lane's kind in the catalog timings. status says how the subagent's work ended: for a verifier it is the verdict, so pass status: \"failed\" when its verdict is FAIL; only a PASS is recorded ok.",
93
+ inputSchema: {
94
+ run: z.string(),
95
+ name: z.string().regex(ID_PATTERN),
96
+ role: z.enum(ROLES),
97
+ rung: z.string().min(3),
98
+ total_tokens: z.number().int().nonnegative(),
99
+ duration_ms: z.number().nonnegative().optional(),
100
+ cost_usd: z.number().nonnegative().optional(),
101
+ status: z.enum(["ok", "failed", "cancelled"]).default("ok"),
102
+ lane: z.string().regex(ID_PATTERN).optional(),
103
+ },
104
+ },
105
+ (a) =>
106
+ handle(() =>
107
+ recordAgentRun(deps, {
108
+ lane: a.lane,
109
+ run: a.run,
110
+ name: a.name,
111
+ role: a.role,
112
+ rung: a.rung,
113
+ totalTokens: a.total_tokens,
114
+ durationMs: a.duration_ms,
115
+ costUsd: a.cost_usd,
116
+ status: a.status,
117
+ }),
118
+ ),
119
+ );
120
+
121
+ server.registerTool(
122
+ "read_knowledge",
123
+ {
124
+ description:
125
+ "What past runs of this repo learned, one line per landed milestone that said something. Read it before an architect plans a new run.",
126
+ inputSchema: { repo: z.string().min(1) },
127
+ },
128
+ (a) => handle(() => readKnowledge(a.repo)),
129
+ );
130
+
131
+ server.registerTool(
132
+ "runs_summary",
133
+ {
134
+ description:
135
+ "Per role and rung over past runs: runs, ok, refusals, climbs from that rung, seconds and tokens; reported Claude subagent runs; and what the user's harness customizations cost per run (median first-turn input, native against isolated; with a run, over that run's repo; null below 3 runs a side or when native is not dearer; none for Codex, which reports no per-request input). Reads only.",
136
+ inputSchema: {
137
+ run: z.string().optional(),
138
+ repo: z.string().optional(),
139
+ role: z.enum(ROLES).optional(),
140
+ since_days: z.number().positive().optional(),
141
+ },
142
+ },
143
+ (a) => handle(() => runsSummary({ run: a.run, repo: a.repo, role: a.role, sinceDays: a.since_days })),
144
+ );
145
+ }