catherd-cli 1.4.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.
- package/README.md +93 -30
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +5 -0
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +110 -23
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +49 -9
- package/src/services/treat-likes.ts +19 -3
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { gitToplevel } from "../infra/git.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
3
|
+
import { HOST_ARG, terminalHost, withThread } from "./host-arg.ts";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
import { VERSION } from "../infra/version.ts";
|
|
5
6
|
import { type DoctorReport, doctor } from "../services/doctor.ts";
|
|
6
7
|
import type { Check } from "../services/doctor-checks.ts";
|
|
7
8
|
import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
8
9
|
import { mcpHandshake } from "./mcp/handshake.ts";
|
|
10
|
+
import { probeRoleServer } from "../infra/role-mcp.ts";
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* One row per check: `✓ ready Bun — 1.4.2`, then the full fix on its own line; a fix of several lines
|
|
@@ -42,16 +44,32 @@ export const doctorCommand = defineCommand({
|
|
|
42
44
|
description:
|
|
43
45
|
"send one labeled smoke to the validated original session (receipt does not prove processing)",
|
|
44
46
|
},
|
|
47
|
+
thread: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description:
|
|
50
|
+
"with --test-push: the Codex thread to send the smoke to (Codex does not export it to the commands it runs)",
|
|
51
|
+
},
|
|
52
|
+
docker: {
|
|
53
|
+
type: "boolean",
|
|
54
|
+
description:
|
|
55
|
+
"also probe Docker as a gate sees it: two compose services reaching each other by name, and the free disk (pulls busybox)",
|
|
56
|
+
},
|
|
45
57
|
plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
|
|
46
58
|
},
|
|
47
59
|
async run({ args }) {
|
|
60
|
+
if (args.thread !== undefined && args["test-push"] !== true)
|
|
61
|
+
throw new CatherdError("E_INPUT_INVALID", "--thread only names where --test-push sends its smoke", {
|
|
62
|
+
fix: `catherd doctor --test-push --thread ${args.thread}`,
|
|
63
|
+
});
|
|
48
64
|
const r = await doctor({
|
|
49
|
-
host: terminalHost(args.host),
|
|
65
|
+
host: withThread(terminalHost(args.host), args.thread),
|
|
50
66
|
repo: await gitToplevel(process.cwd()),
|
|
51
67
|
bunVersion: Bun.version,
|
|
52
68
|
version: VERSION,
|
|
53
69
|
handshake: () => mcpHandshake(),
|
|
70
|
+
roleServerStart: () => probeRoleServer(),
|
|
54
71
|
testPush: args["test-push"] === true,
|
|
72
|
+
docker: args.docker === true,
|
|
55
73
|
});
|
|
56
74
|
if (args.json) printJson(r);
|
|
57
75
|
else for (const l of formatReport(r, args.plain === true)) console.log(l);
|
package/src/entry/host-arg.ts
CHANGED
|
@@ -10,6 +10,34 @@ export const HOST_ARG = {
|
|
|
10
10
|
},
|
|
11
11
|
};
|
|
12
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
|
+
|
|
13
41
|
export function terminalHost(value: string | undefined, env = process.env): HostContext {
|
|
14
42
|
if (value === undefined || value === "auto") return resolveHost({ env });
|
|
15
43
|
if (value !== "codex" && value !== "claude-code")
|
|
@@ -15,7 +15,7 @@ import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../serv
|
|
|
15
15
|
import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
|
|
16
16
|
import { activeName } from "../services/profile-store.ts";
|
|
17
17
|
import { formatRefreshed, syncLines } from "./catalog-command.ts";
|
|
18
|
-
import { mark as markOf } from "./cli-kit.ts";
|
|
18
|
+
import { mark as markOf, refuseInRole } from "./cli-kit.ts";
|
|
19
19
|
import { formatReport } from "./doctor-command.ts";
|
|
20
20
|
import { mcpHandshake } from "./mcp/handshake.ts";
|
|
21
21
|
import type { State } from "./glyphs.ts";
|
|
@@ -213,6 +213,7 @@ export const initCommand = defineCommand({
|
|
|
213
213
|
plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
|
|
214
214
|
},
|
|
215
215
|
async run({ args }) {
|
|
216
|
+
refuseInRole(process.env, "catherd init");
|
|
216
217
|
// a bad --profile is refused before any question is asked
|
|
217
218
|
if (args.profile !== undefined) assertProfileName(args.profile);
|
|
218
219
|
const ask = (args as { input?: boolean }).input === false ? null : await prompter();
|
|
@@ -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: {
|
|
49
|
-
|
|
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
|
});
|
|
@@ -3,7 +3,8 @@ import { defineCommand } from "citty";
|
|
|
3
3
|
import { CatherdError } from "../domain/errors.ts";
|
|
4
4
|
import { scrubSecrets } from "../infra/env.ts";
|
|
5
5
|
import { gitToplevel } from "../infra/git.ts";
|
|
6
|
-
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";
|
|
7
8
|
import { killGroup } from "../infra/proc.ts";
|
|
8
9
|
import { activeName, readProfileDoc } from "../services/profile-store.ts";
|
|
9
10
|
import { printError } from "./cli-kit.ts";
|
|
@@ -44,14 +45,26 @@ export const DOUBLE_INTERRUPT_MS = 2_000;
|
|
|
44
45
|
* a terminal's Ctrl-C reaches catherd only, so the command sees it exactly once, and so does every
|
|
45
46
|
* process it started. A second Ctrl-C within 2 s kills the group. Resolves to the command's exit code.
|
|
46
47
|
*/
|
|
47
|
-
export async function runForwarding(
|
|
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;
|
|
48
54
|
const child = Bun.spawn(argv, {
|
|
49
55
|
stdin: "inherit",
|
|
50
|
-
stdout: "inherit",
|
|
51
|
-
stderr: "inherit",
|
|
52
|
-
env: scrubSecrets(process.env),
|
|
56
|
+
stdout: piped ? "pipe" : "inherit",
|
|
57
|
+
stderr: piped ? "pipe" : "inherit",
|
|
58
|
+
env: { ...scrubSecrets(process.env), ...o.extraEnv },
|
|
53
59
|
detached: true,
|
|
54
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
|
+
: [];
|
|
55
68
|
let lastInt = 0;
|
|
56
69
|
const handlers: [NodeJS.Signals, () => void][] = [
|
|
57
70
|
[
|
|
@@ -80,15 +93,53 @@ export async function runForwarding(argv: string[]): Promise<number> {
|
|
|
80
93
|
},
|
|
81
94
|
);
|
|
82
95
|
try {
|
|
83
|
-
|
|
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;
|
|
84
100
|
} finally {
|
|
85
101
|
watchdog.kill("SIGKILL");
|
|
86
102
|
for (const [sig, h] of handlers) process.off(sig, h);
|
|
87
103
|
}
|
|
88
104
|
}
|
|
89
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
|
+
|
|
90
125
|
/** Spec §8: what `--help` and the usage error show; the command runs after `--`. */
|
|
91
|
-
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";
|
|
92
143
|
|
|
93
144
|
export const lockCommand = defineCommand({
|
|
94
145
|
meta: {
|
|
@@ -102,6 +153,15 @@ export const lockCommand = defineCommand({
|
|
|
102
153
|
type: "string",
|
|
103
154
|
description: "Slots (default: CATHERD_LOCK_SLOTS, else the profile's lock.heavy, else half the cores)",
|
|
104
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
|
+
},
|
|
105
165
|
},
|
|
106
166
|
async run({ args, rawArgs }) {
|
|
107
167
|
const sep = rawArgs.indexOf("--");
|
|
@@ -115,6 +175,31 @@ export const lockCommand = defineCommand({
|
|
|
115
175
|
process.exitCode = 2;
|
|
116
176
|
return;
|
|
117
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;
|
|
118
203
|
const repo = await gitToplevel(process.cwd());
|
|
119
204
|
const slots = resolveSlots(
|
|
120
205
|
args.slots,
|
|
@@ -123,6 +208,17 @@ export const lockCommand = defineCommand({
|
|
|
123
208
|
() => readProfileDoc(activeName(repo)).lock?.heavy ?? "cpus/2",
|
|
124
209
|
(m) => console.error(m),
|
|
125
210
|
);
|
|
126
|
-
|
|
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
|
+
}
|
|
127
223
|
},
|
|
128
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,
|
|
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,11 +12,15 @@ 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
|
-
/**
|
|
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
22
|
): Record<string, string> => {
|
|
19
|
-
return scrubSecrets(base);
|
|
23
|
+
return { ...scrubSecrets(base), CATHERD_NO_SYNC: "1" };
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
/**
|
|
@@ -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 {
|
|
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
|
|
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) =>
|
|
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
|
|
31
|
-
inputSchema: {
|
|
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,
|
|
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 {
|
|
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:
|
|
23
|
-
inputSchema: {
|
|
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(() =>
|
|
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { errorMessage } from "../../domain/errors.ts";
|
|
3
|
+
import { ROLES } from "../../domain/roles.ts";
|
|
4
|
+
import { restoreTmpdir } from "../../infra/env.ts";
|
|
5
|
+
import { startRoleMcpServer } from "./role-server.ts";
|
|
6
|
+
|
|
7
|
+
if (process.argv[2] === "--probe") {
|
|
8
|
+
// doctor's start probe: the server's module graph is loaded by now (spec 1.5 plan 21)
|
|
9
|
+
console.log("ready");
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const [role, run] = z.tuple([z.enum(ROLES), z.string().min(1)]).parse(process.argv.slice(2));
|
|
15
|
+
restoreTmpdir(process.env);
|
|
16
|
+
await startRoleMcpServer({ role, run });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.error(`catherd role MCP: ${errorMessage(e)}`);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|