catherd-cli 0.2.1 → 1.0.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 (187) hide show
  1. package/README.md +114 -30
  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 +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,45 @@
1
+ import { defineCommand } from "citty";
2
+ import { VERSION } from "../infra/version.ts";
3
+ import { type DoctorReport, doctor } from "../services/doctor.ts";
4
+ import type { Check } from "../services/doctor-checks.ts";
5
+ import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
6
+ import { mcpHandshake } from "./mcp/handshake.ts";
7
+
8
+ /**
9
+ * One row per check: `✓ ready Bun — 1.4.2`, then the full fix on its own line; a fix of several lines
10
+ * (one command each) keeps them under the first.
11
+ */
12
+ export function formatCheck(c: Check, plain = false): string[] {
13
+ return [
14
+ `${mark(c.state, plain)} ${c.word.padEnd(18)} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`,
15
+ ...(c.fix ? c.fix.split("\n").map((l, i) => `${i ? " " : " fix: "}${l}`) : []),
16
+ ];
17
+ }
18
+
19
+ export function formatReport(r: DoctorReport, plain = false): string[] {
20
+ return [
21
+ ...r.checks.flatMap((c) => formatCheck(c, plain)),
22
+ "",
23
+ r.ready
24
+ ? `${mark("ok", plain)} ready`
25
+ : `${mark("fail", plain)} not ready: fix the rows marked ${mark("fail", plain)} above`,
26
+ ];
27
+ }
28
+
29
+ /** Spec §8, §10.3: `catherd doctor [--json]`, exit 3 when not ready. */
30
+ export const doctorCommand = defineCommand({
31
+ meta: {
32
+ name: "doctor",
33
+ description: "Readiness report: Bun, backends, Jev, the plugin, agents, the MCP server, locks",
34
+ },
35
+ args: {
36
+ ...JSON_ARG,
37
+ plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
38
+ },
39
+ async run({ args }) {
40
+ const r = await doctor({ bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() });
41
+ if (args.json) printJson(r);
42
+ else for (const l of formatReport(r, args.plain === true)) console.log(l);
43
+ process.exitCode = r.ready ? EXIT.ok : EXIT.notReady;
44
+ },
45
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Spec §9.3's glyphs, each with its ASCII twin for `--plain`: the dashboard and every CLI command draw
3
+ * state with these. Free of OpenTUI, so `mcp`, `lock` and `_supervise` can load it (spec §3.1).
4
+ */
5
+ const GLYPHS = {
6
+ ok: ["✓", "+"],
7
+ warn: ["!", "!"],
8
+ fail: ["✗", "x"],
9
+ skip: ["-", "-"],
10
+ live: ["●", "*"],
11
+ waiting: ["◌", "."],
12
+ on: ["[x]", "[x]"],
13
+ off: ["[ ]", "[ ]"],
14
+ open: ["▾", "v"],
15
+ shut: ["▸", ">"],
16
+ arrow: ["→", "->"],
17
+ dot: ["·", "-"],
18
+ more: ["…", "..."],
19
+ up: ["↑", "^"],
20
+ down: ["↓", "v"],
21
+ bar: ["┃", "|"],
22
+ rule: ["─", "-"],
23
+ current: ["●", "*"],
24
+ full: ["█", "#"],
25
+ empty: ["░", "."],
26
+ } as const;
27
+ export type Glyph = keyof typeof GLYPHS;
28
+ export const GLYPH_NAMES = Object.keys(GLYPHS) as Glyph[];
29
+
30
+ export const glyph = (g: Glyph, plain: boolean): string => GLYPHS[g][plain ? 1 : 0];
31
+
32
+ /** The words state always travels with (spec §9.3: green/amber/red only for state, always with a word). */
33
+ export type State = "ok" | "warn" | "fail" | "skip";
@@ -0,0 +1,142 @@
1
+ import { defineCommand } from "citty";
2
+ import { assertProfileName } from "../domain/profile.ts";
3
+ import { errorMessage, isCatherdError } from "../domain/errors.ts";
4
+ import { configDir } from "../infra/paths.ts";
5
+ import { VERSION } from "../infra/version.ts";
6
+ import { doctor } from "../services/doctor.ts";
7
+ import { jevKey, saveJevKey, testJevKey } from "../services/jev-service.ts";
8
+ import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
9
+ import { formatRefreshed } from "./catalog-command.ts";
10
+ import { mark as markOf } from "./cli-kit.ts";
11
+ import { formatReport } from "./doctor-command.ts";
12
+ import { mcpHandshake } from "./mcp/handshake.ts";
13
+ import type { State } from "./glyphs.ts";
14
+ import { mascot } from "./tui/theme.ts";
15
+ import { type Prompter, prompter } from "./prompt.ts";
16
+
17
+ /** What `init` prints last (spec §9.1): the plugin install commands, to paste into a terminal. */
18
+ export const PLUGIN_STEPS = [
19
+ "Install the Claude Code plugin:",
20
+ " claude plugin marketplace add 47vigen/catherd",
21
+ " claude plugin install catherd@catherd",
22
+ "Then start a new Claude Code session, so it loads the plugin and the catherd agents.",
23
+ ];
24
+
25
+ /** Spec §9.3: the full mascot greets `init` (and the TUI's empty states), nowhere else. */
26
+ export function welcomeLines(version: string): string[] {
27
+ const [ears, face, paws] = mascot("good");
28
+ return [ears, `${face} catherd ${version}`, `${paws} herds your coding agents`, ""];
29
+ }
30
+
31
+ /**
32
+ * The Jev key step. It never stops `init` (Ruling 11): a key that cannot be saved, say because
33
+ * credentials.json is unparsable, is reported with its fix and setup goes on.
34
+ */
35
+ export async function jevStep(
36
+ ask: Prompter | null,
37
+ d: { testJevKey?: (key: string) => Promise<boolean>; saveJevKey?: (key: string) => void } = {},
38
+ plain = false,
39
+ ): Promise<void> {
40
+ const mark = (state: State) => markOf(state, plain);
41
+ if (process.env.TYPESAFE_API_KEY?.trim()) {
42
+ ask?.skip?.();
43
+ return console.log(`${mark("ok")} Jev: using TYPESAFE_API_KEY`);
44
+ }
45
+ if (jevKey()) {
46
+ ask?.skip?.();
47
+ return console.log(`${mark("ok")} Jev: using the saved key`);
48
+ }
49
+ const key = ask ? await ask.secret("TypeSafe API key for Jev (optional; Enter skips): ") : "";
50
+ if (!key)
51
+ return console.log(
52
+ `- Jev: no key; routing uses each lane's Kind and Difficulty (add one later with catherd init)`,
53
+ );
54
+ if (!(await (d.testJevKey ?? testJevKey)(key).catch(() => false)))
55
+ return console.log(`${mark("warn")} Jev: the key did not answer, so it was not saved`);
56
+ try {
57
+ (d.saveJevKey ?? saveJevKey)(key);
58
+ console.log(`${mark("ok")} Jev: the key answers; saved with mode 600`);
59
+ } catch (e) {
60
+ const message = errorMessage(e).split("\n").join(" ");
61
+ console.log(`${mark("warn")} Jev: could not save the key: ${message}`);
62
+ if (isCatherdError(e) && e.fix) console.log(` fix: ${e.fix}`);
63
+ }
64
+ }
65
+
66
+ /** What happened to the profile: written, kept, or (when the defaults do not validate here) why not. */
67
+ export function profileLines(r: InitResult, plain = false): string[] {
68
+ const mark = (state: State) => markOf(state, plain);
69
+ const out: string[] = [];
70
+ if (r.errors.length) {
71
+ out.push(
72
+ `${mark("warn")} profile ${r.profile}: the default profile does not validate here, so it was not written`,
73
+ );
74
+ for (const e of r.errors) {
75
+ out.push(`${mark("warn")} ${e.path}: ${e.message}`);
76
+ if (e.fix) out.push(` fix: ${e.fix}`);
77
+ }
78
+ }
79
+ if (!r.active)
80
+ out.push(`${mark("warn")} no profile was made active: fix the rows above, then run catherd init again`);
81
+ else
82
+ out.push(
83
+ `${mark("ok")} profile ${r.profile} ${r.created ? "written from the defaults" : "kept as it was"}, and active`,
84
+ );
85
+ return out;
86
+ }
87
+
88
+ /** Spec §8 `catherd init [--no-input]`: first-run setup without the TUI (plan 6 adds the TUI wizard). */
89
+ export const initCommand = defineCommand({
90
+ meta: {
91
+ name: "init",
92
+ description:
93
+ "First run: the Jev key, 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 profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
94
+ },
95
+ args: {
96
+ // citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
97
+ "no-input": { type: "boolean", description: "ask nothing: keep what exists, else write the defaults" },
98
+ profile: { type: "string", description: "the profile to set up and make active (default: default)" },
99
+ plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
100
+ },
101
+ async run({ args }) {
102
+ // a bad --profile is refused before any question is asked
103
+ if (args.profile !== undefined) assertProfileName(args.profile);
104
+ const ask = (args as { input?: boolean }).input === false ? null : await prompter();
105
+ const plain = args.plain === true;
106
+ const mark = (state: State) => markOf(state, plain);
107
+ try {
108
+ if (process.stdout.isTTY) for (const line of welcomeLines(VERSION)) console.log(line);
109
+ console.log(`catherd ${VERSION}: setting up in ${configDir()}`);
110
+ await jevStep(ask, {}, plain);
111
+ if (args.profile !== undefined) ask?.skip?.();
112
+ const name = assertProfileName(
113
+ args.profile ?? (ask ? (await ask.ask("Profile to set up [default]: ")) || "default" : "default"),
114
+ );
115
+ // 0.x files go first, so a legacy profile about to be moved aside is never asked about
116
+ const moved = moveLegacy();
117
+ const exists = hasProfileFile(name);
118
+ if (!exists) ask?.skip?.();
119
+ const overwrite =
120
+ ask !== null &&
121
+ exists &&
122
+ /^y(es)?$/i.test(await ask.ask(`Replace profile ${name} with the default profile? [y/N] `));
123
+ const r = await initSetup({ profile: name, overwrite });
124
+ for (const f of [...moved, ...r.moved]) console.log(`${mark("ok")} moved a 0.x file aside: ${f}`);
125
+ for (const l of profileLines(r, plain)) console.log(l);
126
+ if (r.synced?.linked.length)
127
+ console.log(`${mark("ok")} Claude agents linked: ${r.synced.linked.join(", ")}`);
128
+ for (const x of r.refreshed) console.log(formatRefreshed(x, plain));
129
+ console.log("");
130
+ const report = await doctor({
131
+ bunVersion: Bun.version,
132
+ version: VERSION,
133
+ handshake: () => mcpHandshake(),
134
+ });
135
+ for (const l of formatReport(report, plain)) console.log(l);
136
+ console.log("");
137
+ for (const l of PLUGIN_STEPS) console.log(l);
138
+ } finally {
139
+ ask?.close();
140
+ }
141
+ },
142
+ });
@@ -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
+ });
@@ -0,0 +1,82 @@
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, type Progress, wait } from "../../services/dispatch-service.ts";
6
+ import type { Deps } from "../../services/ports.ts";
7
+ import { handle } from "./result.ts";
8
+
9
+ type Notify = (n: {
10
+ method: "notifications/progress";
11
+ params: { progressToken: string | number; progress: number; message: string };
12
+ }) => Promise<void>;
13
+
14
+ /** Progress notifications that can never fail a wait: the client may be gone (audit C8). */
15
+ export function progressTo(token: string | number | undefined, send: Notify): Progress | undefined {
16
+ if (token === undefined) return undefined;
17
+ let n = 0;
18
+ return (message) => {
19
+ try {
20
+ send({
21
+ method: "notifications/progress",
22
+ params: { progressToken: token, progress: ++n, message },
23
+ }).catch(() => {}); // the client is gone: progress is best effort
24
+ } catch {
25
+ // the transport is closed
26
+ }
27
+ };
28
+ }
29
+
30
+ export function registerDispatchTools(server: McpServer, deps: Deps): void {
31
+ server.registerTool(
32
+ "dispatch",
33
+ {
34
+ description:
35
+ "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, and wait(run) collects its record. Dispatch every independent role one after another, then call wait. The brief is the text itself. With lane, the lane file's Owns: paths guard against overlapping lanes. 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.",
36
+ inputSchema: {
37
+ run: z.string(),
38
+ role: z.enum(ROLES),
39
+ name: z.string().regex(ID_PATTERN),
40
+ brief: z.string().min(1),
41
+ rung: z.string().min(3),
42
+ thread: z.string().optional(),
43
+ lane: z.string().regex(ID_PATTERN).optional(),
44
+ next: z.string().optional(),
45
+ },
46
+ },
47
+ (a) => handle(() => dispatch(deps, a)),
48
+ );
49
+
50
+ server.registerTool(
51
+ "wait",
52
+ {
53
+ description:
54
+ "Block until at least one of the run's uncollected dispatches has finished (only those in names, when given; every one of them with all: true), reporting progress meanwhile, and return { records, started, running, hints }. records holds each finished role's { record, hints }, in the order they finished, each returned by one wait only; a role that hit a usage limit has its failover stand-in launched, listed in started. running names every uncollected role this call did not return, still running or already finished, stand-ins included: act on the records, then wait again while running is not empty. Returns at once, with a hint, when nothing is uncollected. Call it from the main thread.",
55
+ inputSchema: {
56
+ run: z.string(),
57
+ names: z.array(z.string().regex(ID_PATTERN)).optional(),
58
+ all: z.boolean().optional(),
59
+ },
60
+ },
61
+ (a, extra) =>
62
+ handle(() =>
63
+ wait(
64
+ deps,
65
+ a,
66
+ progressTo(extra._meta?.progressToken, (n) => extra.sendNotification(n)),
67
+ // a wait its caller cancelled collects nothing: the record would go to no one
68
+ extra.signal,
69
+ ),
70
+ ),
71
+ );
72
+
73
+ server.registerTool(
74
+ "cancel",
75
+ {
76
+ description:
77
+ "Stop a live dispatch (interrupt, then SIGTERM, then SIGKILL) and return its record, marked cancelled, and hints.",
78
+ inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
79
+ },
80
+ (a) => handle(() => cancel(deps, a.run, a.name)),
81
+ );
82
+ }
@@ -0,0 +1,48 @@
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
+ const CLI = fileURLToPath(new URL("../../cli.ts", import.meta.url));
10
+ const TIMEOUT_MS = 20_000;
11
+
12
+ /** The env the doctor's MCP server starts with: catherd's own secrets scrubbed, as for every process it starts. */
13
+ export const handshakeEnv = (
14
+ base: Record<string, string | undefined> = process.env,
15
+ ): Record<string, string> => scrubSecrets(base);
16
+
17
+ /** Spec §10.3: starts `catherd mcp` over stdio, as Claude Code would, and asks it for tools/list. */
18
+ export async function mcpHandshake(): Promise<Handshake> {
19
+ const client = new Client({ name: "catherd-doctor", version: VERSION });
20
+ const transport = new StdioClientTransport({
21
+ command: process.execPath,
22
+ args: [CLI, "mcp"],
23
+ env: handshakeEnv(),
24
+ stderr: "ignore",
25
+ });
26
+ let timer: ReturnType<typeof setTimeout> | undefined;
27
+ const late = new Promise<Handshake>((resolve) => {
28
+ timer = setTimeout(
29
+ () => resolve({ ok: false, tools: [], error: `no answer within ${TIMEOUT_MS / 1000} s` }),
30
+ TIMEOUT_MS,
31
+ );
32
+ });
33
+ try {
34
+ return await Promise.race([
35
+ (async (): Promise<Handshake> => {
36
+ await client.connect(transport);
37
+ const r = await client.listTools();
38
+ return { ok: true, tools: r.tools.map((t) => t.name) };
39
+ })(),
40
+ late,
41
+ ]);
42
+ } catch (e) {
43
+ return { ok: false, tools: [], error: errorMessage(e) };
44
+ } finally {
45
+ clearTimeout(timer);
46
+ await client.close().catch(() => {}); // the answer (or the failure) is already in hand
47
+ }
48
+ }
@@ -0,0 +1,83 @@
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, 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.",
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.",
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.",
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.",
71
+ inputSchema: {
72
+ run: z.string(),
73
+ milestone: z.string().min(1),
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
+ },
80
+ },
81
+ (a) => handle(() => land(deps, a)),
82
+ );
83
+ }
@@ -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
+ }