catherd-cli 1.3.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 (159) hide show
  1. package/README.md +144 -42
  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 +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "./host.ts";
1
2
  import { z } from "zod";
2
3
  import type { Budget } from "./budget.ts";
3
4
  import { BILLING_KEYS, type BillingKey } from "./catalog.ts";
@@ -44,6 +45,7 @@ const RoleDocSchema = z.looseObject({
44
45
  rungs: z.array(z.string()).optional(),
45
46
  defaultRung: z.string().optional(),
46
47
  network: z.boolean().optional(),
48
+ timeouts: z.looseObject({ idleMin: positive.optional(), wallMin: positive.optional() }).optional(),
47
49
  });
48
50
 
49
51
  export const ProfileDocSchema = z.looseObject({
@@ -116,8 +118,19 @@ export type RoleConfig = {
116
118
  defaultRung?: string;
117
119
  /** spec §5: false drops a workspace-write role's network, loopback and Docker grants; absent means granted */
118
120
  network?: false;
121
+ /** plan 23: this role's own idle and wall timeouts, over the profile's `timeouts` (a long gate, a slow model) */
122
+ timeouts?: { idleMin?: number; wallMin?: number };
119
123
  };
120
124
 
125
+ /** The idle and wall timeouts a role runs under: its own `timeouts`, else the profile's. */
126
+ export const roleTimeouts = (
127
+ profile: { timeouts: { idleMin: number; wallMin: number } },
128
+ role: { timeouts?: { idleMin?: number; wallMin?: number } } | undefined,
129
+ ): { idleMin: number; wallMin: number } => ({
130
+ idleMin: role?.timeouts?.idleMin ?? profile.timeouts.idleMin,
131
+ wallMin: role?.timeouts?.wallMin ?? profile.timeouts.wallMin,
132
+ });
133
+
121
134
  /** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
122
135
  export interface Profile {
123
136
  name: string;
@@ -229,7 +242,7 @@ export const PAIRED_FAILOVER: Record<string, string> = Object.fromEntries(
229
242
  /** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
230
243
  const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
231
244
 
232
- /** The full default profile document, every field written out: what `init` and `profile new` save. */
245
+ /** Stored defaults for init/new; architect/verifier rungs resolve from the current host. */
233
246
  export function defaultProfileDoc(name = "default"): ProfileDoc {
234
247
  return {
235
248
  schema: PROFILE_SCHEMA,
@@ -237,7 +250,7 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
237
250
  objective: "cost",
238
251
  jev: { use: "auto" },
239
252
  billing: Object.fromEntries(WRITTEN_BILLING.map((k) => [k, DEFAULT_BILLING[k]])),
240
- roles: structuredClone(BUILTIN_ROLES),
253
+ roles: hostDefaultsDoc({ schema: PROFILE_SCHEMA, roles: structuredClone(BUILTIN_ROLES) }).roles,
241
254
  harness: {
242
255
  codex: { isolated: false },
243
256
  "claude-code": { isolated: false },
@@ -252,12 +265,38 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
252
265
  };
253
266
  }
254
267
 
255
- /** Spec §7.1: every field a document leaves out takes its default; a missing role takes the built-in one. */
256
- export function resolveProfile(doc: ProfileDoc, name: string): Profile {
268
+ /** Remove only the four architect/verifier execution choices, preserving other stored fields. */
269
+ export function hostDefaultsDoc(doc: ProfileDoc): ProfileDoc {
270
+ const next = structuredClone(doc);
271
+ for (const role of ["architect", "verifier"] as const) {
272
+ const r = next.roles?.[role];
273
+ if (r) {
274
+ delete r.rungs;
275
+ delete r.defaultRung;
276
+ }
277
+ }
278
+ return next;
279
+ }
280
+
281
+ /** Resolve omitted role execution choices from the explicit orchestration host. */
282
+ export function resolveProfile(doc: ProfileDoc, name: string, host: OrchestrationHost): Profile {
257
283
  const roles = {} as Record<Role, RoleConfig>;
258
284
  for (const role of ROLES) {
259
285
  const d = doc.roles?.[role];
260
286
  const b = BUILTIN_ROLES[role];
287
+ let omitted = b.rungs;
288
+ if ((role === "architect" || role === "verifier") && d?.rungs === undefined) {
289
+ if (host === "unknown" && (d?.enabled ?? b.enabled))
290
+ throw new CatherdError("E_CONFIG_INVALID", `roles.${role}.rungs needs an orchestration host`, {
291
+ fix: "pass --host codex or --host claude-code, or set explicit role rungs",
292
+ });
293
+ omitted =
294
+ host === "codex"
295
+ ? [`codex:gpt-6.1-sol#${role === "architect" ? "high" : "low"}`]
296
+ : host === "unknown"
297
+ ? []
298
+ : b.rungs;
299
+ }
261
300
  // a role that lists its own rungs never inherits the built-in default rung, which may not be among them
262
301
  const defaultRung = d?.defaultRung ?? (d?.rungs === undefined ? b.defaultRung : undefined);
263
302
  roles[role] = {
@@ -268,9 +307,17 @@ export function resolveProfile(doc: ProfileDoc, name: string): Profile {
268
307
  : known(ACCESS, d.access)
269
308
  ? d.access
270
309
  : STORED_FALLBACK.access,
271
- rungs: [...(d?.rungs ?? b.rungs)],
310
+ rungs: [...(d?.rungs ?? omitted)],
272
311
  ...(defaultRung ? { defaultRung } : {}),
273
312
  ...(d?.network === false ? { network: false as const } : {}),
313
+ ...(d?.timeouts?.idleMin !== undefined || d?.timeouts?.wallMin !== undefined
314
+ ? {
315
+ timeouts: {
316
+ ...(d.timeouts.idleMin !== undefined ? { idleMin: d.timeouts.idleMin } : {}),
317
+ ...(d.timeouts.wallMin !== undefined ? { wallMin: d.timeouts.wallMin } : {}),
318
+ },
319
+ }
320
+ : {}),
274
321
  };
275
322
  }
276
323
  const harness: Profile["harness"] = {};
@@ -317,6 +364,10 @@ const RolePatchSchema = z
317
364
  rungs: z.array(RungSchema),
318
365
  defaultRung: RungSchema.nullable(),
319
366
  network: z.boolean().nullable(),
367
+ timeouts: z
368
+ .strictObject({ idleMin: positive.nullable(), wallMin: positive.nullable() })
369
+ .partial()
370
+ .nullable(),
320
371
  })
321
372
  .partial();
322
373
 
@@ -6,7 +6,8 @@ export type Access = (typeof ACCESS)[number];
6
6
  const RUN_STATUSES = ["ok", "failed", "limit", "cli-too-old", "timeout", "cancelled"] as const;
7
7
  export type RunStatus = (typeof RUN_STATUSES)[number];
8
8
 
9
- const REPLY_STATUSES = ["complete", "partial", "blocked", "refused"] as const;
9
+ /** plan 23: `flaky`, a worker's word for a check that failed, then passed when run alone */
10
+ const REPLY_STATUSES = ["complete", "partial", "blocked", "refused", "flaky"] as const;
10
11
  export type ReplyStatus = (typeof REPLY_STATUSES)[number];
11
12
 
12
13
  export interface Tokens {
@@ -27,6 +28,8 @@ export const EXIT_REASONS = [
27
28
  "wall-timeout",
28
29
  "cancelled",
29
30
  "lost",
31
+ /** plan 23: the provider kept failing (retries with no progress); failover treats it as a usage limit */
32
+ "provider-unavailable",
30
33
  ] as const;
31
34
  export type ExitReason = (typeof EXIT_REASONS)[number];
32
35
  export interface ExitInfo {
@@ -65,6 +68,8 @@ export const RunRecordSchema = z.looseObject({
65
68
  gitUnavailable: z.boolean().optional(),
66
69
  replyStatus: z.enum(REPLY_STATUSES).nullable(),
67
70
  replyWhy: z.string().nullable(),
71
+ /** plan 23: the reply's ENV: line, when the environment stopped the role; absent otherwise */
72
+ environment: z.string().optional(),
68
73
  threadHeavy: z.boolean(),
69
74
  access: z.enum(ACCESS),
70
75
  isolated: z.boolean(),
@@ -75,10 +80,20 @@ export const RunRecordSchema = z.looseObject({
75
80
  replyPath: z.string(),
76
81
  /** the Claude Code session that dispatched it, copied from admit.json (spec §3.3); absent before 1.1 */
77
82
  sessionId: z.string().optional(),
83
+ host: z.enum(["claude-code", "codex"]).optional(),
78
84
  });
79
85
  export type RunRecord = z.infer<typeof RunRecordSchema>;
80
86
 
81
- const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused)\s*(?:—|–|-)\s*(.*)$/;
87
+ const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused|flaky)\s*(?:—|–|-)\s*(.*)$/;
88
+
89
+ /**
90
+ * Plan 23: what stopped the role, when the environment did: an `ENV: <what>` line of its reply (a VPN, no
91
+ * Docker, a dead registry); null without one.
92
+ */
93
+ export function parseReplyEnvironment(reply: string): string | null {
94
+ const m = /^[ \t]*ENV:[ \t]*(\S.*?)[ \t]*$/m.exec(reply);
95
+ return m ? (m[1] as string) : null;
96
+ }
82
97
 
83
98
  /** The role's claim about its own work: the reply's last line, `STATUS: <s> — <why>`. */
84
99
  export function parseReplyStatus(reply: string): { status: ReplyStatus | null; why: string | null } {
@@ -0,0 +1,29 @@
1
+ import { parseReplyStatus } from "./record.ts";
2
+
3
+ /**
4
+ * Plan 22: a final reply never overwrites the report. `finals` are the messages that ended each of the run's
5
+ * turns, in order (a background command's notification can start another turn after the report); `last` is the
6
+ * reply the CLI kept, the last of them. With more than one, the reply is the longest STATUS-bearing message, the
7
+ * later ones after it under `later:`, and the report's STATUS line last, so the record's STATUS is the report's.
8
+ * Otherwise `last` is the reply, unchanged.
9
+ */
10
+ export function composeReply(finals: string[], last: string): string {
11
+ if (finals.length < 2) return last;
12
+ let report = -1;
13
+ for (const [i, f] of finals.entries())
14
+ if (
15
+ parseReplyStatus(f).status !== null &&
16
+ (report < 0 || f.trim().length >= finals[report]!.trim().length)
17
+ )
18
+ report = i;
19
+ if (report < 0 || report === finals.length - 1) return last;
20
+ const lines = finals[report]!.trimEnd().split("\n");
21
+ const status = lines.pop() as string;
22
+ const later = finals
23
+ .slice(report + 1)
24
+ .map((f) => f.trim())
25
+ .filter(Boolean);
26
+ if (!later.length) return last;
27
+ const body = lines.join("\n").trimEnd();
28
+ return [...(body ? [body, ""] : []), "later:", later.join("\n\n"), "", status.trim(), ""].join("\n");
29
+ }
@@ -1,19 +1,36 @@
1
1
  import type { Access } from "./record.ts";
2
+ import { VERIFIER_GATE_RULES } from "./gate-brief.ts";
3
+ import { COORDINATOR_TOOLS } from "./role-scope.ts";
2
4
  import type { Role } from "./roles.ts";
3
5
 
4
6
  // The role prompts of the native Claude subagents, ported from 0.x (spec D9). Headless backends get the
5
7
  // same text from the orchestrator's briefs; only native agents carry it in their agent file.
6
8
 
7
- const RUN_FILES =
8
- "The run folder is outside the project. Read and write it only through the catherd MCP tools read_run_file and write_run_file (in the tool list as mcp__plugin_catherd_catherd__read_run_file and mcp__plugin_catherd_catherd__write_run_file), with the run id the orchestrator gave you. Paths are relative to the run folder.";
9
+ /**
10
+ * How a role reaches its run folder, naming only the tools that role gets (spec 1.5 plan 21, #42 finding 5): every
11
+ * role reads, the architect and the researcher also write. The CLI forms are the fallback when no tool is listed.
12
+ */
13
+ const runFiles = (writes: boolean): string =>
14
+ writes
15
+ ? "The run folder is outside the project. Read and write it only through catherd's read_run_file and write_run_file, with the run id the orchestrator gave you (native Claude subagents: mcp__plugin_catherd_catherd__<name>; headless Codex and Claude Code roles: mcp__catherd_role__<name>). When neither is listed, run catherd run-file read <run> <path>, and catherd run-file write <run> <path> with the content on stdin. Paths are relative to the run folder."
16
+ : "The run folder is outside the project. Read it only through catherd's read_run_file, with the run id the orchestrator gave you (native Claude subagents: mcp__plugin_catherd_catherd__read_run_file; headless Codex and Claude Code roles: mcp__catherd_role__read_run_file). When neither is listed, run catherd run-file read <run> <path>. Paths are relative to the run folder.";
9
17
 
10
18
  const REPLY =
11
19
  "Do not commit. Reply in at most 15 lines: results, file:line, and evidence as a log path, not the log. The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>";
12
20
 
21
+ /**
22
+ * The worker's reply contract. Plan 22, resume hygiene: a worker's thread may be resumed for its fix round, and a
23
+ * command its last turn left running in the background ends the resumed CLI (exit 143). Plan 23: acceptance that
24
+ * builds from HEAD is the verifier's, since a worker does not commit; an environment that stopped it goes on an ENV:
25
+ * line (climb refuses it); a check that failed, then passed alone, is STATUS: flaky with that evidence.
26
+ */
27
+ const WORKER_REPLY =
28
+ "Before you reply, leave nothing running: stop every server, watcher or command you started in the background. Do not commit. Acceptance items that build from HEAD (git archive, a commit's image) belong to the verifier: do not run them, and never commit to get them to run. Reply in at most 15 lines: results, file:line, and evidence as a log path, not the log. When the environment stopped you (no Docker, a VPN, a dead registry), add a line ENV: <what>. The last line of your reply is: STATUS: complete|partial|blocked|refused|flaky — <one line why>; flaky means a check failed, then passed when run alone: name it, and how often each.";
29
+
13
30
  const architect = [
14
31
  "You are the architect of a catherd run. The orchestrator gave you the goal, the acceptance lines, the run id, and usually a dossier: a researcher's map of the code this work touches. Workers are other models that run in the project directory with no memory of this conversation. They will write every line of code from your plan.",
15
32
  "",
16
- `Start from the dossier. Open a project file yourself only to settle a decision the dossier leaves open, and read each file once. Bash is for inspection only. Change nothing in the project. ${RUN_FILES}`,
33
+ `Start from the dossier. Open a project file yourself only to settle a decision the dossier leaves open, and read each file once. Bash is for inspection only. Change nothing in the project. ${runFiles(true)}`,
17
34
  "",
18
35
  "When the acceptance lines include plan: <path>[, <path>…], the user already has a plan and there is no dossier. Translate it, do not design: each plan task becomes lanes, each merge request or phase a milestone with its full check. Copy the plan's decisions into plan.md and the lane files, and decide only what the plan leaves undecided.",
19
36
  "",
@@ -24,7 +41,7 @@ const architect = [
24
41
  "3. Lanes inside each milestone, M1.L1…: each lane gives",
25
42
  " - the files it owns (two lanes of one milestone never share a file);",
26
43
  " - what changes, stated as behavior plus the exact signatures and data shapes it introduces;",
27
- " - its fast check: the command a worker reruns while it works, seconds to a minute or two: its targeted tests plus the linter, and the type check when the project has one, scoped to the packages the lane owns.",
44
+ " - its fast check: the command a worker reruns while it works, seconds to a minute or two: its targeted tests plus the linter of every package the lane touches (each package's own: golangci-lint for a Go module, its lint script for a JS one), and the type check when the project has one, scoped to those packages.",
28
45
  "",
29
46
  " Every lane of a milestone runs at the same time, so split for width: more small lanes beat one long one.",
30
47
  "4. Speed: if the full check takes more than about five minutes, name why and make speeding it up (parallel tests, one shared fixture, fewer real-time waits) a lane of the first milestone.",
@@ -41,6 +58,8 @@ const architect = [
41
58
  "",
42
59
  "catherd reads the Owns: line to keep two running lanes off the same file, and to tell a refusal (owned files unchanged) from work. It reads Kind: and Difficulty: to pick the lane's model when its router is unsure.",
43
60
  "",
61
+ "Two optional lines follow the five when they apply: `After: Mx.Ly, …` when the lane compiles against another lane's changes (catherd dispatches it only once those finish), and `Allow: path[:line], …` under a fast check that greps for absence, naming the hits that are allowed exceptions. Write such greps with word boundaries.",
62
+ "",
44
63
  "Signatures, data shapes and test case names are yours. Function bodies are the worker's. A plan that contains the implementation turns the worker into a typist and spends the most expensive model on typing.",
45
64
  "",
46
65
  "Prefer the smallest design that meets the acceptance lines: no abstraction with one implementation, no config for a value that never changes, no comments restating code.",
@@ -54,17 +73,21 @@ const verifier = [
54
73
  "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
74
  "",
56
75
  "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.",
76
+ " - First call gate_check with only the run id and the milestone: it lists the items already recorded for it (reuse their names; re-check the failed ones first, each command capped at 10 minutes) and the repo's gate environment (export it before the first item; a $NAME value is a secret in that env var).",
77
+ " - Before each item, call the catherd MCP tool gate_check (mcp__catherd_role__gate_check for native headless Codex/Claude Code roles; mcp__plugin_catherd_catherd__gate_check for native Claude subagents) 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.",
78
+ " - After an item passes, call gate_pass (mcp__catherd_role__gate_pass, or mcp__plugin_catherd_catherd__gate_pass for native Claude subagents) with the same item, command and paths, and the evidence.",
79
+ " - When neither tool is listed, run the same from your shell: catherd gate check <run> --milestone <M> --item <item> --command <command> --paths <path,…>, then catherd gate pass <run> --item <item> --command <command> --paths <path,…> --evidence <evidence>.",
80
+ " - Run independent items side by side, each heavy one wrapped in catherd lock --role verifier -- <command>, which queues them within the machine's slots and keeps another run's verifier from running at the same time.",
60
81
  " - Build each commit's images once, and reuse them for the boot check and the acceptance suite.",
61
82
  "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.",
62
83
  "3. Read the diff (git diff plus untracked files) for bugs the acceptance lines miss: wrong edge behavior, dead code, leftovers.",
63
84
  "",
64
85
  "Change nothing in the project. Do not write mutation tests or extra proof tests. The job is to find out whether the work is right, not to grade its test suite.",
65
86
  "",
87
+ ...VERIFIER_GATE_RULES,
88
+ "",
66
89
  "Return, in this order:",
67
- "- VERDICT: PASS or VERDICT: FAIL on the first line.",
90
+ "- VERDICT: PASS or VERDICT: FAIL on the first line, or VERDICT: BLOCKED: environment — <the probe> when the machine stopped the gate.",
68
91
  "- One line per acceptance line: A<n> PASS|FAIL, the command you ran and the decisive output.",
69
92
  "- One line per gate item: PASS|FAIL, or carried over from <commit>.",
70
93
  "- Bugs outside the acceptance lines: file:line, what happens, the input that triggers it.",
@@ -75,11 +98,11 @@ const worker = (version: string) =>
75
98
  [
76
99
  "You are a worker in a catherd run. The orchestrator's message is your brief: the acceptance lines, the lane file to read, the files you own and the files you must not touch, and your fast check.",
77
100
  "",
78
- `Read your lane file first. ${RUN_FILES} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
101
+ `Read your lane file first. ${runFiles(false)} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
79
102
  "",
80
103
  `Change only the files you own. Run your fast check until it passes. Run the full suite only if the brief says so, and wrap any full build or full test suite in: bunx catherd-cli@${version} lock -- <command>. Other lanes share this machine.`,
81
104
  "",
82
- REPLY,
105
+ WORKER_REPLY,
83
106
  ].join("\n");
84
107
 
85
108
  const reviewer = [
@@ -123,7 +146,7 @@ const writer = [
123
146
  const researcher = [
124
147
  "You answer a factual question about the code, or map it for a dossier. Change nothing in the project. Give file:line for every claim.",
125
148
  "",
126
- `A dossier lists: the files and folders involved, one line each on what they hold; the existing patterns the work should copy, by path; the build, test and run commands, with how long the full suite takes when the docs, the CI config or a log say so (never run the suite to find out; write "unknown" instead); the lint and type-check commands, and how to scope each to one package; the symbols the change will call or alter, with their signatures; the project rules (CLAUDE.md, AGENTS.md, conventions) that bind this work; and risks: shared files, generated code, slow or flaky tests. It may run to 200 lines. Write it with write_run_file to the path the brief names, and reply with that path. ${RUN_FILES}`,
149
+ `A dossier lists: the files and folders involved, one line each on what they hold; the existing patterns the work should copy, by path; the build, test and run commands, with how long the full suite takes when the docs, the CI config or a log say so (never run the suite to find out; write "unknown" instead); the lint and type-check commands, and how to scope each to one package; the symbols the change will call or alter, with their signatures; the project rules (CLAUDE.md, AGENTS.md, conventions) that bind this work; and risks: shared files, generated code, slow or flaky tests. It may run to 200 lines. Write it with write_run_file to the path the brief names, and reply with that path. ${runFiles(true)}`,
127
150
  "",
128
151
  "For a single question, reply in at most 15 lines. The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>",
129
152
  ].join("\n");
@@ -148,8 +171,8 @@ const STATUS_LINE =
148
171
  */
149
172
  const CONTRACTS: Record<Role, string> = {
150
173
  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,
174
+ verifier: `The first line of your reply is VERDICT: PASS or VERDICT: FAIL, or VERDICT: BLOCKED: environment — <the probe that proves it> when the machine, not the work, stops the gate (run that probe twice, 5 s apart, before you call the host blocked). ${STATUS_LINE}`,
175
+ worker: WORKER_REPLY,
153
176
  reviewer: REPLY,
154
177
  "ui-reviewer": REPLY,
155
178
  artist: REPLY,
@@ -169,10 +192,27 @@ export function withReplyContract(role: Role, brief: string): string {
169
192
  /** The role's prompt; the worker's names the catherd version whose `lock` it must use. */
170
193
  export const rolePrompt = (role: Role, version: string): string => BODIES[role](version);
171
194
 
195
+ /** The catherd plugin's MCP tool names as a native Claude subagent sees them. */
196
+ export const NATIVE_TOOL_PREFIX = "mcp__plugin_catherd_catherd__";
197
+
198
+ /**
199
+ * The catherd tools a native subagent may never call: the coordinator tools, and record_agent_run, which only the
200
+ * orchestrator calls (a native verifier recording its own `ok` would satisfy land's evidence).
201
+ */
202
+ const NATIVE_FORBIDDEN = [...COORDINATOR_TOOLS, "record_agent_run"] as const;
203
+
204
+ /**
205
+ * Spec 1.5 plan 21: a native Claude subagent shares the orchestrator's MCP server, so it has no env of its own to
206
+ * refuse it by. Its agent file forbids the coordinator tools and record_agent_run, and its prompt says why.
207
+ */
208
+ export const NOT_THE_ORCHESTRATOR = `You are a role of a catherd run, not its orchestrator. Never call catherd's ${NATIVE_FORBIDDEN.join(", ")}: they belong to the orchestrator, which reads your reply.`;
209
+
172
210
  /**
173
211
  * Spec D10 for native subagents, whose only lever is the agent file's tool list: read-only drops the
174
212
  * editing tools (its Bash stays, for inspection, so enforcement is advisory); every role drops Agent,
175
- * so a role never spawns its own subagents.
213
+ * so a role never spawns its own subagents, and catherd's coordinator tools and record_agent_run (spec 1.5 plan 21).
176
214
  */
177
- export const nativeDisallowedTools = (access: Access): string[] =>
178
- access === "read-only" ? ["Write", "Edit", "NotebookEdit", "Agent"] : ["Agent"];
215
+ export const nativeDisallowedTools = (access: Access): string[] => [
216
+ ...(access === "read-only" ? ["Write", "Edit", "NotebookEdit", "Agent"] : ["Agent"]),
217
+ ...NATIVE_FORBIDDEN.map((tool) => `${NATIVE_TOOL_PREFIX}${tool}`),
218
+ ];
@@ -0,0 +1,106 @@
1
+ import { CatherdError } from "./errors.ts";
2
+ import { ID_PATTERN } from "./ids.ts";
3
+
4
+ /**
5
+ * Spec 1.5 plan 21: the env var the supervisor sets in every role's process, `<run>/<role-name>`. A process
6
+ * that carries it is a role of that run: it never owns the run and never reaches the coordinator tools.
7
+ */
8
+ export const ROLE_ENV = "CATHERD_ROLE";
9
+
10
+ /** The run and the dispatch name a role process works for. */
11
+ export interface RoleScope {
12
+ run: string;
13
+ name: string;
14
+ }
15
+
16
+ const isId = (s: string): boolean => ID_PATTERN.test(s) && !s.includes("..");
17
+
18
+ export const formatRoleScope = (s: RoleScope): string => `${s.run}/${s.name}`;
19
+
20
+ /** `<run>/<role-name>`, else null (unset, empty, or not two ids). */
21
+ export function parseRoleScope(value: string | undefined): RoleScope | null {
22
+ if (!value) return null;
23
+ const [run, name, ...rest] = value.split("/");
24
+ if (rest.length > 0 || !run || !name || !isId(run) || !isId(name)) return null;
25
+ return { run, name };
26
+ }
27
+
28
+ /** A run id as `createRun` makes it: `YYYYMMDD-HHMMSS-<slug>`. */
29
+ const RUN_ID = /^\d{8}-\d{6}-/;
30
+
31
+ /**
32
+ * The role a temp dir names, when it is a role's scratch, `…/runs/<run>/scratch/<name>[/…]`: Codex passes an
33
+ * MCP server only a short list of env vars, TMPDIR among them and CATHERD_ROLE not, so a server a role's
34
+ * Codex starts knows it is a role by its TMPDIR.
35
+ */
36
+ export function roleScopeOfScratch(tmpdir: string | undefined): RoleScope | null {
37
+ if (!tmpdir) return null;
38
+ const parts = tmpdir.split(/[\\/]+/).filter(Boolean);
39
+ for (let i = parts.length - 2; i >= 2; i--) {
40
+ if (parts[i] !== "scratch" || parts[i - 2] !== "runs") continue;
41
+ const run = parts[i - 1] as string;
42
+ const name = parts[i + 1] as string;
43
+ if (RUN_ID.test(run)) return parseRoleScope(`${run}/${name}`);
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** The role this process works for: CATHERD_ROLE, else a TMPDIR that is a role's scratch, else null. */
49
+ export function roleScopeFromEnv(env: Record<string, string | undefined>): RoleScope | null {
50
+ return parseRoleScope(env[ROLE_ENV]) ?? roleScopeOfScratch(env.TMPDIR);
51
+ }
52
+
53
+ /**
54
+ * The tools only the orchestrator calls: each one claims the run, steers a role or changes the run's plan
55
+ * (spec 1.5 plan 21). A role process gets E_ROLE_SCOPE for them.
56
+ */
57
+ export const COORDINATOR_TOOLS = [
58
+ "peek",
59
+ "result",
60
+ "dispatch",
61
+ "run_start",
62
+ "climb",
63
+ "land",
64
+ "park",
65
+ "cancel",
66
+ "set_next",
67
+ "answer",
68
+ "profile_set",
69
+ "workspace_start",
70
+ "workspace_contract",
71
+ "workspace_child_start",
72
+ "test_push",
73
+ "workspace_budget",
74
+ "workspace_pause",
75
+ "workspace_resume",
76
+ "lane_set",
77
+ "owns_add",
78
+ "run_pin",
79
+ ] as const;
80
+
81
+ const COORDINATOR = new Set<string>(COORDINATOR_TOOLS);
82
+
83
+ export const isCoordinatorTool = (tool: string): boolean => COORDINATOR.has(tool);
84
+
85
+ /**
86
+ * Whether a role process may not make this call: every coordinator tool, except a `peek` of its own
87
+ * dispatch (its run and its own name) and a `workspace_contract` read (no `content`).
88
+ */
89
+ export function refusedForRole(tool: string, args: unknown, scope: RoleScope): boolean {
90
+ if (!COORDINATOR.has(tool)) return false;
91
+ const a = (args ?? {}) as Record<string, unknown>;
92
+ if (tool === "peek") return !(a.run === scope.run && a.name === scope.name);
93
+ if (tool === "workspace_contract") return a.content !== undefined;
94
+ return true;
95
+ }
96
+
97
+ /** E_ROLE_SCOPE: the error a role process gets for a coordinator tool, with the line that fixes it. */
98
+ export function roleScopeError(tool: string, scope: RoleScope): CatherdError {
99
+ return new CatherdError(
100
+ "E_ROLE_SCOPE",
101
+ `${tool} is the orchestrator's: this process runs ${scope.name} of run ${scope.run}`,
102
+ {
103
+ fix: `a role reports through its reply; read its run's files with read_run_file, or catherd run-file read ${scope.run} <path>`,
104
+ },
105
+ );
106
+ }
@@ -0,0 +1,34 @@
1
+ import type { Role } from "./roles.ts";
2
+
3
+ export interface RoleMcpContext {
4
+ run: string;
5
+ role: Role;
6
+ }
7
+
8
+ export const ROLE_MCP_SERVER = "catherd_role";
9
+
10
+ /** Spec 1.5 plan 21: the backends whose harness gets the role server, isolated or not. */
11
+ export const ROLE_SERVER_BACKENDS: readonly string[] = ["codex", "claude-code"];
12
+
13
+ /**
14
+ * Spec 1.5 plan 21: the backends whose sandbox catherd grants the role's scratch, so they get it as TMPDIR.
15
+ * Cursor, Grok and agy write their grants once per isolated home, not per dispatch: they keep the inherited one.
16
+ */
17
+ export const SCRATCH_BACKENDS: readonly string[] = ["codex", "claude-code", "opencode"];
18
+
19
+ /**
20
+ * Whether a role on `backend` gets its scratch as TMPDIR. opencode only when isolated (plan 21 ruling 22): without
21
+ * --standalone its tools run in the shared background service, whose env the client cannot set.
22
+ */
23
+ export function grantsScratch(backend: string, isolated: boolean): boolean {
24
+ if (backend === "opencode") return isolated;
25
+ return SCRATCH_BACKENDS.includes(backend);
26
+ }
27
+
28
+ /** Run artifacts and gate evidence have their own authority, independent of repository writes. */
29
+ export function roleMcpTools(role: Role): string[] {
30
+ const tools = ["read_run_file", "read_knowledge"];
31
+ if (role === "architect" || role === "researcher") tools.push("write_run_file");
32
+ if (role === "verifier") tools.push("gate_check", "gate_pass");
33
+ return tools;
34
+ }
@@ -43,6 +43,35 @@ export interface RouteRow {
43
43
  jev?: RouteJev | null;
44
44
  /** climb rows: the climb was caused by the environment, not the rung's capability (spec §5.6) */
45
45
  env?: boolean;
46
+ /** route rows (spec 1.5 plan 24): the decision in one line, as `route` returned it */
47
+ why?: string;
48
+ /** route rows: where Jev disagreed with the lane's declared Kind/Difficulty */
49
+ jevSaid?: string;
50
+ /** route rows: no rung clears the lane's bar, and the closest one */
51
+ noClear?: string;
52
+ /** route rows: rungs of equal scores on other quotas competed for the start, and what decided */
53
+ tie?: string;
54
+ /** route rows: the chosen rung's thresholds, values and sources (spec 1.2 §5.3), kept out of `route`'s answer */
55
+ provenance?: unknown;
56
+ }
57
+
58
+ /**
59
+ * Spec 1.5 plan 24: one routes.jsonl row for a role's decision outside a lane: `route` without a lane file, or
60
+ * a lane-less `dispatch` (its `name`). `readRoutes` skips these rows: they have no lane to climb.
61
+ */
62
+ export interface RoleRouteRow {
63
+ at: string;
64
+ lane: null;
65
+ role: Role;
66
+ /** the dispatch's name; null for a `route` */
67
+ name: string | null;
68
+ rung: string;
69
+ ladder: string[];
70
+ source: "route" | "dispatch";
71
+ /** who chose the rung: the router (its source), or the coordinator passing `rung` to `dispatch` */
72
+ decidedBy: RouteSource | "orchestrator";
73
+ why: string;
74
+ provenance?: unknown;
46
75
  }
47
76
 
48
77
  export function nextRung(ladder: string[], current: string): string | null {
@@ -118,6 +147,50 @@ export function laneOutcome(rows: RouteRow[], lane: string, landed: boolean, at:
118
147
  };
119
148
  }
120
149
 
150
+ /**
151
+ * Spec 1.5 plan 24: a lane's final outcome in routes.jsonl, beside the route row that holds Jev's answer, for
152
+ * calibrating Jev's difficulty question: what Jev said and what the route used, whether the lane climbed, and
153
+ * how it ended. `readRoutes` skips these rows; outcomes.jsonl keeps its own (spec §5.6).
154
+ */
155
+ export interface OutcomeRouteRow {
156
+ at: string;
157
+ lane: string;
158
+ source: "outcome";
159
+ decidedBy: RouteSource;
160
+ kind: Kind | null;
161
+ difficulty: Difficulty | null;
162
+ questionSet: string | null;
163
+ jev: RouteJev | null;
164
+ /** some climb moved the lane to another rung (an environment climb included) */
165
+ climbed: boolean;
166
+ startRung: string;
167
+ finalRung: string;
168
+ landed: boolean;
169
+ start_ok: boolean;
170
+ envCaused: boolean;
171
+ }
172
+
173
+ /** The routes.jsonl outcome row of `o`, with the kind and difficulty of the lane's last route. */
174
+ export function outcomeRouteRow(rows: RouteRow[], o: OutcomeRow): OutcomeRouteRow {
175
+ const start = rows.findLast((r) => r.lane === o.lane && r.source === "route");
176
+ return {
177
+ at: o.at,
178
+ lane: o.lane,
179
+ source: "outcome",
180
+ decidedBy: o.source,
181
+ kind: start?.kind ?? null,
182
+ difficulty: start?.difficulty ?? null,
183
+ questionSet: o.questionSet,
184
+ jev: o.jevProbs,
185
+ climbed: o.climbs.length > 0,
186
+ startRung: o.startRung,
187
+ finalRung: o.finalRung,
188
+ landed: o.landed,
189
+ start_ok: o.start_ok,
190
+ envCaused: o.envCaused,
191
+ };
192
+ }
193
+
121
194
  /** Spec §5.6: one row per lane, the last one written (last row per lane wins), in first-seen lane order. */
122
195
  export function latestOutcomes(rows: OutcomeRow[]): OutcomeRow[] {
123
196
  const byLane = new Map<string, OutcomeRow>();