catherd-cli 0.2.1 → 1.0.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 (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
package/src/mcp/server.ts DELETED
@@ -1,26 +0,0 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { reconcileLive } from "../core/reconcile.ts";
4
- import { listRuns } from "../core/runstore.ts";
5
- import { VERSION } from "../version.ts";
6
- import { registerDispatch } from "./dispatch.ts";
7
- import { registerLaneTools } from "./lane-tools.ts";
8
- import { registerPreflight } from "./preflight.ts";
9
- import { registerRunTools } from "./run-tools.ts";
10
- import { registerSetupTools } from "./setup-tools.ts";
11
-
12
- export function buildServer(): McpServer {
13
- const server = new McpServer({ name: "catherd", version: VERSION });
14
- registerRunTools(server);
15
- registerLaneTools(server);
16
- registerDispatch(server);
17
- registerSetupTools(server);
18
- registerPreflight(server);
19
- return server;
20
- }
21
-
22
- export async function startMcpServer(): Promise<void> {
23
- process.env.CATHERD_TICK_MS ??= "30000";
24
- for (const run of listRuns()) reconcileLive(run.dir);
25
- await buildServer().connect(new StdioServerTransport());
26
- }
@@ -1,175 +0,0 @@
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 DELETED
@@ -1,20 +0,0 @@
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
- }
@@ -1,130 +0,0 @@
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
- }
119
-
120
- /** Read-only count of catherd's own symlinks in `claudeAgentsDir()`, for the dashboard's
121
- * "Claude agents" status row — unlike `writeClaudeAgents`, this never touches the filesystem. */
122
- export function countLinkedAgents(): number {
123
- const target = claudeAgentsDir();
124
- const ours = `${join(configDir(), "agents")}${sep}`;
125
- if (!lstatOrNull(target)) return 0;
126
- return readdirSync(target).filter((name) => {
127
- const st = lstatOrNull(join(target, name));
128
- return st?.isSymbolicLink() && readlinkSync(join(target, name)).startsWith(ours);
129
- }).length;
130
- }
@@ -1,228 +0,0 @@
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
- }