catherd-cli 0.1.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/catalog/catalog.json +112 -0
  4. package/catalog/models-dev.json +56453 -0
  5. package/package.json +52 -0
  6. package/plugin/.claude-plugin/plugin.json +13 -0
  7. package/plugin/.mcp.json +8 -0
  8. package/plugin/commands/catherd-setup.md +8 -0
  9. package/plugin/commands/catherd.md +10 -0
  10. package/plugin/skills/catherd/SKILL.md +301 -0
  11. package/plugin/skills/catherd-setup/SKILL.md +65 -0
  12. package/src/cli.ts +28 -0
  13. package/src/core/codex-events.ts +49 -0
  14. package/src/core/codex.ts +166 -0
  15. package/src/core/harness.ts +89 -0
  16. package/src/core/lanes.ts +67 -0
  17. package/src/core/lock.ts +111 -0
  18. package/src/core/opencode.ts +207 -0
  19. package/src/core/reconcile.ts +26 -0
  20. package/src/core/reply.ts +54 -0
  21. package/src/core/runstore.ts +163 -0
  22. package/src/core/spawn.ts +89 -0
  23. package/src/core/status.ts +193 -0
  24. package/src/files.ts +19 -0
  25. package/src/mcp/commands.ts +7 -0
  26. package/src/mcp/dispatch.ts +154 -0
  27. package/src/mcp/lane-tools.ts +200 -0
  28. package/src/mcp/out.ts +6 -0
  29. package/src/mcp/preflight.ts +69 -0
  30. package/src/mcp/run-tools.ts +121 -0
  31. package/src/mcp/runs.ts +118 -0
  32. package/src/mcp/server.ts +26 -0
  33. package/src/mcp/setup-tools.ts +175 -0
  34. package/src/paths.ts +20 -0
  35. package/src/profile/agents.ts +118 -0
  36. package/src/profile/profile.ts +228 -0
  37. package/src/profile/role-prompts.ts +129 -0
  38. package/src/routing/catalog.ts +227 -0
  39. package/src/routing/commands.ts +22 -0
  40. package/src/routing/jev.ts +181 -0
  41. package/src/routing/route.ts +155 -0
  42. package/src/routing/select.ts +90 -0
  43. package/src/tui/backends.ts +57 -0
  44. package/src/tui/commands.ts +51 -0
  45. package/src/tui/editor.tsx +221 -0
  46. package/src/tui/init.tsx +221 -0
  47. package/src/tui/matrix-model.ts +211 -0
  48. package/src/tui/matrix.tsx +350 -0
  49. package/src/tui/profiles.ts +38 -0
  50. package/src/tui/theme.ts +140 -0
  51. package/src/tui/ui.tsx +104 -0
  52. package/src/tui/watch-model.ts +83 -0
  53. package/src/tui/watch.tsx +258 -0
  54. package/src/types.ts +184 -0
  55. package/src/version.ts +7 -0
@@ -0,0 +1,175 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import diff from "microdiff";
5
+ import { z } from "zod";
6
+ import { formatHarness, harnessCosts } from "../core/harness.ts";
7
+ import { ID } from "../core/lanes.ts";
8
+ import { listRuns } from "../core/runstore.ts";
9
+ import { runsSummary } from "../core/status.ts";
10
+ import { claudeAgentsDir, saveProfileAndAgents } from "../profile/agents.ts";
11
+ import {
12
+ activeProfileName,
13
+ defaultProfile,
14
+ listProfiles,
15
+ loadProfile,
16
+ patchProfile,
17
+ type ProfilePatch,
18
+ validateProfile,
19
+ } from "../profile/profile.ts";
20
+ import { capableFor, loadCatalog } from "../routing/catalog.ts";
21
+ import { type Backend, type Profile, ROLES, splitRung } from "../types.ts";
22
+ import { json } from "./out.ts";
23
+
24
+ const isolation = z.object({ isolated: z.boolean() });
25
+
26
+ const PATCH = z.object({
27
+ objective: z.enum(["cost", "speed"]).optional(),
28
+ roles: z
29
+ .partialRecord(
30
+ z.enum(ROLES),
31
+ z.object({
32
+ enabled: z.boolean().optional(),
33
+ models: z.record(z.string(), z.array(z.string())).optional(),
34
+ defaultRung: z.string().optional(),
35
+ }),
36
+ )
37
+ .optional(),
38
+ harness: z.object({ codex: isolation.optional(), opencode: isolation.optional() }).optional(),
39
+ lock: z.object({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]) }).optional(),
40
+ notify: z.array(z.enum(["milestone", "finish", "blocked"])).optional(),
41
+ });
42
+
43
+ /** Whether each backend's CLI is on PATH. Claude is always available: it needs no CLI, only agent files. */
44
+ function installedBackends(): Record<Backend, boolean> {
45
+ const probe = (bin: string) =>
46
+ Bun.which(bin) !== null &&
47
+ Bun.spawnSync([bin, "--version"], { stdout: "ignore", stderr: "ignore" }).success;
48
+ return { codex: probe("codex"), opencode: probe("opencode"), claude: true };
49
+ }
50
+
51
+ const profileOrNew = (name: string): Profile =>
52
+ listProfiles().includes(name) ? loadProfile(name) : { ...defaultProfile(), name };
53
+
54
+ export function registerSetupTools(server: McpServer): void {
55
+ server.registerTool(
56
+ "catalog_query",
57
+ {
58
+ description:
59
+ "Models catherd can place, with their capabilities, the roles they are capable for, their scored rungs, any 'treat like', and whether their backend is installed. Scored models first.",
60
+ inputSchema: {
61
+ role: z.enum(ROLES).optional(),
62
+ backend: z.enum(["codex", "opencode", "claude"]).optional(),
63
+ text: z.string().optional(),
64
+ scored_only: z.boolean().default(false),
65
+ limit: z.number().int().min(1).max(500).default(50),
66
+ },
67
+ },
68
+ async (a) => {
69
+ const c = loadCatalog();
70
+ const installed = installedBackends();
71
+ const needle = a.text?.toLowerCase();
72
+ const models = c.models
73
+ .filter(
74
+ (m) =>
75
+ (!a.role || capableFor(a.role, m)) &&
76
+ (!a.backend || m.backend === a.backend) &&
77
+ (!needle || m.id.toLowerCase().includes(needle)),
78
+ )
79
+ .map((m) => ({
80
+ id: m.id,
81
+ backend: m.backend,
82
+ installed: installed[m.backend],
83
+ efforts: m.efforts,
84
+ capabilities: m.capabilities,
85
+ roles: ROLES.filter((r) => capableFor(r, m)),
86
+ scored: c.entries.filter((e) => splitRung(e.rung).model === m.id),
87
+ treatLike: Object.fromEntries(
88
+ Object.entries(c.treatLike).filter(([k]) => splitRung(k).model === m.id),
89
+ ),
90
+ }))
91
+ .filter((m) => !a.scored_only || m.scored.length > 0 || Object.keys(m.treatLike).length > 0)
92
+ .sort((x, y) => y.scored.length - x.scored.length || x.id.localeCompare(y.id));
93
+ return json({ total: models.length, models: models.slice(0, a.limit) });
94
+ },
95
+ );
96
+
97
+ server.registerTool(
98
+ "profile_get",
99
+ {
100
+ description:
101
+ "A profile (the active one without a name), the active profile's name, and every profile name.",
102
+ inputSchema: { name: z.string().regex(ID).optional() },
103
+ },
104
+ async (a) => {
105
+ const active = activeProfileName();
106
+ return json({ active, profiles: listProfiles(), profile: loadProfile(a.name ?? active) });
107
+ },
108
+ );
109
+
110
+ server.registerTool(
111
+ "profile_validate",
112
+ {
113
+ description:
114
+ "Check a profile (the active one without a name): every enabled role has a capable model, the worker's ladder is non-empty for every kind, and no unscored model is enabled without its 'treat like'.",
115
+ inputSchema: { name: z.string().regex(ID).optional() },
116
+ },
117
+ async (a) => {
118
+ const errors = validateProfile(loadProfile(a.name ?? activeProfileName()), loadCatalog());
119
+ return json({ valid: errors.length === 0, errors });
120
+ },
121
+ );
122
+
123
+ server.registerTool(
124
+ "profile_set",
125
+ {
126
+ description:
127
+ "Apply a patch to a profile (the active one without a name). It validates first and writes nothing when invalid; otherwise it saves the profile and regenerates the Claude agent files. Returns the diff and the agents that need a new Claude Code session.",
128
+ inputSchema: { name: z.string().regex(ID).optional(), patch: PATCH },
129
+ },
130
+ async (a) => {
131
+ const c = loadCatalog();
132
+ const before = profileOrNew(a.name ?? activeProfileName());
133
+ const { harness, ...rest } = a.patch;
134
+ const patched = patchProfile(before, rest as ProfilePatch);
135
+ const after: Profile = harness ? { ...patched, harness: { ...patched.harness, ...harness } } : patched;
136
+ const errors = validateProfile(after, c);
137
+ if (errors.length) return json({ saved: false, errors });
138
+ const dir = claudeAgentsDir();
139
+ const had = new Set(existsSync(dir) ? readdirSync(dir) : []);
140
+ const agents = saveProfileAndAgents(after, c);
141
+ return json({
142
+ saved: true,
143
+ diff: diff(before as unknown as Record<string, unknown>, after as unknown as Record<string, unknown>),
144
+ agents,
145
+ new_session_needed_for: agents.linked
146
+ .filter((l) => !had.has(basename(l)))
147
+ .map((l) => basename(l, ".md")),
148
+ });
149
+ },
150
+ );
151
+
152
+ server.registerTool(
153
+ "runs_summary",
154
+ {
155
+ description:
156
+ "Per role and rung over past runs: runs, ok, refusals, climbs from that rung, seconds and tokens; plus what the user's harness customizations cost per run.",
157
+ inputSchema: {
158
+ run: z.string().optional(),
159
+ repo: z.string().optional(),
160
+ role: z.enum(ROLES).optional(),
161
+ since_days: z.number().positive().optional(),
162
+ },
163
+ },
164
+ async (a) => {
165
+ const harness = harnessCosts(listRuns().map((r) => r.dir)).map((h) => ({
166
+ ...h,
167
+ line: formatHarness(h),
168
+ }));
169
+ return json({
170
+ rungs: runsSummary({ run: a.run, repo: a.repo, role: a.role, sinceDays: a.since_days }),
171
+ harness,
172
+ });
173
+ },
174
+ );
175
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ function root(kind: "config" | "data"): string {
5
+ const home = process.env.CATHERD_HOME;
6
+ if (home) return join(home, kind);
7
+ if (kind === "config") return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "catherd");
8
+ return join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "catherd");
9
+ }
10
+
11
+ export const configDir = (): string => root("config");
12
+ export const dataDir = (): string => root("data");
13
+
14
+ export function repoSlug(repoRoot: string): string {
15
+ return repoRoot.replace(/^\/+/, "").replaceAll("/", "-");
16
+ }
17
+
18
+ export function runsRoot(repoRoot: string): string {
19
+ return join(dataDir(), repoSlug(repoRoot));
20
+ }
@@ -0,0 +1,118 @@
1
+ import { lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join, sep } from "node:path";
4
+ import { configDir } from "../paths.ts";
5
+ import { modelOf } from "../routing/catalog.ts";
6
+ import { type Catalog, type Profile, ROLES, type Role, type RungId, rungOf, splitRung } from "../types.ts";
7
+ import { activeProfileName, saveProfile } from "./profile.ts";
8
+ import { ROLE_PROMPTS } from "./role-prompts.ts";
9
+
10
+ const slug = (s: string) =>
11
+ s
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, "-")
14
+ .replace(/^-|-$/g, "");
15
+
16
+ export function agentName(role: Role, rung: RungId): string {
17
+ const { model, effort } = splitRung(rung);
18
+ return `catherd-${role}-${slug(model)}-${effort}`;
19
+ }
20
+
21
+ export function claudeAgentsDir(): string {
22
+ return process.env.CATHERD_CLAUDE_AGENTS_DIR ?? join(homedir(), ".claude", "agents");
23
+ }
24
+
25
+ function render(role: Role, rung: RungId): string {
26
+ const { model, effort } = splitRung(rung);
27
+ const p = ROLE_PROMPTS[role];
28
+ return [
29
+ "---",
30
+ `name: ${agentName(role, rung)}`,
31
+ `description: Internal ${role} role of the catherd orchestrator, on ${model} at ${effort} effort. Dispatched only by the catherd skill while a run is in flight. Never for a plain request, even one that names this role.`,
32
+ `model: ${model}`,
33
+ `effort: ${effort}`,
34
+ `disallowedTools: ${p.disallowedTools.join(", ")}`,
35
+ "---",
36
+ "",
37
+ p.body,
38
+ "",
39
+ ].join("\n");
40
+ }
41
+
42
+ const lstatOrNull = (p: string) => {
43
+ try {
44
+ return lstatSync(p);
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ export function writeClaudeAgents(
51
+ p: Profile,
52
+ c: Catalog,
53
+ ): { written: string[]; linked: string[]; pruned: string[] } {
54
+ const root = join(configDir(), "agents");
55
+ const dir = join(root, p.name);
56
+ const files = new Map<string, string>();
57
+ for (const role of ROLES) {
58
+ const rc = p.roles[role];
59
+ if (!rc?.enabled) continue;
60
+ for (const [model, efforts] of Object.entries(rc.models)) {
61
+ if (modelOf(c, model)?.backend !== "claude") continue;
62
+ for (const effort of efforts) {
63
+ const rung = rungOf(model, effort);
64
+ files.set(join(dir, `${agentName(role, rung)}.md`), render(role, rung));
65
+ }
66
+ }
67
+ }
68
+
69
+ const active = p.name === activeProfileName();
70
+ const target = claudeAgentsDir();
71
+ const ours = `${root}${sep}`;
72
+ const isOurs = (link: string) => {
73
+ const st = lstatOrNull(link);
74
+ return !st || (st.isSymbolicLink() && readlinkSync(link).startsWith(ours));
75
+ };
76
+ if (active) {
77
+ for (const f of files.keys()) {
78
+ const link = join(target, basename(f));
79
+ if (!isOurs(link))
80
+ throw new Error(`catherd: ${link} exists and is not catherd's; move it away and save again`);
81
+ }
82
+ }
83
+
84
+ mkdirSync(dir, { recursive: true });
85
+ for (const [f, text] of files) writeFileSync(f, text);
86
+ for (const f of readdirSync(dir)) if (!files.has(join(dir, f))) rmSync(join(dir, f));
87
+ const written = [...files.keys()];
88
+ if (!active) return { written, linked: [], pruned: [] };
89
+
90
+ mkdirSync(target, { recursive: true });
91
+ const linked: string[] = [];
92
+ for (const f of written) {
93
+ const link = join(target, basename(f));
94
+ rmSync(link, { force: true });
95
+ symlinkSync(f, link);
96
+ linked.push(link);
97
+ }
98
+ const pruned: string[] = [];
99
+ for (const name of readdirSync(target)) {
100
+ const link = join(target, name);
101
+ if (!lstatOrNull(link)?.isSymbolicLink()) continue;
102
+ const to = readlinkSync(link);
103
+ if (to.startsWith(ours) && !files.has(to)) {
104
+ rmSync(link);
105
+ pruned.push(link);
106
+ }
107
+ }
108
+ return { written, linked, pruned };
109
+ }
110
+
111
+ export function saveProfileAndAgents(
112
+ p: Profile,
113
+ c: Catalog,
114
+ ): { written: string[]; linked: string[]; pruned: string[] } {
115
+ const r = writeClaudeAgents(p, c);
116
+ saveProfile(p);
117
+ return r;
118
+ }
@@ -0,0 +1,228 @@
1
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { readJsonFile } from "../files.ts";
5
+ import { configDir } from "../paths.ts";
6
+ import { capableFor, isScored, modelOf } from "../routing/catalog.ts";
7
+ import { candidates, clearsBar } from "../routing/select.ts";
8
+ import {
9
+ type Catalog,
10
+ DIFFICULTIES,
11
+ type HarnessConfig,
12
+ KINDS,
13
+ type NotifyMoment,
14
+ type Profile,
15
+ ROLE_NEEDS,
16
+ ROLES,
17
+ type Role,
18
+ type RoleConfig,
19
+ type RungId,
20
+ rungOf,
21
+ splitRung,
22
+ } from "../types.ts";
23
+
24
+ const NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
25
+
26
+ const Harness = z.object({ isolated: z.boolean() }).default(() => ({ isolated: false }));
27
+
28
+ const ProfileSchema = z.object({
29
+ name: z.string().regex(NAME),
30
+ objective: z.enum(["cost", "speed"]),
31
+ roles: z.record(
32
+ z.enum(ROLES),
33
+ z.object({
34
+ enabled: z.boolean(),
35
+ models: z.record(z.string().min(1), z.array(z.string().min(1))),
36
+ defaultRung: z.string().optional(),
37
+ }),
38
+ ),
39
+ // Profiles written before the harness toggle existed load as native, which is the default.
40
+ harness: z
41
+ .object({ codex: Harness, opencode: Harness })
42
+ .default(() => ({ codex: { isolated: false }, opencode: { isolated: false } })),
43
+ lock: z.object({ heavy: z.union([z.number().int().min(1), z.literal("cpus/2")]) }),
44
+ notify: z.array(z.enum(["milestone", "finish", "blocked"])),
45
+ // spec §11b: quota failover and the run budget. Both optional, so old profile files still load.
46
+ failover: z.record(z.string(), z.string()).optional(),
47
+ budget: z
48
+ .object({ minutes: z.number().optional(), tokens: z.number().optional(), usd: z.number().optional() })
49
+ .optional(),
50
+ });
51
+ const ConfigSchema = z.looseObject({ activeProfile: z.string().optional() });
52
+ const ProjectsSchema = z.record(z.string(), z.string());
53
+
54
+ export type ProfilePatch = {
55
+ objective?: Profile["objective"];
56
+ /** A role's `models` map is replaced whole, so a patch can drop an entry. */
57
+ roles?: Partial<Record<Role, Partial<RoleConfig>>>;
58
+ harness?: Partial<Record<"codex" | "opencode", Partial<HarnessConfig>>>;
59
+ lock?: Profile["lock"];
60
+ notify?: NotifyMoment[];
61
+ };
62
+
63
+ const profilesDir = () => join(configDir(), "profiles");
64
+ const configFile = () => join(configDir(), "config.json");
65
+ const projectsFile = () => join(configDir(), "projects.json");
66
+
67
+ function checkName(name: string): string {
68
+ if (!NAME.test(name))
69
+ throw new Error(`catherd: bad profile name "${name}": use letters, digits, "-" and "_"`);
70
+ return name;
71
+ }
72
+
73
+ function readOr<T>(schema: z.ZodType<T>, file: string, fallback: T): T {
74
+ return existsSync(file) ? readJsonFile(schema, file) : fallback;
75
+ }
76
+
77
+ function writeJson(file: string, value: unknown): void {
78
+ mkdirSync(dirname(file), { recursive: true });
79
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
80
+ }
81
+
82
+ export function defaultProfile(): Profile {
83
+ return {
84
+ name: "default",
85
+ objective: "cost",
86
+ roles: {
87
+ architect: { enabled: true, models: { "claude-opus-5-5": ["high"] } },
88
+ verifier: { enabled: true, models: { "claude-opus-5-5": ["low"] } },
89
+ worker: {
90
+ enabled: true,
91
+ defaultRung: "gpt-6-sol#medium",
92
+ models: { "gpt-6-luna": ["high"], "gpt-6-sol": ["medium", "high", "xhigh"] },
93
+ },
94
+ reviewer: { enabled: true, models: { "gpt-6-sol": ["high"] } },
95
+ "ui-reviewer": { enabled: true, models: { "gpt-6-sol": ["medium"] } },
96
+ artist: { enabled: true, models: { "gpt-6-sol": ["medium"] } },
97
+ writer: { enabled: true, models: { "gpt-6-luna": ["high"] } },
98
+ researcher: { enabled: true, models: { "gpt-6-luna": ["high"] } },
99
+ },
100
+ harness: { codex: { isolated: false }, opencode: { isolated: false } },
101
+ lock: { heavy: "cpus/2" },
102
+ notify: ["milestone", "finish", "blocked"],
103
+ };
104
+ }
105
+
106
+ export function loadProfile(name?: string): Profile {
107
+ const n = checkName(name ?? activeProfileName());
108
+ const file = join(profilesDir(), `${n}.json`);
109
+ if (existsSync(file)) return { ...readJsonFile(ProfileSchema, file), name: n };
110
+ if (n === "default") return defaultProfile();
111
+ throw new Error(`catherd: no profile named "${n}" (looked for ${file})`);
112
+ }
113
+
114
+ export function saveProfile(p: Profile): void {
115
+ const file = join(profilesDir(), `${checkName(p.name)}.json`);
116
+ const r = ProfileSchema.safeParse(p);
117
+ if (!r.success) throw new Error(`catherd: profile "${p.name}" is invalid:\n${z.prettifyError(r.error)}`);
118
+ writeJson(file, r.data);
119
+ }
120
+
121
+ export function listProfiles(): string[] {
122
+ const onDisk = existsSync(profilesDir())
123
+ ? readdirSync(profilesDir())
124
+ .filter((f) => f.endsWith(".json"))
125
+ .map((f) => f.slice(0, -".json".length))
126
+ .filter((n) => NAME.test(n))
127
+ : [];
128
+ return [...new Set(["default", ...onDisk])].sort();
129
+ }
130
+
131
+ export function activeProfileName(repo?: string): string {
132
+ const bound = repo === undefined ? undefined : readOr(ProjectsSchema, projectsFile(), {})[resolve(repo)];
133
+ return bound ?? readOr(ConfigSchema, configFile(), {}).activeProfile ?? "default";
134
+ }
135
+
136
+ export function setActiveProfile(name: string, repo?: string): void {
137
+ if (!listProfiles().includes(name)) throw new Error(`catherd: no profile named "${name}"`);
138
+ if (repo === undefined)
139
+ writeJson(configFile(), { ...readOr(ConfigSchema, configFile(), {}), activeProfile: name });
140
+ else writeJson(projectsFile(), { ...readOr(ProjectsSchema, projectsFile(), {}), [resolve(repo)]: name });
141
+ }
142
+
143
+ export function patchProfile(p: Profile, patch: ProfilePatch): Profile {
144
+ const roles = { ...p.roles };
145
+ for (const role of ROLES) {
146
+ const rc = patch.roles?.[role];
147
+ if (rc) roles[role] = { ...roles[role], ...rc };
148
+ }
149
+ return {
150
+ ...p,
151
+ objective: patch.objective ?? p.objective,
152
+ roles,
153
+ harness: {
154
+ codex: { ...p.harness.codex, ...patch.harness?.codex },
155
+ opencode: { ...p.harness.opencode, ...patch.harness?.opencode },
156
+ },
157
+ lock: patch.lock ?? p.lock,
158
+ notify: patch.notify ?? p.notify,
159
+ };
160
+ }
161
+
162
+ const backendOf = (c: Catalog, rung: RungId): string | undefined =>
163
+ modelOf(c, splitRung(rung).model)?.backend;
164
+
165
+ export function validateProfile(p: Profile, c: Catalog): string[] {
166
+ const errors: string[] = [];
167
+ if (!p.roles.worker.enabled) errors.push("worker: cannot be disabled");
168
+ for (const role of ROLES) {
169
+ const rc = p.roles[role];
170
+ if (!rc.enabled) continue;
171
+ const usable: RungId[] = [];
172
+ for (const [id, efforts] of Object.entries(rc.models)) {
173
+ const m = modelOf(c, id);
174
+ if (!m) {
175
+ errors.push(`${role}: ${id} is not in the catalog`);
176
+ continue;
177
+ }
178
+ if (!capableFor(role, m)) {
179
+ errors.push(
180
+ `${role}: ${id} cannot fill this role (it needs ${Object.keys(ROLE_NEEDS[role]).join(" + ")})`,
181
+ );
182
+ continue;
183
+ }
184
+ for (const effort of efforts) {
185
+ const rung = rungOf(id, effort);
186
+ if (!m.efforts.includes(effort)) {
187
+ errors.push(`${role}: ${id} has no effort "${effort}" (it has ${m.efforts.join(", ")})`);
188
+ } else if (!isScored(c, rung)) {
189
+ errors.push(
190
+ `${role}: ${rung} is unscored; declare it "treat like" a scored entry in catalog.override.json`,
191
+ );
192
+ } else {
193
+ usable.push(rung);
194
+ }
195
+ }
196
+ }
197
+ if (usable.length === 0) errors.push(`${role}: no usable model is enabled`);
198
+ else if (rc.defaultRung && !usable.includes(rc.defaultRung)) {
199
+ errors.push(`${role}: defaultRung ${rc.defaultRung} is not one of its enabled entries`);
200
+ }
201
+ }
202
+ const worker = candidates(p, c, "worker");
203
+ if (worker.length > 1) {
204
+ for (const kind of KINDS) {
205
+ for (const d of DIFFICULTIES) {
206
+ if (!worker.some((r) => clearsBar(c, r, kind, d))) {
207
+ errors.push(`worker: no enabled entry clears the ${kind}/${d} bar`);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ // spec §11b: a failover stand-in must be usable on its own, and on a different backend, so
213
+ // a quota outage on one backend cannot fail over onto the very backend that is out of quota.
214
+ for (const [from, to] of Object.entries(p.failover ?? {})) {
215
+ if (!isScored(c, to)) {
216
+ errors.push(`failover: ${to} (stand-in for ${from}) is unscored`);
217
+ continue;
218
+ }
219
+ const fromBackend = backendOf(c, from);
220
+ const toBackend = backendOf(c, to);
221
+ if (fromBackend && toBackend && fromBackend === toBackend) {
222
+ errors.push(
223
+ `failover: ${to} is on the same backend (${toBackend}) as ${from}; a stand-in must be on another backend`,
224
+ );
225
+ }
226
+ }
227
+ return errors;
228
+ }
@@ -0,0 +1,129 @@
1
+ import type { Role } from "../types.ts";
2
+
3
+ const READ_ONLY = ["Write", "Edit", "NotebookEdit", "Agent"];
4
+ const WRITES = ["Agent"];
5
+
6
+ const RUN_FILES =
7
+ "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.";
8
+
9
+ const REPLY =
10
+ "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>";
11
+
12
+ const architect = [
13
+ "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.",
14
+ "",
15
+ `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}`,
16
+ "",
17
+ "Write plan.md with exactly these parts, in this order:",
18
+ "",
19
+ "1. Decisions D1…Dn: each one is a choice, the alternative you rejected, and one line on why. Cover only choices where two reasonable engineers would differ.",
20
+ "2. Milestones M1…Mn: each one is a shippable step with its own acceptance lines and its full check (the command that proves the whole milestone). Order them so each builds on the last.",
21
+ "3. Lanes inside each milestone, M1.L1…: each lane gives",
22
+ " - the files it owns (two lanes of one milestone never share a file);",
23
+ " - what changes, stated as behavior plus the exact signatures and data shapes it introduces;",
24
+ " - its fast check: the targeted command a worker reruns while it works, seconds to a minute or two.",
25
+ "",
26
+ " Every lane of a milestone runs at the same time, so split for width: more small lanes beat one long one.",
27
+ "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.",
28
+ "5. Edge cases the tests must pin, one line each.",
29
+ "6. Out of scope: what no worker may touch.",
30
+ "",
31
+ "Then write one file per lane, lanes/Mx.Ly.md. It is everything that worker needs and nothing else: the lane's section, the decisions and edge cases it relies on (copied, not referenced), and the out-of-scope lines. A worker reads its lane file, never plan.md. Its first three lines are exactly:",
32
+ "",
33
+ " # Mx.Ly — <one line>",
34
+ " Owns: <repo-relative paths, comma-separated>",
35
+ " Fast check: <command>",
36
+ "",
37
+ "catherd reads the Owns: line to keep two running lanes off the same file, and to tell a refusal (owned files unchanged) from work.",
38
+ "",
39
+ "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.",
40
+ "",
41
+ "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.",
42
+ "",
43
+ "Your reply to the orchestrator is short: the milestones, each with its lanes as Mx.Ly — one line — owned files, and the full-check command per milestone. The detail lives in the files.",
44
+ "",
45
+ "When the orchestrator sends you a design finding later, answer with a delta: rewrite the affected lane files and the matching plan.md sections, and reply with what changed.",
46
+ ].join("\n");
47
+
48
+ const verifier = [
49
+ "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.",
50
+ "",
51
+ "1. Run the check command once. Report its exit code and the failing lines.",
52
+ "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.",
53
+ "3. Read the diff (git diff plus untracked files) for bugs the acceptance lines miss: wrong edge behavior, dead code, leftovers.",
54
+ "",
55
+ "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.",
56
+ "",
57
+ "Return, in this order:",
58
+ "- VERDICT: PASS or VERDICT: FAIL on the first line.",
59
+ "- One line per acceptance line: A<n> PASS|FAIL, the command you ran and the decisive output.",
60
+ "- Bugs outside the acceptance lines: file:line, what happens, the input that triggers it.",
61
+ "- What you could not check, and why.",
62
+ ].join("\n");
63
+
64
+ const worker = [
65
+ "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.",
66
+ "",
67
+ `Read your lane file first. ${RUN_FILES} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
68
+ "",
69
+ "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: npx -y catherd lock -- <command>. Other lanes share this machine.",
70
+ "",
71
+ REPLY,
72
+ ].join("\n");
73
+
74
+ const reviewer = [
75
+ "You review a milestone's diff that you did not write. The brief gives the acceptance lines and the changed files; read new files in full. Change nothing in the project.",
76
+ "",
77
+ "Report every finding as: BLOCKER|BUG|NIT file:line — problem — fix. Cover:",
78
+ "- unmet acceptance lines and edge cases;",
79
+ "- code or abstractions nobody needs;",
80
+ "- comments that restate code;",
81
+ "- tests that assert nothing.",
82
+ "",
83
+ "With no finding, the reply is CLEAN.",
84
+ "",
85
+ REPLY,
86
+ ].join("\n");
87
+
88
+ const uiReviewer = [
89
+ "You review the screens a milestone changed, from screenshots. The brief gives the URL, the changed screens by route, the viewports and the themes. Change nothing in the project.",
90
+ "",
91
+ "Take each screenshot with agent-browser screenshot <path> at viewport size, not full page, to the paths the brief names, and open each PNG yourself before you judge it.",
92
+ "",
93
+ "Report every finding as: BLOCKER|BUG|NIT <screen> — problem — fix — <screenshot path>, always with the path.",
94
+ "",
95
+ REPLY,
96
+ ].join("\n");
97
+
98
+ const artist = [
99
+ "You make the images a screen needs. The brief gives, per image, the screen, where the image sits, its shape, the mood and scene in words, and the final .webp path in the project.",
100
+ "",
101
+ "Use your built-in image tool, and copy its untouched output. The only processing is cwebp -q 85 in.png -o out.webp. Never paint, patch or upscale. Never redraw a real logo, and never present a generated person as a real customer. Add one provenance line per image to a README.md beside it.",
102
+ "",
103
+ REPLY,
104
+ ].join("\n");
105
+
106
+ const writer = [
107
+ "You write the docs a milestone needs: README, docs pages, the changelog, or a merge request body, as the brief says. The brief names the files you own; change nothing else. Write plainly, and describe what the code does now, not how it got there.",
108
+ "",
109
+ REPLY,
110
+ ].join("\n");
111
+
112
+ const researcher = [
113
+ "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.",
114
+ "",
115
+ `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; 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}`,
116
+ "",
117
+ "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>",
118
+ ].join("\n");
119
+
120
+ export const ROLE_PROMPTS: Record<Role, { disallowedTools: string[]; body: string }> = {
121
+ architect: { disallowedTools: READ_ONLY, body: architect },
122
+ verifier: { disallowedTools: READ_ONLY, body: verifier },
123
+ worker: { disallowedTools: WRITES, body: worker },
124
+ reviewer: { disallowedTools: READ_ONLY, body: reviewer },
125
+ "ui-reviewer": { disallowedTools: READ_ONLY, body: uiReviewer },
126
+ artist: { disallowedTools: WRITES, body: artist },
127
+ writer: { disallowedTools: WRITES, body: writer },
128
+ researcher: { disallowedTools: READ_ONLY, body: researcher },
129
+ };