catherd-cli 1.0.0 → 1.1.1

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 (83) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +118 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/run.tsx +11 -1
  44. package/src/entry/tui/state.ts +26 -6
  45. package/src/entry/tui/stories.ts +36 -4
  46. package/src/entry/tui/text.ts +12 -0
  47. package/src/entry/tui/theme.ts +1 -0
  48. package/src/entry/tui/views/runs.tsx +409 -117
  49. package/src/entry/tui/views/status.tsx +5 -2
  50. package/src/entry/tui/widgets/list.tsx +4 -3
  51. package/src/infra/claude-session.ts +98 -0
  52. package/src/infra/dispatch-dir.ts +14 -6
  53. package/src/infra/env.ts +26 -2
  54. package/src/infra/peer-inbox.ts +92 -0
  55. package/src/infra/supervisor.ts +33 -3
  56. package/src/services/admission.ts +38 -12
  57. package/src/services/dispatch-service.ts +338 -197
  58. package/src/services/dispatches.ts +30 -1
  59. package/src/services/doctor-access.ts +231 -0
  60. package/src/services/doctor-backends.ts +3 -18
  61. package/src/services/doctor-checks.ts +46 -1
  62. package/src/services/doctor-push.ts +109 -0
  63. package/src/services/doctor.ts +59 -79
  64. package/src/services/finalize.ts +43 -25
  65. package/src/services/gate-service.ts +288 -0
  66. package/src/services/global-install.ts +99 -0
  67. package/src/services/lane-service.ts +150 -8
  68. package/src/services/milestones.ts +208 -0
  69. package/src/services/notifier.ts +296 -0
  70. package/src/services/peek.ts +103 -0
  71. package/src/services/ports.ts +7 -4
  72. package/src/services/preflight.ts +17 -3
  73. package/src/services/protocol.ts +212 -0
  74. package/src/services/questions.ts +102 -0
  75. package/src/services/reconcile.ts +40 -11
  76. package/src/services/reentry.ts +22 -0
  77. package/src/services/run-service.ts +42 -9
  78. package/src/services/run-store.ts +18 -0
  79. package/src/services/runs-page.ts +254 -0
  80. package/src/services/session-view.ts +152 -0
  81. package/src/services/sessions.ts +92 -0
  82. package/src/services/state.ts +10 -3
  83. package/src/services/summary.ts +14 -0
@@ -1,5 +1,4 @@
1
1
  import {
2
- billingKeyOf,
3
2
  type Catalog,
4
3
  capableFor,
5
4
  effortOffered,
@@ -8,12 +7,22 @@ import {
8
7
  rungInfo,
9
8
  scoresOf,
10
9
  } from "./catalog.ts";
11
- import { type Rung, tryParseRung } from "./ids.ts";
10
+ import {
11
+ catalogRungs,
12
+ claudeBilled,
13
+ downgradeDims,
14
+ ladderDropDims,
15
+ quotaOf,
16
+ rankStandIns,
17
+ } from "./failover.ts";
18
+ import { tryParseRung } from "./ids.ts";
12
19
  import { DIFFICULTIES, KINDS } from "./lane.ts";
13
20
  import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
14
21
  import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
15
22
  import { candidates, clearsBar, type RoutingProfile } from "./select.ts";
16
23
 
24
+ export { quotaOf };
25
+
17
26
  /** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
18
27
  export interface Issue {
19
28
  path: string;
@@ -35,12 +44,6 @@ export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
35
44
  role: p.roles[role],
36
45
  });
37
46
 
38
- /**
39
- * Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
40
- * quota, except that the native `claude` path and headless `claude-code` both draw on the Claude plan.
41
- */
42
- export const quotaOf = (r: Rung): string => (billingKeyOf(r) === "claude" ? "claude-code" : billingKeyOf(r));
43
-
44
47
  /** Whether a rung's scores are catherd's guess: borrowed through a treat-like, or only `inferred` ones. */
45
48
  export function inferredScores(c: Catalog, info: RungInfo): { inferred: boolean; via: string | null } {
46
49
  const s = scoresOf(c, info.canonical);
@@ -54,7 +57,8 @@ export function inferredScores(c: Catalog, info: RungInfo): { inferred: boolean;
54
57
  * treat-like; a failover stand-in unscored or on the same quota; a rung whose backend catherd cannot run
55
58
  * (`backends` lists those it can, `claude` included). Everything else worth knowing is a warning: an access
56
59
  * mode other than the role's default, an effort or model the last listing does not offer, a stand-in
57
- * that never runs. With the stored `doc` `p` came from, a value this catherd does not know is also a
60
+ * that never runs, and (spec 1.1 §11) a stand-in that downgrades its rung, one that spends Claude quota
61
+ * while another plan could stand in, and a ladder that goes down. With the stored `doc` `p` came from, a value this catherd does not know is also a
58
62
  * warning, one that says how the value is read.
59
63
  */
60
64
  export function validateProfile(
@@ -131,6 +135,16 @@ export function validateProfile(
131
135
  if (!scoresOf(c, info.canonical))
132
136
  errors.push({ path: `${at}.rungs`, message: `${rung} is unscored`, fix: TREAT_LIKE_FIX(rung) });
133
137
  }
138
+ rc.rungs.forEach((rung, i) => {
139
+ const prev = rc.rungs[i - 1];
140
+ const down = prev === undefined ? [] : ladderDropDims(c, prev, rung);
141
+ if (down.length)
142
+ warnings.push({
143
+ path: `${at}.rungs`,
144
+ message: `the ladder goes down at ${rung}: it scores below ${prev} on ${down.join(", ")}`,
145
+ fix: `order ${at}.rungs weakest first`,
146
+ });
147
+ });
134
148
  if (rc.defaultRung !== undefined && !rc.rungs.includes(rc.defaultRung))
135
149
  errors.push({
136
150
  path: `${at}.defaultRung`,
@@ -169,6 +183,11 @@ export function validateProfile(
169
183
  }
170
184
 
171
185
  const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
186
+ // the stand-ins catherd could run here, for the fixes below
187
+ const pool = catalogRungs(c).filter((x) => {
188
+ const r = tryParseRung(x);
189
+ return r !== null && backends.includes(r.backend);
190
+ });
172
191
  for (const [from, to] of Object.entries(p.failover)) {
173
192
  const at = `failover.${from}`;
174
193
  const a = tryParseRung(from);
@@ -193,6 +212,21 @@ export function validateProfile(
193
212
  message: `stand-in ${to} draws on the same quota as ${from}, which is out when ${from} hits its limit`,
194
213
  fix: "name a stand-in on another backend or plan",
195
214
  });
215
+ const ranked = rankStandIns(c, p.billing, from, pool);
216
+ const down = downgradeDims(c, from, to);
217
+ if (down.length)
218
+ warnings.push({
219
+ path: at,
220
+ message: `downgrade: ${to} stands in for ${from}, scoring below it on ${down.join(", ")}`,
221
+ fix: `catherd profile set failover.${from} ${ranked[0] ?? "null"}`,
222
+ });
223
+ const other = ranked.find((x) => !claudeBilled(x));
224
+ if (claudeBilled(to) && other)
225
+ warnings.push({
226
+ path: at,
227
+ message: `stand-in ${to} spends Claude quota, while ${other} could stand in on another plan`,
228
+ fix: `catherd profile set failover.${from} ${other}`,
229
+ });
196
230
  if (b.backend === "claude")
197
231
  warnings.push({
198
232
  path: at,
@@ -43,6 +43,7 @@ const RoleDocSchema = z.looseObject({
43
43
  access: z.string().optional(),
44
44
  rungs: z.array(z.string()).optional(),
45
45
  defaultRung: z.string().optional(),
46
+ network: z.boolean().optional(),
46
47
  });
47
48
 
48
49
  export const ProfileDocSchema = z.looseObject({
@@ -113,6 +114,8 @@ export type RoleConfig = {
113
114
  /** in ladder order, each `backend:model#effort`; `claude:` runs as a native subagent, `claude-code:` headless */
114
115
  rungs: string[];
115
116
  defaultRung?: string;
117
+ /** spec §5: false drops a workspace-write role's network, loopback and Docker grants; absent means granted */
118
+ network?: false;
116
119
  };
117
120
 
118
121
  /** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
@@ -155,15 +158,15 @@ export const BUILTIN_ROLES: Record<Role, RoleConfig> = {
155
158
  };
156
159
 
157
160
  /**
158
- * Spec §7.2: each Codex rung fails over to the best-matching OpenCode Go model. Go serves GPT-6 Luna
159
- * itself; for Sol it has no GPT model, so Kimi K3 stands in through a shipped treat-like (scores.json),
160
- * which `profile show` marks inferred.
161
+ * Spec 1.1 §11: each shipped worker rung fails over to the best stand-in the shipped catalog offers
162
+ * (`rankStandIns` over `catalogRungs`, pinned by test/domain/failover.test.ts): on another quota, paid
163
+ * from a plan, and no downgrade on the dims the rung's bars use. Go serves GPT-6 Luna itself; Kimi K3
164
+ * borrows Sol medium's scores (a shipped treat-like). Sol high and xhigh have no such stand-in on the
165
+ * owner's plans, so they have none: a limit on them pauses the lane instead of dropping it a tier.
161
166
  */
162
167
  export const DEFAULT_FAILOVER: Record<string, string> = {
163
168
  [LUNA_HIGH]: "opencode:opencode-go/gpt-6-luna#high",
164
169
  [SOL("medium")]: "opencode:opencode-go/kimi-k3#max",
165
- [SOL("high")]: "opencode:opencode-go/kimi-k3#max",
166
- [SOL("xhigh")]: "opencode:opencode-go/kimi-k3#max",
167
170
  };
168
171
 
169
172
  /** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
@@ -210,6 +213,7 @@ export function resolveProfile(doc: ProfileDoc, name: string): Profile {
210
213
  : STORED_FALLBACK.access,
211
214
  rungs: [...(d?.rungs ?? b.rungs)],
212
215
  ...(defaultRung ? { defaultRung } : {}),
216
+ ...(d?.network === false ? { network: false as const } : {}),
213
217
  };
214
218
  }
215
219
  const harness: Profile["harness"] = {};
@@ -247,13 +251,15 @@ export function agentName(profile: string, role: Role, rung: string): string {
247
251
  }
248
252
 
249
253
  // Patches: what profile_set and `catherd profile set` accept. Strict at every level, so a misspelt key is
250
- // refused with E_INPUT_INVALID instead of dropped (plan-2 review m9). `null` removes a map entry.
254
+ // refused with E_INPUT_INVALID instead of dropped (plan-2 review m9). `null` removes a map entry or a nullable
255
+ // field (a role's `network` among them: absent means granted).
251
256
  const RolePatchSchema = z
252
257
  .strictObject({
253
258
  enabled: z.boolean(),
254
259
  access: z.enum(ACCESS),
255
260
  rungs: z.array(RungSchema),
256
261
  defaultRung: RungSchema.nullable(),
262
+ network: z.boolean().nullable(),
257
263
  })
258
264
  .partial();
259
265
 
@@ -71,6 +71,8 @@ export const RunRecordSchema = z.looseObject({
71
71
  images: z.array(z.string()),
72
72
  error: z.object({ code: z.string(), message: z.string() }).nullable(),
73
73
  replyPath: z.string(),
74
+ /** the Claude Code session that dispatched it, copied from admit.json (spec §3.3); absent before 1.1 */
75
+ sessionId: z.string().optional(),
74
76
  });
75
77
  export type RunRecord = z.infer<typeof RunRecordSchema>;
76
78
 
@@ -53,7 +53,11 @@ const architect = [
53
53
  const verifier = [
54
54
  "You verify work you did not write. You get the acceptance lines, the check command and how to run the thing. You do not get the author's account of it, and you should not look for one.",
55
55
  "",
56
- "1. Run the check command once. Report its exit code and the failing lines.",
56
+ "1. Run the check command once. Report its exit code and the failing lines. When the check has several gate items (suites, lint, builds, a boot check):",
57
+ " - Before each item, call the catherd MCP tool gate_check (mcp__plugin_catherd_catherd__gate_check) with the run id, the milestone you verify (M1, as your brief names it), the item, its command and the repo paths it depends on. When it answers carried: true, do not run the item: report it as carried over from its commit. It also tells the orchestrator which step you are on.",
58
+ " - After an item passes, call gate_pass (mcp__plugin_catherd_catherd__gate_pass) with the same item, command and paths, and the evidence.",
59
+ " - Run independent items side by side, each heavy one wrapped in catherd lock, which queues them within the machine's slots.",
60
+ " - Build each commit's images once, and reuse them for the boot check and the acceptance suite.",
57
61
  "2. Exercise every acceptance line through the real entry point: the CLI, the HTTP route, the page. Read source only to find that entry point. When a line needs data or files, build them in a temporary directory outside the project.",
58
62
  "3. Read the diff (git diff plus untracked files) for bugs the acceptance lines miss: wrong edge behavior, dead code, leftovers.",
59
63
  "",
@@ -62,6 +66,7 @@ const verifier = [
62
66
  "Return, in this order:",
63
67
  "- VERDICT: PASS or VERDICT: FAIL on the first line.",
64
68
  "- One line per acceptance line: A<n> PASS|FAIL, the command you ran and the decisive output.",
69
+ "- One line per gate item: PASS|FAIL, or carried over from <commit>.",
65
70
  "- Bugs outside the acceptance lines: file:line, what happens, the input that triggers it.",
66
71
  "- What you could not check, and why.",
67
72
  ].join("\n");
@@ -134,6 +139,33 @@ const BODIES: Record<Role, (version: string) => string> = {
134
139
  researcher: () => researcher,
135
140
  };
136
141
 
142
+ const STATUS_LINE =
143
+ "The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>";
144
+
145
+ /**
146
+ * Spec 1.1 §6: the reply contract `dispatch` appends to every brief it writes, the tail of the role's
147
+ * prompt: reply length and the STATUS line. The architect and the verifier keep their own reply shapes.
148
+ */
149
+ const CONTRACTS: Record<Role, string> = {
150
+ architect: `Reply briefly: the milestones, each with its lanes as Mx.Ly — one line — owned files, and the full-check command. ${STATUS_LINE}`,
151
+ verifier: `The first line of your reply is VERDICT: PASS or VERDICT: FAIL. ${STATUS_LINE}`,
152
+ worker: REPLY,
153
+ reviewer: REPLY,
154
+ "ui-reviewer": REPLY,
155
+ artist: REPLY,
156
+ writer: REPLY,
157
+ researcher: `For a single question, reply in at most 15 lines. ${STATUS_LINE}`,
158
+ };
159
+
160
+ export const replyContract = (role: Role): string => CONTRACTS[role];
161
+
162
+ /** `brief` with the role's reply contract as its last paragraph, once: a brief that already ends with it is kept. */
163
+ export function withReplyContract(role: Role, brief: string): string {
164
+ const contract = CONTRACTS[role];
165
+ const body = brief.trimEnd();
166
+ return body.endsWith(contract) ? `${body}\n` : `${body}\n\n${contract}\n`;
167
+ }
168
+
137
169
  /** The role's prompt; the worker's names the catherd version whose `lock` it must use. */
138
170
  export const rolePrompt = (role: Role, version: string): string => BODIES[role](version);
139
171
 
@@ -1,3 +1,15 @@
1
+ const PARKED = /^parked: [^;]*; /;
2
+
3
+ /**
4
+ * Spec 1.1 §8: `next` with the parked milestones in front of it, once ("parked: M2 waits on the owner;
5
+ * <next>"), whoever wrote the step: a milestone stays parked in state.md until it is answered.
6
+ */
7
+ export function withParked(next: string, parked: string[]): string {
8
+ const base = next.replace(PARKED, "");
9
+ if (parked.length === 0) return base;
10
+ return `parked: ${parked.join(", ")} ${parked.length > 1 ? "wait" : "waits"} on the owner; ${base}`;
11
+ }
12
+
1
13
  export interface StateView {
2
14
  title: string;
3
15
  head: string;
@@ -5,9 +17,11 @@ export interface StateView {
5
17
  running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
6
18
  lastCheck: string | null;
7
19
  next: string;
20
+ /** spec 1.1 §10: the protocol's next step, derived from the run's files; always the last line */
21
+ protocol: string;
8
22
  }
9
23
 
10
- /** state.md: enough for a fresh session to resume from alone; the next step is always the last line. */
24
+ /** state.md: enough for a fresh session to resume from alone; the protocol's next step is always the last line. */
11
25
  export function renderState(s: StateView): string {
12
26
  const waiting = s.running.map((r) => r.name);
13
27
  return [
@@ -27,7 +41,9 @@ export function renderState(s: StateView): string {
27
41
  "",
28
42
  `Last check: ${s.lastCheck ?? "none"}`,
29
43
  "",
30
- `Next: ${waiting.length ? `wait for ${waiting.join(", ")}; then ${s.next}` : s.next}`,
44
+ // 1.1: no tool waits; each running role's record arrives as a catherd message
45
+ `Next: ${waiting.length ? `running ${waiting.join(", ")} (results arrive as catherd messages; peek to check); then ${s.next}` : s.next}`,
46
+ `Protocol next: ${s.protocol}`,
31
47
  "",
32
48
  ].join("\n");
33
49
  }
package/src/entry/deps.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readSessionEnv } from "../infra/claude-session.ts";
1
2
  import { VERSION } from "../infra/version.ts";
2
3
  import type { Deps } from "../services/ports.ts";
3
4
  import { profileService } from "../services/profile-service.ts";
@@ -10,7 +11,7 @@ export function defaultDeps(): Deps {
10
11
  routing: routingService(),
11
12
  version: VERSION,
12
13
  pollMs: 250,
13
- tickMs: Number(process.env.CATHERD_TICK_MS) || 30_000,
14
+ session: readSessionEnv(process.env),
14
15
  now: Date.now,
15
16
  };
16
17
  }
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { VERSION } from "../infra/version.ts";
3
3
  import { type DoctorReport, doctor } from "../services/doctor.ts";
4
+ import { probePush } from "../services/doctor-push.ts";
4
5
  import type { Check } from "../services/doctor-checks.ts";
5
6
  import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
6
7
  import { mcpHandshake } from "./mcp/handshake.ts";
@@ -37,7 +38,12 @@ export const doctorCommand = defineCommand({
37
38
  plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
38
39
  },
39
40
  async run({ args }) {
40
- const r = await doctor({ bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() });
41
+ const r = await doctor({
42
+ bunVersion: Bun.version,
43
+ version: VERSION,
44
+ handshake: () => mcpHandshake(),
45
+ push: () => probePush(),
46
+ });
41
47
  if (args.json) printJson(r);
42
48
  else for (const l of formatReport(r, args.plain === true)) console.log(l);
43
49
  process.exitCode = r.ready ? EXIT.ok : EXIT.notReady;
@@ -7,6 +7,7 @@ const GLYPHS = {
7
7
  warn: ["!", "!"],
8
8
  fail: ["✗", "x"],
9
9
  skip: ["-", "-"],
10
+ info: ["i", "i"],
10
11
  live: ["●", "*"],
11
12
  waiting: ["◌", "."],
12
13
  on: ["[x]", "[x]"],
@@ -30,4 +31,4 @@ export const GLYPH_NAMES = Object.keys(GLYPHS) as Glyph[];
30
31
  export const glyph = (g: Glyph, plain: boolean): string => GLYPHS[g][plain ? 1 : 0];
31
32
 
32
33
  /** 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";
34
+ export type State = "ok" | "warn" | "fail" | "skip" | "info";
@@ -5,6 +5,8 @@ import { configDir } from "../infra/paths.ts";
5
5
  import { VERSION } from "../infra/version.ts";
6
6
  import { doctor } from "../services/doctor.ts";
7
7
  import { jevKey, saveJevKey, testJevKey } from "../services/jev-service.ts";
8
+ import { reinstallCommand } from "../services/doctor-checks.ts";
9
+ import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../services/global-install.ts";
8
10
  import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
9
11
  import { formatRefreshed } from "./catalog-command.ts";
10
12
  import { mark as markOf } from "./cli-kit.ts";
@@ -63,6 +65,38 @@ export async function jevStep(
63
65
  }
64
66
  }
65
67
 
68
+ /**
69
+ * Spec 1.1 §12: installs the global CLI at this version (the plugin's launcher then needs no bunx), and
70
+ * says "installing catherd…" before the resolve. It never stops `init`: a failure is a `!` line and its fix.
71
+ */
72
+ export async function globalStep(
73
+ version: string,
74
+ o: { skip: boolean; deps?: GlobalInstallDeps; plain?: boolean },
75
+ ): Promise<void> {
76
+ const mark = (state: State) => markOf(state, o.plain === true);
77
+ if (o.skip)
78
+ return console.log(`- catherd: not installed globally (--no-global); the plugin starts it with bunx`);
79
+ const r = await ensureGlobal(version, o.deps ?? realGlobalInstall, () =>
80
+ console.log(`installing catherd… (${reinstallCommand(version)})`),
81
+ );
82
+ if (r.state === "current") return console.log(`${mark("ok")} catherd ${version} is installed globally`);
83
+ if (r.state === "installed")
84
+ return console.log(
85
+ `${mark("ok")} catherd ${version} installed globally; the plugin starts it without bunx`,
86
+ );
87
+ if (r.state === "shadowed") {
88
+ const found = r.onPath === null ? "no catherd is on PATH" : `the catherd first on PATH is ${r.onPath}`;
89
+ console.log(
90
+ `${mark("warn")} catherd ${version} installed globally, but ${found}, so the plugin starts it with bunx`,
91
+ );
92
+ return console.log(
93
+ ` fix: put bun's global bin folder (bun pm bin -g) first on PATH, then run catherd init again`,
94
+ );
95
+ }
96
+ console.log(`${mark("warn")} could not install catherd globally: ${r.reason}`);
97
+ console.log(` fix: ${reinstallCommand(version)}`);
98
+ }
99
+
66
100
  /** What happened to the profile: written, kept, or (when the defaults do not validate here) why not. */
67
101
  export function profileLines(r: InitResult, plain = false): string[] {
68
102
  const mark = (state: State) => markOf(state, plain);
@@ -90,11 +124,16 @@ export const initCommand = defineCommand({
90
124
  meta: {
91
125
  name: "init",
92
126
  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",
127
+ "First run: the global catherd command, 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
128
  },
95
129
  args: {
96
130
  // citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
97
131
  "no-input": { type: "boolean", description: "ask nothing: keep what exists, else write the defaults" },
132
+ // read as global: false, like no-input above
133
+ "no-global": {
134
+ type: "boolean",
135
+ description: "do not install the global catherd command (bun add -g catherd-cli@<this version>)",
136
+ },
98
137
  profile: { type: "string", description: "the profile to set up and make active (default: default)" },
99
138
  plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
100
139
  },
@@ -107,6 +146,7 @@ export const initCommand = defineCommand({
107
146
  try {
108
147
  if (process.stdout.isTTY) for (const line of welcomeLines(VERSION)) console.log(line);
109
148
  console.log(`catherd ${VERSION}: setting up in ${configDir()}`);
149
+ await globalStep(VERSION, { skip: (args as { global?: boolean }).global === false, plain });
110
150
  await jevStep(ask, {}, plain);
111
151
  if (args.profile !== undefined) ask?.skip?.();
112
152
  const name = assertProfileName(
@@ -1,7 +1,11 @@
1
1
  import { defineCommand } from "citty";
2
+ import { restoreTmpdir } from "../../infra/env.ts";
2
3
  import { startMcpServer } from "./server.ts";
3
4
 
4
5
  export const mcpCommand = defineCommand({
5
6
  meta: { name: "mcp", description: "Run the catherd MCP server over stdio" },
6
- run: () => startMcpServer(),
7
+ run: () => {
8
+ restoreTmpdir(process.env);
9
+ return startMcpServer();
10
+ },
7
11
  });
@@ -2,37 +2,17 @@ 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 { cancel, dispatch, type Progress, wait } from "../../services/dispatch-service.ts";
5
+ import { cancel, dispatch } from "../../services/dispatch-service.ts";
6
+ import { peek } from "../../services/peek.ts";
6
7
  import type { Deps } from "../../services/ports.ts";
7
8
  import { handle } from "./result.ts";
8
9
 
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
10
  export function registerDispatchTools(server: McpServer, deps: Deps): void {
31
11
  server.registerTool(
32
12
  "dispatch",
33
13
  {
34
14
  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.",
15
+ "Start one role on a process backend (codex, claude-code, opencode) and return at launch, in about a second, with { dispatched: { name, role, rung, dispatchId, admittedAt }, hints }; the role runs on. When it finishes, catherd sends this session a <cross-session-message from-name=\"catherd\"> naming the run, the role and its status, and result(run, name) reads the record. Dispatch every independent role one after another, then end your turn. The brief is the text itself; dispatch appends the role's reply contract (reply length and the STATUS line), so do not write it. With lane, the lane file's Owns: paths guard against overlapping lanes, and a lane not yet routed is routed first (a rung off its routed ladder starts at the routed rung, with a hint). thread resumes that role's thread for its own fix round. Refused with a structured error (E_ADMIT_*, E_RUN_BUDGET, E_BACKEND_*) before anything starts. Call it from the main thread.",
36
16
  inputSchema: {
37
17
  run: z.string(),
38
18
  role: z.enum(ROLES),
@@ -48,35 +28,25 @@ export function registerDispatchTools(server: McpServer, deps: Deps): void {
48
28
  );
49
29
 
50
30
  server.registerTool(
51
- "wait",
31
+ "peek",
52
32
  {
53
33
  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.",
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.",
55
35
  inputSchema: {
56
- run: z.string(),
57
- names: z.array(z.string().regex(ID_PATTERN)).optional(),
58
- all: z.boolean().optional(),
36
+ run: z.string().optional(),
37
+ name: z.string().regex(ID_PATTERN).optional(),
59
38
  },
60
39
  },
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
- ),
40
+ (a) => handle(() => peek(deps, a)),
71
41
  );
72
42
 
73
43
  server.registerTool(
74
44
  "cancel",
75
45
  {
76
46
  description:
77
- "Stop a live dispatch (interrupt, then SIGTERM, then SIGKILL) and return its record, marked cancelled, and hints.",
47
+ "Stop a live dispatch (interrupt, then SIGTERM, then SIGKILL) and return its record, marked cancelled and read, and hints.",
78
48
  inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
79
49
  },
80
- (a) => handle(() => cancel(deps, a.run, a.name)),
50
+ (a) => handle(() => cancel(deps, a.run, a.name, { read: true })),
81
51
  );
82
52
  }
@@ -6,27 +6,37 @@ import { scrubSecrets } from "../../infra/env.ts";
6
6
  import { VERSION } from "../../infra/version.ts";
7
7
  import type { Handshake } from "../../services/doctor.ts";
8
8
 
9
- const CLI = fileURLToPath(new URL("../../cli.ts", import.meta.url));
10
- const TIMEOUT_MS = 20_000;
9
+ /** The plugin's MCP launcher, shipped in the package beside src/ (spec 1.1 §12). */
10
+ export const LAUNCHER = fileURLToPath(new URL("../../../plugin/bin/catherd-mcp", import.meta.url));
11
+ /** A cold `bunx catherd-cli@<version>` resolve took about 30 s in the 1.0.0 fresh install. */
12
+ const TIMEOUT_MS = 60_000;
13
+ const STDERR_TAIL = 4_000;
11
14
 
12
15
  /** The env the doctor's MCP server starts with: catherd's own secrets scrubbed, as for every process it starts. */
13
16
  export const handshakeEnv = (
14
17
  base: Record<string, string | undefined> = process.env,
15
18
  ): Record<string, string> => scrubSecrets(base);
16
19
 
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> {
20
+ /**
21
+ * Spec §10.3 and 1.1 §12: starts the MCP server the way the plugin does, `sh <launcher>` over stdio, and
22
+ * asks it for tools/list. What it writes to stderr comes back too, so doctor can say what broke.
23
+ */
24
+ export async function mcpHandshake(o: { launcher?: string } = {}): Promise<Handshake> {
19
25
  const client = new Client({ name: "catherd-doctor", version: VERSION });
20
26
  const transport = new StdioClientTransport({
21
- command: process.execPath,
22
- args: [CLI, "mcp"],
27
+ command: "sh",
28
+ args: [o.launcher ?? LAUNCHER],
23
29
  env: handshakeEnv(),
24
- stderr: "ignore",
30
+ stderr: "pipe",
31
+ });
32
+ let stderr = "";
33
+ transport.stderr?.on("data", (b: Buffer) => {
34
+ stderr = (stderr + b.toString()).slice(-STDERR_TAIL);
25
35
  });
26
36
  let timer: ReturnType<typeof setTimeout> | undefined;
27
37
  const late = new Promise<Handshake>((resolve) => {
28
38
  timer = setTimeout(
29
- () => resolve({ ok: false, tools: [], error: `no answer within ${TIMEOUT_MS / 1000} s` }),
39
+ () => resolve({ ok: false, tools: [], error: `no answer within ${TIMEOUT_MS / 1000} s`, stderr }),
30
40
  TIMEOUT_MS,
31
41
  );
32
42
  });
@@ -40,7 +50,7 @@ export async function mcpHandshake(): Promise<Handshake> {
40
50
  late,
41
51
  ]);
42
52
  } catch (e) {
43
- return { ok: false, tools: [], error: errorMessage(e) };
53
+ return { ok: false, tools: [], error: errorMessage(e), stderr };
44
54
  } finally {
45
55
  clearTimeout(timer);
46
56
  await client.close().catch(() => {}); // the answer (or the failure) is already in hand
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { ID_PATTERN } from "../../domain/ids.ts";
4
4
  import { ROLES } from "../../domain/roles.ts";
5
5
  import { CLIMB_REASONS } from "../../domain/route.ts";
6
- import { ask, climb, land, route } from "../../services/lane-service.ts";
6
+ import { ask, climb, LAND_SKIPS, land, route } from "../../services/lane-service.ts";
7
7
  import type { Deps } from "../../services/ports.ts";
8
8
  import { preflight } from "../../services/preflight.ts";
9
9
  import { handle } from "./result.ts";
@@ -13,7 +13,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
13
13
  "route",
14
14
  {
15
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.",
16
+ "The rung for a lane (from Jev, else the lane file's Kind/Difficulty lines, else the profile default) or, without a lane file, a role's default rung, with the ladder above it. Rungs are backend:model#effort; a claude: rung comes with the agent to run it as. A lane whose Kind: or Difficulty: the catalog does not know is refused with E_LANE_INVALID.",
17
17
  inputSchema: {
18
18
  run: z.string(),
19
19
  lane_file: z.string().optional(),
@@ -27,7 +27,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
27
27
  "preflight",
28
28
  {
29
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.",
30
+ "Run each lane's fast check once, on the base tree, behind the heavy-command lock, with a 120 s timeout. Each lane is pass, fails-as-expected, skipped (it checks a file the lane creates) or cannot-start; only cannot-start blocks. When the profile asks for confirmation, the first call returns the commands to show the user; call again with confirmed: true. Refused with E_LANE_INVALID, running nothing, while any lane's Kind: or Difficulty: is not one the catalog knows.",
31
31
  inputSchema: { run: z.string(), confirmed: z.boolean().optional() },
32
32
  },
33
33
  (a) => handle(() => preflight(deps, { run: a.run, confirmed: a.confirmed })),
@@ -37,7 +37,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
37
37
  "climb",
38
38
  {
39
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.",
40
+ "Move a routed lane one rung up its ladder and record why. Returns the next rung, or top: true, and any hints. Dispatch the lane again at that rung on a fresh thread. Pass env: true when the environment caused it (a missing service, a broken tool, a usage limit), not the rung. Refused with E_CLIMB_DESIGN when the evidence is a design question (Jev's finding answer is design, or a blocked climb's evidence is about lane ownership): send it to the architect instead.",
41
41
  inputSchema: {
42
42
  run: z.string(),
43
43
  lane: z.string().regex(ID_PATTERN),
@@ -67,15 +67,16 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
67
67
  "land",
68
68
  {
69
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.",
70
+ "Record a landed milestone after you commit it: its five-column ledger row (with the minutes it took) and state.md's last check and next step, with any hints. learned, when given, goes to this repo's knowledge.md. Returns digest: the milestone's digest (R/digests/<milestone>.md: A-lines, commit, lanes with rungs and climbs, reviewer findings, the verifier's verdict with carried items, minutes and tokens), for the milestone push to link. Refused with E_LAND_GATE unless, since the milestone's lanes started, a reviewer named reviewer-<milestone> ended ok (a dispatch, or a Claude subagent recorded with record_agent_run, role reviewer) and a verifier verdict naming the milestone was recorded ok (record_agent_run, role verifier; a headless verifier's reply opening VERDICT: PASS). A skip over an empty commit range is refused: commit first. skip: docs-only lands a milestone whose commit range changed only docs; skip: no-code one that changed no source file (put the evidence, e.g. a green pipeline, in evidence).",
71
71
  inputSchema: {
72
72
  run: z.string(),
73
- milestone: z.string().min(1),
73
+ milestone: z.string().regex(ID_PATTERN),
74
74
  what: z.string().min(1),
75
75
  commit: z.string().regex(/^[0-9a-f]{7,40}$/),
76
76
  evidence: z.string().min(1),
77
77
  next: z.string().min(1),
78
78
  learned: z.string().min(1).optional(),
79
+ skip: z.enum(LAND_SKIPS).optional(),
79
80
  },
80
81
  },
81
82
  (a) => handle(() => land(deps, a)),