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.
Files changed (139) hide show
  1. package/README.md +93 -30
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +110 -45
  9. package/plugin/skills/catherd-setup/SKILL.md +2 -2
  10. package/src/adapters/antigravity/index.ts +18 -3
  11. package/src/adapters/backend.ts +18 -2
  12. package/src/adapters/claude-code/index.ts +126 -14
  13. package/src/adapters/codex/events.ts +10 -0
  14. package/src/adapters/codex/index.ts +17 -5
  15. package/src/adapters/cursor/index.ts +7 -1
  16. package/src/adapters/grok/home.ts +75 -20
  17. package/src/adapters/grok/index.ts +27 -8
  18. package/src/adapters/opencode/index.ts +38 -1
  19. package/src/cli.ts +5 -0
  20. package/src/domain/agents.ts +3 -1
  21. package/src/domain/brief.ts +91 -0
  22. package/src/domain/calibration.ts +7 -0
  23. package/src/domain/catalog.ts +12 -1
  24. package/src/domain/changes.ts +23 -1
  25. package/src/domain/cost.ts +8 -3
  26. package/src/domain/errors.ts +4 -0
  27. package/src/domain/failover.ts +17 -5
  28. package/src/domain/gate-brief.ts +52 -0
  29. package/src/domain/hints.ts +13 -2
  30. package/src/domain/lane.ts +99 -3
  31. package/src/domain/notice.ts +9 -2
  32. package/src/domain/profile-rules.ts +112 -16
  33. package/src/domain/profile.ts +24 -0
  34. package/src/domain/record.ts +16 -2
  35. package/src/domain/reply.ts +29 -0
  36. package/src/domain/role-prompts.ts +56 -16
  37. package/src/domain/role-scope.ts +106 -0
  38. package/src/domain/role-tools.ts +34 -0
  39. package/src/domain/route.ts +73 -0
  40. package/src/domain/select.ts +177 -25
  41. package/src/domain/state.ts +5 -0
  42. package/src/domain/util.ts +22 -0
  43. package/src/domain/workspace.ts +130 -0
  44. package/src/entry/catalog-command.ts +14 -6
  45. package/src/entry/cli-kit.ts +17 -1
  46. package/src/entry/deps.ts +2 -0
  47. package/src/entry/doctor-command.ts +20 -2
  48. package/src/entry/host-arg.ts +28 -0
  49. package/src/entry/init-command.ts +2 -1
  50. package/src/entry/knowledge-command.ts +78 -3
  51. package/src/entry/lock-command.ts +104 -8
  52. package/src/entry/mcp/dispatch-tools.ts +3 -3
  53. package/src/entry/mcp/handshake.ts +6 -2
  54. package/src/entry/mcp/lane-tools.ts +55 -8
  55. package/src/entry/mcp/protocol-tools.ts +10 -4
  56. package/src/entry/mcp/push-tools.ts +16 -0
  57. package/src/entry/mcp/role-bin.ts +20 -0
  58. package/src/entry/mcp/role-server.ts +116 -0
  59. package/src/entry/mcp/run-tools.ts +20 -4
  60. package/src/entry/mcp/server.ts +98 -7
  61. package/src/entry/mcp/setup-tools.ts +4 -3
  62. package/src/entry/mcp/workspace-tools.ts +142 -0
  63. package/src/entry/pause-command.ts +67 -0
  64. package/src/entry/profile-command.ts +23 -4
  65. package/src/entry/role-cli-command.ts +115 -0
  66. package/src/entry/runs-command.ts +78 -8
  67. package/src/entry/supervise-command.ts +30 -3
  68. package/src/entry/tui/effects.ts +4 -0
  69. package/src/entry/tui/state.ts +37 -10
  70. package/src/entry/tui/views/profiles.tsx +1 -1
  71. package/src/entry/tui/views/runs.tsx +66 -20
  72. package/src/entry/tui/views/save-dialog.tsx +6 -2
  73. package/src/entry/tui/views/status.tsx +6 -2
  74. package/src/entry/workspace-command.ts +44 -0
  75. package/src/infra/boot-lock.ts +25 -0
  76. package/src/infra/delivery.ts +3 -0
  77. package/src/infra/dispatch-dir.ts +2 -0
  78. package/src/infra/env.ts +11 -1
  79. package/src/infra/git.ts +6 -0
  80. package/src/infra/heavy-lock.ts +93 -4
  81. package/src/infra/host-probe.ts +16 -0
  82. package/src/infra/lock-activity.ts +88 -0
  83. package/src/infra/login-env.ts +48 -0
  84. package/src/infra/paths.ts +35 -0
  85. package/src/infra/role-mcp.ts +69 -0
  86. package/src/infra/sources/arena.ts +11 -3
  87. package/src/infra/sources/artificial-analysis.ts +8 -1
  88. package/src/infra/sources/cache.ts +2 -0
  89. package/src/infra/supervisor.ts +107 -16
  90. package/src/services/admission.ts +110 -23
  91. package/src/services/budget.ts +26 -6
  92. package/src/services/catalog-service.ts +20 -12
  93. package/src/services/credentials.ts +11 -6
  94. package/src/services/dispatch-service.ts +204 -16
  95. package/src/services/dispatches.ts +30 -2
  96. package/src/services/doctor-access.ts +8 -3
  97. package/src/services/doctor-docker.ts +221 -0
  98. package/src/services/doctor-role-mcp.ts +65 -0
  99. package/src/services/doctor.ts +68 -6
  100. package/src/services/end-push.ts +157 -0
  101. package/src/services/finalize.ts +12 -2
  102. package/src/services/gate-env.ts +218 -0
  103. package/src/services/gate-service.ts +216 -45
  104. package/src/services/global-install.ts +46 -13
  105. package/src/services/lane-edit.ts +154 -0
  106. package/src/services/lane-service.ts +257 -52
  107. package/src/services/milestones.ts +137 -26
  108. package/src/services/notifier.ts +56 -3
  109. package/src/services/orchestrator-wait.ts +82 -0
  110. package/src/services/pause.ts +161 -0
  111. package/src/services/peek.ts +79 -17
  112. package/src/services/ports.ts +31 -2
  113. package/src/services/preflight.ts +153 -19
  114. package/src/services/profile-service.ts +1 -0
  115. package/src/services/profile-store.ts +10 -3
  116. package/src/services/protocol.ts +119 -25
  117. package/src/services/provenance.ts +16 -12
  118. package/src/services/questions.ts +15 -0
  119. package/src/services/reconcile.ts +22 -1
  120. package/src/services/reentry.ts +7 -6
  121. package/src/services/role-access.ts +27 -0
  122. package/src/services/routing-service.ts +144 -36
  123. package/src/services/run-pin.ts +125 -0
  124. package/src/services/run-service.ts +151 -19
  125. package/src/services/run-store.ts +109 -15
  126. package/src/services/runs-page.ts +42 -7
  127. package/src/services/scratch.ts +60 -0
  128. package/src/services/sessions.ts +3 -1
  129. package/src/services/source-derive.ts +28 -5
  130. package/src/services/source-sync.ts +9 -2
  131. package/src/services/standins.ts +40 -6
  132. package/src/services/state.ts +21 -0
  133. package/src/services/summary.ts +49 -9
  134. package/src/services/treat-likes.ts +19 -3
  135. package/src/services/verifier-brief.ts +23 -0
  136. package/src/services/verifier-step.ts +48 -0
  137. package/src/services/workspace-admission.ts +267 -0
  138. package/src/services/workspace-service.ts +254 -0
  139. package/src/services/workspace-store.ts +95 -0
@@ -0,0 +1,116 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
+ import { z } from "zod";
5
+ import { ID_PATTERN } from "../../domain/ids.ts";
6
+ import { ROLE_MCP_SERVER, type RoleMcpContext, roleMcpTools } from "../../domain/role-tools.ts";
7
+ import { log } from "../../infra/log.ts";
8
+ import { gateCheckOrList, gatePass } from "../../services/gate-service.ts";
9
+ import type { Deps } from "../../services/ports.ts";
10
+ import { readKnowledge, readRunFile, writeRunFile } from "../../services/run-service.ts";
11
+ import { findRun } from "../../services/run-store.ts";
12
+ import { defaultDeps } from "../deps.ts";
13
+ import { handle, sdkToolError, toolOf } from "./result.ts";
14
+
15
+ /** A role can reach only its run artifacts and, for a verifier, that run's gate evidence. */
16
+ export function buildRoleServer(context: RoleMcpContext, deps: Deps = defaultDeps()): McpServer {
17
+ const run = findRun(context.run);
18
+ const server = new McpServer({ name: ROLE_MCP_SERVER, version: deps.version });
19
+ const tools = new Set(roleMcpTools(context.role));
20
+ const boundRun = z.literal(run.id);
21
+ const runFile = { run: boundRun, path: z.string().min(1) };
22
+ const call = async (tool: string, f: () => unknown) => {
23
+ const started = Date.now();
24
+ const result = await handle(f);
25
+ log(result.isError ? "warn" : "info", "tool", {
26
+ tool,
27
+ ms: Date.now() - started,
28
+ ok: !result.isError,
29
+ code: result.isError ? (result.structuredContent as { code?: string } | undefined)?.code : undefined,
30
+ });
31
+ return result;
32
+ };
33
+ (server as unknown as { createToolError: typeof sdkToolError }).createToolError = (message) => {
34
+ const result = sdkToolError(message);
35
+ log("warn", "tool", {
36
+ tool: toolOf(message),
37
+ ok: false,
38
+ code: (result.structuredContent as { code?: string } | undefined)?.code,
39
+ });
40
+ return result;
41
+ };
42
+
43
+ server.registerTool(
44
+ "read_run_file",
45
+ {
46
+ description: "Read an artifact from this role's run folder, using a run-relative path.",
47
+ inputSchema: runFile,
48
+ annotations: { readOnlyHint: true, openWorldHint: false },
49
+ },
50
+ (a) => call("read_run_file", () => readRunFile(a)),
51
+ );
52
+ server.registerTool(
53
+ "read_knowledge",
54
+ {
55
+ description:
56
+ "Read what past runs learned about this role's repository. Takes no argument: it reads the run's own repository.",
57
+ // #42 finding 4: `.`, the pwd or /tmp vs /private/tmp never matched a literal; any repo given is ignored
58
+ inputSchema: { repo: z.string().optional() },
59
+ annotations: { readOnlyHint: true, openWorldHint: false },
60
+ },
61
+ () => call("read_knowledge", () => readKnowledge(run.meta.repo)),
62
+ );
63
+ if (tools.has("write_run_file"))
64
+ server.registerTool(
65
+ "write_run_file",
66
+ {
67
+ description: "Write a plan, lane, dossier or note in this run; catherd's own files are protected.",
68
+ inputSchema: { ...runFile, content: z.string() },
69
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
70
+ },
71
+ (a) => call("write_run_file", () => writeRunFile(a)),
72
+ );
73
+ if (tools.has("gate_check")) {
74
+ const gate = {
75
+ run: boundRun,
76
+ item: z.string().min(1),
77
+ command: z.string().min(1),
78
+ paths: z.array(z.string().min(1)).min(1),
79
+ };
80
+ server.registerTool(
81
+ "gate_check",
82
+ {
83
+ description:
84
+ "Check whether this run's gate item passed on unchanged content; records the current step. With only run and milestone, lists the items already checked for that milestone in this run, each with its command and whether it passed: reuse those names and re-check the failed ones first.",
85
+ inputSchema: {
86
+ run: boundRun,
87
+ item: gate.item.optional(),
88
+ command: gate.command.optional(),
89
+ paths: gate.paths.optional(),
90
+ milestone: z.string().regex(ID_PATTERN).optional(),
91
+ },
92
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
93
+ },
94
+ (a) => call("gate_check", () => gateCheckOrList(deps, a)),
95
+ );
96
+ server.registerTool(
97
+ "gate_pass",
98
+ {
99
+ description: "Record this run's passed gate item with the evidence, command and content hash.",
100
+ inputSchema: { ...gate, evidence: z.string().min(1) },
101
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
102
+ },
103
+ (a) => call("gate_pass", () => gatePass(deps, a)),
104
+ );
105
+ }
106
+ return server;
107
+ }
108
+
109
+ /** Role servers have no orchestrator ownership, notifier, reconciliation or background catalog sync. */
110
+ export async function startRoleMcpServer(
111
+ context: RoleMcpContext,
112
+ o: { deps?: Deps; transport?: Transport } = {},
113
+ ): Promise<void> {
114
+ const server = buildRoleServer(context, o.deps);
115
+ await server.connect(o.transport ?? new StdioServerTransport());
116
+ }
@@ -15,6 +15,7 @@ import {
15
15
  startRun,
16
16
  writeRunFile,
17
17
  } from "../../services/run-service.ts";
18
+ import { repin } from "../../services/run-pin.ts";
18
19
  import { runsSummary, status } from "../../services/summary.ts";
19
20
  import { handle } from "./result.ts";
20
21
 
@@ -23,14 +24,25 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
23
24
  "run_start",
24
25
  {
25
26
  description:
26
- "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.",
27
+ "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. from: the run this one takes over (a planning run handed to the execution run in a worktree): it is closed with a pointer here, and status hides it.",
27
28
  inputSchema: {
28
29
  repo: z.string().min(1),
29
30
  title: z.string().min(1),
30
31
  a_lines: z.array(z.string().min(1)).min(1),
32
+ from: z.string().regex(ID_PATTERN).optional(),
31
33
  },
32
34
  },
33
- (a) => handle(() => startRun(deps, { repo: a.repo, title: a.title, aLines: a.a_lines })),
35
+ (a) => handle(() => startRun(deps, { repo: a.repo, title: a.title, aLines: a.a_lines, from: a.from })),
36
+ );
37
+
38
+ server.registerTool(
39
+ "run_pin",
40
+ {
41
+ description:
42
+ "Re-pin a run to what its repo runs on now: the profile, each role's access and each backend's isolation. run_start pins them; while they differ, dispatch keeps the pinned values and status and state.md say what changed. Only on the owner's word. Returns the new pin and what it changed. Orchestrator only.",
43
+ inputSchema: { run: z.string().regex(ID_PATTERN) },
44
+ },
45
+ (a) => handle(() => repin(deps, a)),
34
46
  );
35
47
 
36
48
  server.registerTool(
@@ -56,7 +68,7 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
56
68
  "status",
57
69
  {
58
70
  description:
59
- "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.",
71
+ "catherd's version, and one screen per run: the state.md tail, live roles, unread results waiting for the orchestrator, 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 or unread completions waiting for its orchestrator, else the newest. Reads only.",
60
72
  inputSchema: { run: z.string().optional() },
61
73
  },
62
74
  (a) =>
@@ -92,7 +104,7 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
92
104
  "record_agent_run",
93
105
  {
94
106
  description:
95
- "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.",
107
+ '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. For a verifier, pass verdict: the first line of its reply; a VERDICT: BLOCKED: environment — <probe> (status "failed") is then a blocker to surface to the owner, not a fix round. Pass reply_status, the STATUS word its reply ends with: a reviewer that stopped partial is not the milestone review.',
96
108
  inputSchema: {
97
109
  run: z.string(),
98
110
  name: z.string().regex(ID_PATTERN),
@@ -103,6 +115,8 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
103
115
  cost_usd: z.number().nonnegative().optional(),
104
116
  status: z.enum(["ok", "failed", "cancelled"]).default("ok"),
105
117
  lane: z.string().regex(ID_PATTERN).optional(),
118
+ verdict: z.string().min(1).optional(),
119
+ reply_status: z.enum(["complete", "partial", "blocked", "refused", "flaky"]).optional(),
106
120
  },
107
121
  },
108
122
  (a) =>
@@ -117,6 +131,8 @@ export function registerRunTools(server: McpServer, deps: Deps): void {
117
131
  durationMs: a.duration_ms,
118
132
  costUsd: a.cost_usd,
119
133
  status: a.status,
134
+ verdict: a.verdict,
135
+ replyStatus: a.reply_status,
120
136
  }),
121
137
  ),
122
138
  );
@@ -4,24 +4,91 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
5
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { sessionKey, type HostContext } from "../../domain/host.ts";
7
- import { errorMessage } from "../../domain/errors.ts";
7
+ import { type CatherdError, errorMessage, isCatherdError } from "../../domain/errors.ts";
8
+ import {
9
+ isCoordinatorTool,
10
+ refusedForRole,
11
+ type RoleScope,
12
+ roleScopeError,
13
+ } from "../../domain/role-scope.ts";
14
+ import { takeBootLock } from "../../infra/boot-lock.ts";
8
15
  import { resolveHost } from "../../infra/host-context.ts";
9
16
  import { log } from "../../infra/log.ts";
17
+ import { assertRoleMay } from "../../services/role-access.ts";
10
18
  import { currentSession } from "../../services/sessions.ts";
11
19
  import { startNotifier } from "../../services/notifier.ts";
12
20
  import type { Deps } from "../../services/ports.ts";
13
- import { reconcileAll } from "../../services/reconcile.ts";
21
+ import { adoptOwned, reconcileAll } from "../../services/reconcile.ts";
14
22
  import { backgroundSync } from "../../services/source-sync.ts";
15
23
  import { defaultDeps } from "../deps.ts";
16
24
  import { registerDispatchTools } from "./dispatch-tools.ts";
17
25
  import { registerLaneTools } from "./lane-tools.ts";
18
26
  import { registerProtocolTools } from "./protocol-tools.ts";
19
- import { sdkToolError, toolOf } from "./result.ts";
27
+ import { registerPushTools } from "./push-tools.ts";
28
+ import { handle, sdkToolError, toolOf } from "./result.ts";
20
29
  import { registerRunTools } from "./run-tools.ts";
21
30
  import { registerSetupTools } from "./setup-tools.ts";
31
+ import { registerWorkspaceTools } from "./workspace-tools.ts";
22
32
 
23
33
  type ObserveSession = (context: Deps) => (() => void) | undefined;
24
34
 
35
+ /** Spec 1.5 plan 21: a coordinator tool's description says, first, that only the orchestrator calls it. */
36
+ function orchestratorOnly(name: string, config: unknown): unknown {
37
+ const c = config as { description?: string };
38
+ return isCoordinatorTool(name) && c.description
39
+ ? { ...c, description: `Orchestrator only (a role process gets E_ROLE_SCOPE). ${c.description}` }
40
+ : config;
41
+ }
42
+
43
+ type RequestHandler = (
44
+ request: { params?: { name?: unknown; arguments?: unknown } },
45
+ extra: unknown,
46
+ ) => unknown;
47
+
48
+ /** The role server's tools that act on a run: in a role they are bound as their CLI forms are (assertRoleMay). */
49
+ const RUN_BOUND_TOOLS = new Set(["read_run_file", "write_run_file", "gate_check", "gate_pass"]);
50
+
51
+ /**
52
+ * Why a role process may not make this call on the full server, else null (plan 21 review, finding 3): a
53
+ * coordinator tool; `record_agent_run`, which could fabricate a verifier's ok; or a run-bound tool on another
54
+ * run or outside its role's tool set, as `catherd run-file` and `catherd gate` refuse it.
55
+ */
56
+ function roleRefusal(name: string, args: unknown, role: RoleScope): CatherdError | null {
57
+ if (refusedForRole(name, args, role) || name === "record_agent_run") return roleScopeError(name, role);
58
+ if (!RUN_BOUND_TOOLS.has(name)) return null;
59
+ const run = (args as { run?: unknown } | undefined)?.run;
60
+ try {
61
+ assertRoleMay(role, typeof run === "string" ? run : "", name);
62
+ return null;
63
+ } catch (e) {
64
+ if (isCatherdError(e)) return e;
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Spec 1.5 plan 21: in a role's process (`deps.role`) a coordinator tool call is refused with E_ROLE_SCOPE
71
+ * before anything else, its input check included, so a role learns at once that the tool is not its own. The
72
+ * SDK keeps its request handlers in a private map; test/entry/role-scope-mcp.test.ts fails if it moves.
73
+ */
74
+ function refuseCoordinatorTools(server: McpServer, deps: Deps): void {
75
+ const handlers = (server.server as unknown as { _requestHandlers: Map<string, RequestHandler> })
76
+ ._requestHandlers;
77
+ const callTool = handlers.get("tools/call");
78
+ if (!callTool) return;
79
+ handlers.set("tools/call", async (request, extra) => {
80
+ const role = deps.role;
81
+ const name = String(request.params?.name ?? "");
82
+ const refusal = role ? roleRefusal(name, request.params?.arguments, role) : null;
83
+ if (!refusal) return callTool(request, extra);
84
+ const r = await handle(() => {
85
+ throw refusal;
86
+ });
87
+ log("warn", "tool", { tool: name, ok: false, code: "E_ROLE_SCOPE" });
88
+ return r;
89
+ });
90
+ }
91
+
25
92
  type Handler = (...args: unknown[]) => CallToolResult | Promise<CallToolResult>;
26
93
 
27
94
  /** Spec §10.2: every tool call is logged with its duration and outcome (the error code), never its input. */
@@ -38,7 +105,7 @@ function logToolCalls(
38
105
  h: Handler,
39
106
  ) => unknown;
40
107
  (server as unknown as { registerTool: typeof register }).registerTool = (name, config, handler) =>
41
- register(name, config, async (...args: unknown[]) => {
108
+ register(name, orchestratorOnly(name, config), async (...args: unknown[]) => {
42
109
  const started = Date.now();
43
110
  const epoch = generation();
44
111
  const extra = args.at(-1) as { _meta?: Record<string, unknown> } | undefined;
@@ -102,16 +169,25 @@ export function buildServer(deps: Deps = defaultDeps(), observe?: ObserveSession
102
169
  return r;
103
170
  };
104
171
  registerRunTools(server, scoped);
172
+ registerWorkspaceTools(server, scoped);
105
173
  registerLaneTools(server, scoped);
106
174
  registerDispatchTools(server, scoped);
107
175
  registerSetupTools(server, scoped);
108
176
  registerProtocolTools(server, scoped);
177
+ registerPushTools(server, scoped);
178
+ refuseCoordinatorTools(server, deps);
109
179
  return server;
110
180
  }
111
181
 
112
182
  /** Connect immediately; initialized triggers recovery without delaying the handshake. */
113
183
  export async function startMcpServer(
114
- o: { transport?: Transport; sync?: () => Promise<unknown>; deps?: Deps } = {},
184
+ o: {
185
+ transport?: Transport;
186
+ sync?: () => Promise<unknown>;
187
+ deps?: Deps;
188
+ /** plan 22: the boot lock (tests replace it); null while another live server holds it */
189
+ bootLock?: () => (() => void) | null;
190
+ } = {},
115
191
  ): Promise<void> {
116
192
  const deps = o.deps ?? defaultDeps();
117
193
  const observed = new Map<string, ReturnType<typeof startNotifier>>();
@@ -134,6 +210,8 @@ export async function startMcpServer(
134
210
  let generation = 0;
135
211
  let notifier: ReturnType<typeof startNotifier> | undefined;
136
212
  let recovery: Deps | undefined;
213
+ // plan 22: taken at the first initialize, held until this server closes; null: another server leads the boot
214
+ let boot: (() => void) | null | undefined;
137
215
  const invalidate = () => {
138
216
  generation++;
139
217
  if (recovery) recovery.host = { host: "unknown", session: null, conflict: null };
@@ -143,10 +221,17 @@ export async function startMcpServer(
143
221
  observed.clear();
144
222
  closed();
145
223
  };
146
- server.server.onclose = invalidate;
224
+ server.server.onclose = () => {
225
+ invalidate();
226
+ boot?.();
227
+ boot = undefined;
228
+ };
147
229
  server.server.oninitialized = () => {
148
230
  invalidate();
149
231
  initialized();
232
+ boot ??= (o.bootLock ?? takeBootLock)();
233
+ const lead = boot !== null;
234
+ if (!lead) log("info", "boot", { skipped: "another catherd server runs the boot sync and reconcile" });
150
235
  log("info", "session", {
151
236
  host: deps.host.host,
152
237
  conflict: deps.host.conflict,
@@ -164,12 +249,18 @@ export async function startMcpServer(
164
249
  if (target) observed.set(sessionKey(target), active);
165
250
  void Promise.resolve()
166
251
  .then(() => {
167
- if (epoch === generation) return (o.sync ?? (() => backgroundSync()))();
252
+ if (lead && epoch === generation) return (o.sync ?? (() => backgroundSync()))();
168
253
  })
169
254
  .catch((e: unknown) => log("debug", "sources", { error: errorMessage(e) }));
170
255
  void (async () => {
171
256
  try {
172
257
  if (epoch !== generation) return;
258
+ // the leading server reconciles every run; this one still watches the live roles of the runs its own
259
+ // session owns (a coordinator's server restarted mid-run) and tells its session what it owns
260
+ if (!lead) {
261
+ adoptOwned(context);
262
+ return void active.scan();
263
+ }
173
264
  const r = await reconcileAll(context);
174
265
  if (epoch !== generation) return;
175
266
  const shown = r.warnings.length;
@@ -59,7 +59,8 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
59
59
  },
60
60
  (a) =>
61
61
  handle(async () => {
62
- const r = await (deps.sync ?? ((o) => syncSources(o)))({ force: a.force });
62
+ // (1.2 minor) a sync already running (the boot sync) answers busy at once, never a 120 s wait
63
+ const r = await (deps.sync ?? ((o) => syncSources(o)))({ force: a.force, wait: false });
63
64
  return {
64
65
  newlyScored: r.newlyScored,
65
66
  standInsNoLongerNeeded: r.noLongerNeeded,
@@ -90,7 +91,7 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
90
91
  "profile_validate",
91
92
  {
92
93
  description:
93
- "Check a profile (without a name: the profile this repo runs on, as in profile_get). errors block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a 'treat like', a failover stand-in unscored or on the same quota, a backend catherd cannot run. warnings do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs. Each has a path, a message and often a fix.",
94
+ "Check a profile (without a name: the profile this repo runs on, as in profile_get). errors block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a 'treat like', a failover stand-in unscored or on the same quota, a backend catherd cannot run. warnings do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs, a quota none of whose rungs ever starts a lane, and every kind and difficulty no worker rung clears. Each has a path, a message and often a fix.",
94
95
  inputSchema: { name: PROFILE, repo: REPO },
95
96
  },
96
97
  (a) => handle(async () => deps.profiles.validate(a.name, await toplevel(a.repo))),
@@ -100,7 +101,7 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
100
101
  "profile_set",
101
102
  {
102
103
  description:
103
- "Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid, unless the patch repairs an invalid profile: it fixes at least one of its errors and adds none, and then it saves with `errors` listing those still open. A save rewrites the agent files and relinks them. Returns saved, the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
104
+ "Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung, timeouts: a role's own idleMin and wallMin over the profile's), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid, unless the patch repairs an invalid profile: it fixes at least one of its errors and adds none, and then it saves with `errors` listing those still open. A save rewrites the agent files and relinks them. Returns saved, the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
104
105
  inputSchema: { name: PROFILE, repo: REPO, patch: ProfilePatchSchema },
105
106
  },
106
107
  (a) => handle(async () => deps.profiles.set(a.name, a.patch, await toplevel(a.repo))),
@@ -0,0 +1,142 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import {
4
+ BaseRefSchema,
5
+ RELEASES,
6
+ WORKSPACE_LIMIT,
7
+ WorkspaceBudgetSchema,
8
+ WorkspaceIdSchema,
9
+ } from "../../domain/workspace.ts";
10
+ import { pauseWorkspace, resumeWorkspace } from "../../services/pause.ts";
11
+ import type { Deps } from "../../services/ports.ts";
12
+ import { setWorkspaceBudget, workspaceContract } from "../../services/workspace-admission.ts";
13
+ import {
14
+ inspectWorkspace,
15
+ startWorkspace,
16
+ startWorkspaceChild,
17
+ workspaceStatus,
18
+ } from "../../services/workspace-service.ts";
19
+ import { handle } from "./result.ts";
20
+
21
+ const id = WorkspaceIdSchema;
22
+ const repos = z
23
+ .record(id, z.string().min(1))
24
+ .refine((r) => Object.keys(r).length <= WORKSPACE_LIMIT)
25
+ .optional();
26
+ const aLines = z.array(z.string().min(1)).min(1).max(100);
27
+
28
+ export function registerWorkspaceTools(server: McpServer, deps: Deps): void {
29
+ server.registerTool(
30
+ "workspace_inspect",
31
+ {
32
+ description:
33
+ "Resolve explicit workspace members from catherd.workspace.json or a supplied repo map. The root may be a plain directory. Reads only; never scans unrelated repositories.",
34
+ inputSchema: { root: z.string().min(1), repos },
35
+ },
36
+ (a) => handle(() => inspectWorkspace(a.root, a.repos)),
37
+ );
38
+ server.registerTool(
39
+ "workspace_start",
40
+ {
41
+ description:
42
+ "Snapshot a workspace's selected repositories, step dependency graph and optional shared budget (no cap unless given; raise it later with workspace_budget). A step releases its dependents when its milestone lands, or with release: 'merge' and base (e.g. origin/main) once its landed commit is an ancestor of that ref, checked when a dependent asks. Returns workspace.id and dir. Child single-repo runs are created later with workspace_child_start; no commits or pushes are performed.",
43
+ inputSchema: {
44
+ root: z.string().min(1),
45
+ title: z.string().min(1),
46
+ a_lines: aLines,
47
+ repos,
48
+ steps: z
49
+ .array(
50
+ // #43 finding 6: an unknown key (dependsOn for depends_on) is refused, never dropped
51
+ z.strictObject({
52
+ id,
53
+ repo: id,
54
+ title: z.string().min(1),
55
+ a_lines: aLines,
56
+ depends_on: z.array(id).max(WORKSPACE_LIMIT).optional(),
57
+ milestone: id.optional(),
58
+ release: z.enum(RELEASES).optional(),
59
+ base: BaseRefSchema.optional(),
60
+ }),
61
+ )
62
+ .min(1)
63
+ .max(WORKSPACE_LIMIT),
64
+ budget: WorkspaceBudgetSchema.optional(),
65
+ },
66
+ },
67
+ (a) =>
68
+ handle(() =>
69
+ startWorkspace(deps, {
70
+ root: a.root,
71
+ title: a.title,
72
+ aLines: a.a_lines,
73
+ repos: a.repos,
74
+ budget: a.budget,
75
+ steps: a.steps.map((s) => ({
76
+ id: s.id,
77
+ repo: s.repo,
78
+ title: s.title,
79
+ aLines: s.a_lines,
80
+ dependsOn: s.depends_on,
81
+ milestone: s.milestone,
82
+ release: s.release,
83
+ base: s.base,
84
+ })),
85
+ }),
86
+ ),
87
+ );
88
+ server.registerTool(
89
+ "workspace_contract",
90
+ {
91
+ description:
92
+ "Read or write the workspace's shared contract before any child run exists. Once a child is created the contract is frozen and copied into each child as workspace-contract.md.",
93
+ inputSchema: { workspace: z.string().min(1), content: z.string().optional() },
94
+ },
95
+ (a) => handle(() => workspaceContract(a)),
96
+ );
97
+ server.registerTool(
98
+ "workspace_child_start",
99
+ {
100
+ description:
101
+ "Create or return a step's single-repo run once its dependencies have landed their declared milestone, every dispatch of theirs is collected, and each 'merge' release's commit is in its base ref (fetch first; nothing is polled). Returns run, dir and the frozen contract path. Use the existing route, dispatch, verification and land tools on this child run.",
102
+ inputSchema: { workspace: z.string().min(1), step: id },
103
+ },
104
+ (a) => handle(() => startWorkspaceChild(deps, a)),
105
+ );
106
+ server.registerTool(
107
+ "workspace_pause",
108
+ {
109
+ description:
110
+ "Pause every child of a workspace on one blocker (a VPN, Docker down): admission refuses each dispatch and child start with E_ADMIT_PAUSED and the reason until workspace_resume. Running roles finish. status shows the pause first. Push the reason to the owner once.",
111
+ inputSchema: { workspace: z.string().min(1), reason: z.string().min(1) },
112
+ },
113
+ (a) => handle(() => pauseWorkspace(deps.now(), a)),
114
+ );
115
+ server.registerTool(
116
+ "workspace_resume",
117
+ {
118
+ description: "Lift a workspace's pause. Returns the pause it lifted, or null when none was in force.",
119
+ inputSchema: { workspace: z.string().min(1) },
120
+ },
121
+ (a) => handle(() => resumeWorkspace(deps.now(), a)),
122
+ );
123
+ const cap = z.number().finite().nonnegative().nullable().optional();
124
+ server.registerTool(
125
+ "workspace_budget",
126
+ {
127
+ description:
128
+ "Set or raise a workspace's shared budget: each of minutes, tokens and usd given as a number replaces that cap, null removes it, and one left out stays. A workspace has no cap unless one is set; its minutes count from its first child. Returns the budget, the spend and its status.",
129
+ inputSchema: { workspace: z.string().min(1), minutes: cap, tokens: cap, usd: cap },
130
+ },
131
+ (a) => handle(() => setWorkspaceBudget(deps, a)),
132
+ );
133
+ server.registerTool(
134
+ "workspace_status",
135
+ {
136
+ description:
137
+ "Read a workspace's step readiness, child run ids, landed milestones, aggregate spending and shared budget. Reads only; never starts children automatically.",
138
+ inputSchema: { workspace: z.string().min(1) },
139
+ },
140
+ (a) => handle(() => workspaceStatus(deps, a.workspace)),
141
+ );
142
+ }
@@ -0,0 +1,67 @@
1
+ import { defineCommand } from "citty";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import {
4
+ pauseLine,
5
+ pauseMachine,
6
+ pauseWorkspace,
7
+ resumeMachine,
8
+ resumeWorkspace,
9
+ } from "../services/pause.ts";
10
+ import { JSON_ARG, printJson, refuseInRole } from "./cli-kit.ts";
11
+
12
+ /** Exactly one of --machine and --workspace: what a pause or resume covers. */
13
+ function scope(args: { machine?: boolean; workspace?: string }, usage: string): "machine" | string {
14
+ if (args.machine === true && args.workspace === undefined) return "machine";
15
+ if (args.machine !== true && args.workspace) return args.workspace;
16
+ throw new CatherdError(
17
+ "E_INPUT_INVALID",
18
+ "name what to pause: --machine or --workspace <id>, one of them",
19
+ {
20
+ fix: usage,
21
+ },
22
+ );
23
+ }
24
+
25
+ const SCOPE_ARGS = {
26
+ machine: { type: "boolean", description: "every run on this machine" },
27
+ workspace: { type: "string", description: "every child of this workspace" },
28
+ } as const;
29
+
30
+ const PAUSE_USAGE = 'catherd pause --machine "the VPN takes the default route"';
31
+ const RESUME_USAGE = "catherd resume --machine";
32
+
33
+ /** Spec 1.5 "Group pause": one blocker, one pause, instead of a park per run and milestone. */
34
+ export const pauseCommand = defineCommand({
35
+ meta: {
36
+ name: "pause",
37
+ description:
38
+ "Pause every run on this machine (--machine) or in a workspace: dispatches are refused until resume",
39
+ },
40
+ args: {
41
+ reason: { type: "positional", required: true, description: "why: what blocks the runs" },
42
+ ...SCOPE_ARGS,
43
+ ...JSON_ARG,
44
+ },
45
+ run({ args }) {
46
+ refuseInRole(process.env, "catherd pause");
47
+ const at = scope(args, PAUSE_USAGE);
48
+ const r =
49
+ at === "machine"
50
+ ? pauseMachine(Date.now(), args.reason)
51
+ : pauseWorkspace(Date.now(), { workspace: at, reason: args.reason });
52
+ if (args.json) return printJson(r);
53
+ console.log(pauseLine(r.pause));
54
+ },
55
+ });
56
+
57
+ export const resumeCommand = defineCommand({
58
+ meta: { name: "resume", description: "Lift a pause on this machine (--machine) or on a workspace" },
59
+ args: { ...SCOPE_ARGS, ...JSON_ARG },
60
+ run({ args }) {
61
+ refuseInRole(process.env, "catherd resume");
62
+ const at = scope(args, RESUME_USAGE);
63
+ const r = at === "machine" ? resumeMachine(Date.now()) : resumeWorkspace(Date.now(), { workspace: at });
64
+ if (args.json) return printJson(r);
65
+ console.log(r.resumed ? `resumed: ${pauseLine(r.resumed)}` : "nothing was paused");
66
+ },
67
+ });