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.
- package/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { adapterFor } from "../../adapters/registry.ts";
|
|
3
|
+
import "../../adapters/all.ts";
|
|
4
|
+
import { agentFiles } from "../../domain/agents.ts";
|
|
5
|
+
import type { Catalog } from "../../domain/catalog.ts";
|
|
6
|
+
import { HARNESS_KEYS, type Profile, type ProfileDoc, type ProfilePatch } from "../../domain/profile.ts";
|
|
7
|
+
import { type Validation, validateProfile } from "../../domain/profile-rules.ts";
|
|
8
|
+
import type { Access } from "../../domain/record.ts";
|
|
9
|
+
import type { RouteSource } from "../../domain/route.ts";
|
|
10
|
+
import { VERSION } from "../../infra/version.ts";
|
|
11
|
+
import {
|
|
12
|
+
type CatalogModel,
|
|
13
|
+
catalogQuery,
|
|
14
|
+
loadCatalog,
|
|
15
|
+
type Refreshed,
|
|
16
|
+
refreshDiscovery,
|
|
17
|
+
saveTreatLike,
|
|
18
|
+
} from "../../services/catalog-service.ts";
|
|
19
|
+
import { cancel } from "../../services/dispatch-service.ts";
|
|
20
|
+
import { type DoctorReport, doctor } from "../../services/doctor.ts";
|
|
21
|
+
import type { Synced } from "../../services/agent-links.ts";
|
|
22
|
+
import {
|
|
23
|
+
activate,
|
|
24
|
+
createProfile,
|
|
25
|
+
deleteProfile,
|
|
26
|
+
patchProfile,
|
|
27
|
+
type Saved,
|
|
28
|
+
} from "../../services/profile-service.ts";
|
|
29
|
+
import {
|
|
30
|
+
activeName,
|
|
31
|
+
configFile,
|
|
32
|
+
enforcementOf,
|
|
33
|
+
listProfiles,
|
|
34
|
+
profilesDir,
|
|
35
|
+
projectsFile,
|
|
36
|
+
readProfileDoc,
|
|
37
|
+
readProjects,
|
|
38
|
+
runnableBackends,
|
|
39
|
+
} from "../../services/profile-store.ts";
|
|
40
|
+
import { findRun, listRuns, readRoutes, type Run, runPaths } from "../../services/run-store.ts";
|
|
41
|
+
import { type RunSummary, summarizeRun } from "../../services/summary.ts";
|
|
42
|
+
import { defaultDeps } from "../deps.ts";
|
|
43
|
+
import { mcpHandshake } from "../mcp/handshake.ts";
|
|
44
|
+
|
|
45
|
+
/** A save refused because the profile changed on disk since its preview read it carries this path. */
|
|
46
|
+
export { CHANGED_ON_DISK } from "../../services/profile-service.ts";
|
|
47
|
+
|
|
48
|
+
/** One line of the run list. */
|
|
49
|
+
export interface RunRow {
|
|
50
|
+
id: string;
|
|
51
|
+
title: string;
|
|
52
|
+
repo: string;
|
|
53
|
+
createdAt: string;
|
|
54
|
+
live: number;
|
|
55
|
+
roleRuns: number;
|
|
56
|
+
landed: number;
|
|
57
|
+
/** the budget's spent fraction, null without a cap */
|
|
58
|
+
budget: number | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Climb {
|
|
62
|
+
lane: string;
|
|
63
|
+
from: string;
|
|
64
|
+
to: string;
|
|
65
|
+
reason: string;
|
|
66
|
+
/** the environment's fault (a limit, a missing service), not the rung's */
|
|
67
|
+
env: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Decision {
|
|
71
|
+
lane: string;
|
|
72
|
+
role: string;
|
|
73
|
+
source: RouteSource;
|
|
74
|
+
kind: string | null;
|
|
75
|
+
difficulty: string | null;
|
|
76
|
+
rung: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Spec §9.1's run view: live lanes, climbs, Jev decisions, budget, landed milestones. */
|
|
80
|
+
export interface RunDetail {
|
|
81
|
+
summary: RunSummary;
|
|
82
|
+
climbs: Climb[];
|
|
83
|
+
decisions: Decision[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Everything the TUI reads or changes, behind one seam (spec §9.4 `effects.ts`: services injected).
|
|
88
|
+
* The live set calls the services; tests and the storybook pass fakes.
|
|
89
|
+
*/
|
|
90
|
+
export interface Effects {
|
|
91
|
+
version: string;
|
|
92
|
+
doctor(): Promise<DoctorReport>;
|
|
93
|
+
runs(): { rows: RunRow[]; warnings: string[] };
|
|
94
|
+
run(id: string): RunDetail;
|
|
95
|
+
/** stops a live role; resolves to the line the toast shows */
|
|
96
|
+
cancel(run: string, name: string): Promise<string>;
|
|
97
|
+
profiles(): {
|
|
98
|
+
names: string[];
|
|
99
|
+
/** global */
|
|
100
|
+
active: string;
|
|
101
|
+
/** what this directory runs on */
|
|
102
|
+
here: string;
|
|
103
|
+
/** the bound repo, null outside one or unbound */
|
|
104
|
+
repo: string | null;
|
|
105
|
+
};
|
|
106
|
+
readProfile(name: string): ProfileDoc;
|
|
107
|
+
catalog(billing: Profile["billing"]): { catalog: Catalog; models: CatalogModel[] };
|
|
108
|
+
validate(p: Profile, c: Catalog): Validation;
|
|
109
|
+
enforcement(rung: string, access: Access): "enforced" | "advisory";
|
|
110
|
+
/** the harnesses a profile can isolate */
|
|
111
|
+
harnesses: readonly string[];
|
|
112
|
+
/** the native agents a profile links, by name */
|
|
113
|
+
agents(p: Profile): string[];
|
|
114
|
+
/**
|
|
115
|
+
* writes the staged treat-likes, then the profile patch, through the ProfileService (validation reads
|
|
116
|
+
* the treat-likes, so they go first); a refused patch leaves the treat-likes written. With `shown` (the
|
|
117
|
+
* profile as the save dialog's last preview read it), the patch is written only over that profile: when
|
|
118
|
+
* another process changed it meanwhile (while a treat-like waited for the catalog lock, say), nothing
|
|
119
|
+
* is written and the result's one error has the path CHANGED_ON_DISK
|
|
120
|
+
*/
|
|
121
|
+
save(
|
|
122
|
+
name: string,
|
|
123
|
+
patch: ProfilePatch,
|
|
124
|
+
treatLikes: Record<string, string>,
|
|
125
|
+
shown?: ProfileDoc,
|
|
126
|
+
): Promise<Saved>;
|
|
127
|
+
/**
|
|
128
|
+
* applies the scope the user confirmed: binds `repo` (this TUI's repo) to it, or with null makes it the
|
|
129
|
+
* global active profile; never a scope recomputed as it writes
|
|
130
|
+
*/
|
|
131
|
+
activate(name: string, repo: string | null): Synced;
|
|
132
|
+
create(name: string, from?: string): Saved;
|
|
133
|
+
remove(name: string): Synced;
|
|
134
|
+
refreshCatalog(): Promise<Refreshed[]>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The word for `here`: the bound repo's profile, or the global active one. */
|
|
138
|
+
export const hereWord = (p: { repo: string | null }): string => (p.repo ? "this repo" : "active");
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What changes when a run changes: the mtimes of its record files and of its roles folder, plus the
|
|
142
|
+
* profiles folder, config.json and projects.json (a saved budget cap or another active profile moves the
|
|
143
|
+
* run's budget fraction).
|
|
144
|
+
*/
|
|
145
|
+
export function stampOf(dir: string): string {
|
|
146
|
+
const p = runPaths(dir);
|
|
147
|
+
return [
|
|
148
|
+
p.runs,
|
|
149
|
+
p.routes,
|
|
150
|
+
p.jev,
|
|
151
|
+
p.agents,
|
|
152
|
+
p.state,
|
|
153
|
+
p.ledger,
|
|
154
|
+
p.roles,
|
|
155
|
+
profilesDir(),
|
|
156
|
+
configFile(),
|
|
157
|
+
projectsFile(),
|
|
158
|
+
]
|
|
159
|
+
.map((f) => {
|
|
160
|
+
try {
|
|
161
|
+
return String(statSync(f).mtimeMs);
|
|
162
|
+
} catch {
|
|
163
|
+
return "-";
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.join(" ");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The run list, each row computed again only when its run's files changed or a role is live (spec §9.4:
|
|
171
|
+
* runs data memoised by mtime). A live run's elapsed times move without any file changing.
|
|
172
|
+
*/
|
|
173
|
+
export function memoRuns(compute: (run: Run) => RunRow, stamp: (dir: string) => string = stampOf) {
|
|
174
|
+
const cache = new Map<string, { stamp: string; row: RunRow }>();
|
|
175
|
+
return (runs: Run[]): RunRow[] =>
|
|
176
|
+
runs.map((r) => {
|
|
177
|
+
const s = stamp(r.dir);
|
|
178
|
+
const hit = cache.get(r.dir);
|
|
179
|
+
if (hit && hit.stamp === s && hit.row.live === 0) return hit.row;
|
|
180
|
+
const row = compute(r);
|
|
181
|
+
cache.set(r.dir, { stamp: s, row });
|
|
182
|
+
return row;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function rowOf(s: RunSummary): RunRow {
|
|
187
|
+
return {
|
|
188
|
+
id: s.id,
|
|
189
|
+
title: s.title,
|
|
190
|
+
repo: s.repo,
|
|
191
|
+
createdAt: s.createdAt,
|
|
192
|
+
live: s.live.length,
|
|
193
|
+
roleRuns: s.totals.runs,
|
|
194
|
+
landed: s.milestones.length,
|
|
195
|
+
budget: s.budget?.fraction ?? null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The climbs and route decisions in a run's routes.jsonl. */
|
|
200
|
+
function routesOf(run: Run): { climbs: Climb[]; decisions: Decision[] } {
|
|
201
|
+
const rows = readRoutes(run);
|
|
202
|
+
return {
|
|
203
|
+
climbs: rows
|
|
204
|
+
.filter((r) => r.source === "climb" && r.from !== null)
|
|
205
|
+
.map((r) => ({
|
|
206
|
+
lane: r.lane,
|
|
207
|
+
from: r.from as string,
|
|
208
|
+
to: r.rung,
|
|
209
|
+
reason: r.reason ?? "",
|
|
210
|
+
env: r.env === true,
|
|
211
|
+
})),
|
|
212
|
+
decisions: rows
|
|
213
|
+
.filter((r) => r.source === "route")
|
|
214
|
+
.map((r) => ({
|
|
215
|
+
lane: r.lane,
|
|
216
|
+
role: r.role,
|
|
217
|
+
source: r.decidedBy,
|
|
218
|
+
kind: r.kind,
|
|
219
|
+
difficulty: r.difficulty,
|
|
220
|
+
rung: r.rung,
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The live effects. `repo` is the git toplevel the TUI was opened in (null outside one): inside a repo
|
|
227
|
+
* bound to a profile, `here` is that profile and activating binds the repo instead of the global profile.
|
|
228
|
+
*/
|
|
229
|
+
export function liveEffects(repo: string | null = null): Effects {
|
|
230
|
+
const deps = defaultDeps();
|
|
231
|
+
const rows = memoRuns((r) => rowOf(summarizeRun(deps, r)));
|
|
232
|
+
const harnesses = HARNESS_KEYS.filter((h) => adapterFor(h));
|
|
233
|
+
const bound = () => (repo !== null && readProjects().bindings[repo] !== undefined ? repo : null);
|
|
234
|
+
return {
|
|
235
|
+
version: VERSION,
|
|
236
|
+
doctor: () => doctor({ bunVersion: Bun.version, version: VERSION, handshake: () => mcpHandshake() }),
|
|
237
|
+
runs() {
|
|
238
|
+
const { runs, corrupt } = listRuns();
|
|
239
|
+
return { rows: rows(runs), warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`) };
|
|
240
|
+
},
|
|
241
|
+
run(id) {
|
|
242
|
+
const run = findRun(id);
|
|
243
|
+
return { summary: summarizeRun(deps, run), ...routesOf(run) };
|
|
244
|
+
},
|
|
245
|
+
async cancel(run, name) {
|
|
246
|
+
const r = await cancel(deps, run, name);
|
|
247
|
+
return `${r.record.name} ${r.record.status}`;
|
|
248
|
+
},
|
|
249
|
+
profiles: () => ({ names: listProfiles(), active: activeName(), here: activeName(repo), repo: bound() }),
|
|
250
|
+
readProfile: readProfileDoc,
|
|
251
|
+
catalog(billing) {
|
|
252
|
+
return {
|
|
253
|
+
catalog: loadCatalog({ timings: false }),
|
|
254
|
+
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
validate: (p, c) => validateProfile(p, c, runnableBackends()),
|
|
258
|
+
enforcement: enforcementOf,
|
|
259
|
+
harnesses,
|
|
260
|
+
agents: (p) => agentFiles(p, VERSION).map((f) => f.name),
|
|
261
|
+
async save(name, patch, treatLikes, shown) {
|
|
262
|
+
for (const [rung, like] of Object.entries(treatLikes)) await saveTreatLike(rung, like);
|
|
263
|
+
return patchProfile(name, patch, shown === undefined ? {} : { expect: shown });
|
|
264
|
+
},
|
|
265
|
+
activate: (name, scope) => activate(name, scope),
|
|
266
|
+
create: createProfile,
|
|
267
|
+
remove: deleteProfile,
|
|
268
|
+
refreshCatalog: () => refreshDiscovery(),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import {
|
|
3
|
+
applyPatch,
|
|
4
|
+
defaultProfileDoc,
|
|
5
|
+
diffProfiles,
|
|
6
|
+
type ProfileDoc,
|
|
7
|
+
resolveProfile,
|
|
8
|
+
} from "../../domain/profile.ts";
|
|
9
|
+
import { validateProfile } from "../../domain/profile-rules.ts";
|
|
10
|
+
import { catalogQuery, loadCatalog } from "../../services/catalog-service.ts";
|
|
11
|
+
import type { DoctorReport } from "../../services/doctor.ts";
|
|
12
|
+
import type { RunSummary } from "../../services/summary.ts";
|
|
13
|
+
import { CHANGED_ON_DISK, type Effects, type RunDetail, type RunRow, rowOf } from "./effects.ts";
|
|
14
|
+
import { withStaged } from "./profile-tree.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fixed data for the storybook (CATHERD_STORY=1), the frame snapshots and the view tests: a setup with
|
|
18
|
+
* one backend missing and one not logged in, two runs (one live), and profiles kept in memory. Only
|
|
19
|
+
* the catalog is the shipped one, read through the catalog service.
|
|
20
|
+
*/
|
|
21
|
+
export const FIXTURE_REPORT: DoctorReport = {
|
|
22
|
+
ready: false,
|
|
23
|
+
version: "1.0.0",
|
|
24
|
+
checks: [
|
|
25
|
+
{ id: "bun", label: "Bun", state: "ok", word: "ready", detail: "1.4.2" },
|
|
26
|
+
{ id: "config", label: "config", state: "ok", word: "ready", detail: "active profile default" },
|
|
27
|
+
{ id: "profile", label: "profile default", state: "ok", word: "ready", detail: "valid" },
|
|
28
|
+
{
|
|
29
|
+
id: "backend:codex",
|
|
30
|
+
label: "codex",
|
|
31
|
+
state: "ok",
|
|
32
|
+
word: "ready",
|
|
33
|
+
detail: "0.156.1, logged in, 14 models",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "backend:claude-code",
|
|
37
|
+
label: "claude-code",
|
|
38
|
+
state: "ok",
|
|
39
|
+
word: "ready",
|
|
40
|
+
detail: "2.1.282, logged in, 6 models",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "backend:opencode",
|
|
44
|
+
label: "opencode",
|
|
45
|
+
state: "warn",
|
|
46
|
+
word: "not logged in",
|
|
47
|
+
detail: "2.0.16 (a failover stand-in uses it)",
|
|
48
|
+
fix: "opencode auth login",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "jev",
|
|
52
|
+
label: "Jev",
|
|
53
|
+
state: "skip",
|
|
54
|
+
word: "no key",
|
|
55
|
+
detail: "optional: routing uses the lane files",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "plugin",
|
|
59
|
+
label: "Claude Code plugin",
|
|
60
|
+
state: "fail",
|
|
61
|
+
word: "missing",
|
|
62
|
+
detail: "not installed",
|
|
63
|
+
fix: "claude plugin marketplace add 47vigen/catherd && claude plugin install catherd@catherd",
|
|
64
|
+
},
|
|
65
|
+
{ id: "agents", label: "Claude agents", state: "ok", word: "ready", detail: "2 linked" },
|
|
66
|
+
{ id: "mcp", label: "MCP server", state: "ok", word: "ready", detail: "21 tools" },
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const summary = (o: Partial<RunSummary> & Pick<RunSummary, "id" | "title">): RunSummary => ({
|
|
71
|
+
repo: "/home/me/app",
|
|
72
|
+
createdAt: "2026-09-26T11:48:00.000Z",
|
|
73
|
+
stateTail: [],
|
|
74
|
+
live: [],
|
|
75
|
+
totals: {
|
|
76
|
+
runs: 0,
|
|
77
|
+
ok: 0,
|
|
78
|
+
notOk: [],
|
|
79
|
+
tokens: { input: 0, cached: 0, output: 0 },
|
|
80
|
+
costUsd: 0,
|
|
81
|
+
wallMinutes: 12,
|
|
82
|
+
},
|
|
83
|
+
agents: { runs: 0, totalTokens: 0, costUsd: 0 },
|
|
84
|
+
jev: { decisions: 0, fallbacks: 0 },
|
|
85
|
+
harness: [],
|
|
86
|
+
budget: null,
|
|
87
|
+
milestones: [],
|
|
88
|
+
warnings: [],
|
|
89
|
+
...o,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const FIXTURE_RUNS: RunDetail[] = [
|
|
93
|
+
{
|
|
94
|
+
summary: summary({
|
|
95
|
+
id: "20260926-114800-jobs-screen",
|
|
96
|
+
title: "Jobs screen",
|
|
97
|
+
stateTail: ["M1 in review", "Next: fix round for M1.L2"],
|
|
98
|
+
live: [
|
|
99
|
+
{
|
|
100
|
+
name: "worker-M1.L2",
|
|
101
|
+
rung: "codex:gpt-6-sol#medium",
|
|
102
|
+
state: "running",
|
|
103
|
+
secs: 252,
|
|
104
|
+
dispatchId: "d2",
|
|
105
|
+
},
|
|
106
|
+
{ name: "reviewer-M1", rung: "codex:gpt-6-sol#high", state: "starting", secs: 4, dispatchId: "d3" },
|
|
107
|
+
],
|
|
108
|
+
totals: {
|
|
109
|
+
runs: 3,
|
|
110
|
+
ok: 3,
|
|
111
|
+
notOk: [],
|
|
112
|
+
tokens: { input: 812_000, cached: 640_000, output: 41_000 },
|
|
113
|
+
costUsd: 0,
|
|
114
|
+
wallMinutes: 12,
|
|
115
|
+
},
|
|
116
|
+
jev: { decisions: 2, fallbacks: 0 },
|
|
117
|
+
budget: { fraction: 0.52, minutes: { spent: 31, cap: 60 } },
|
|
118
|
+
milestones: ["M0 | scaffold the jobs screen | 3f2a9c1 | 9 | bun test test/jobs"],
|
|
119
|
+
}),
|
|
120
|
+
climbs: [
|
|
121
|
+
{
|
|
122
|
+
lane: "M1.L2",
|
|
123
|
+
from: "codex:gpt-6-luna#high",
|
|
124
|
+
to: "codex:gpt-6-sol#medium",
|
|
125
|
+
reason: "refused",
|
|
126
|
+
env: false,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
decisions: [
|
|
130
|
+
{
|
|
131
|
+
lane: "M1.L1",
|
|
132
|
+
role: "worker",
|
|
133
|
+
source: "lane",
|
|
134
|
+
kind: "repo_code",
|
|
135
|
+
difficulty: "copy",
|
|
136
|
+
rung: "codex:gpt-6-luna#high",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
lane: "M1.L2",
|
|
140
|
+
role: "worker",
|
|
141
|
+
source: "lane",
|
|
142
|
+
kind: "repo_code",
|
|
143
|
+
difficulty: "build",
|
|
144
|
+
rung: "codex:gpt-6-luna#high",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
summary: summary({
|
|
150
|
+
id: "20260925-090000-auth-refactor",
|
|
151
|
+
title: "Auth refactor",
|
|
152
|
+
repo: "/home/me/api",
|
|
153
|
+
createdAt: "2026-09-25T09:00:00.000Z",
|
|
154
|
+
stateTail: ["Next: done"],
|
|
155
|
+
totals: {
|
|
156
|
+
runs: 9,
|
|
157
|
+
ok: 8,
|
|
158
|
+
notOk: ["worker-M2.L1 (limit)"],
|
|
159
|
+
tokens: { input: 3_100_000, cached: 2_400_000, output: 160_000 },
|
|
160
|
+
costUsd: 1.84,
|
|
161
|
+
wallMinutes: 96,
|
|
162
|
+
},
|
|
163
|
+
milestones: [
|
|
164
|
+
"M1 | tokens | a1 | 30 | ok",
|
|
165
|
+
"M2 | sessions | b2 | 41 | ok",
|
|
166
|
+
"M3 | cleanup | c3 | 25 | ok",
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
climbs: [],
|
|
170
|
+
decisions: [],
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
const fixtureRow = (d: RunDetail): RunRow => rowOf(d.summary);
|
|
175
|
+
|
|
176
|
+
const BACKENDS = ["claude", "codex", "claude-code", "opencode"];
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Effects over the fixtures; profile writes stay in memory and are recorded in `writes`. `repo` is the
|
|
180
|
+
* directory the TUI stands in and `bindings` the repo bindings, as `liveEffects(repo)` reads them.
|
|
181
|
+
*/
|
|
182
|
+
export function fixtureEffects(
|
|
183
|
+
o: {
|
|
184
|
+
runs?: RunDetail[];
|
|
185
|
+
report?: DoctorReport;
|
|
186
|
+
repo?: string;
|
|
187
|
+
bindings?: Record<string, string>;
|
|
188
|
+
} = {},
|
|
189
|
+
): Effects & { writes: string[] } {
|
|
190
|
+
const runs = o.runs ?? FIXTURE_RUNS;
|
|
191
|
+
const docs = new Map<string, ProfileDoc>([["default", defaultProfileDoc()]]);
|
|
192
|
+
let active = "default";
|
|
193
|
+
const bindings = new Map(Object.entries(o.bindings ?? {}));
|
|
194
|
+
const bound = () => (o.repo && bindings.has(o.repo) ? o.repo : null);
|
|
195
|
+
const writes: string[] = [];
|
|
196
|
+
/** what the ProfileService returns for writing `after` over `before` */
|
|
197
|
+
const result = (
|
|
198
|
+
name: string,
|
|
199
|
+
before: ProfileDoc,
|
|
200
|
+
after: ProfileDoc,
|
|
201
|
+
staged: Record<string, string> = {},
|
|
202
|
+
) => {
|
|
203
|
+
const p = resolveProfile(after, name);
|
|
204
|
+
const v = validateProfile(p, withStaged(loadCatalog({ timings: false }), staged), BACKENDS);
|
|
205
|
+
return {
|
|
206
|
+
saved: v.errors.length === 0,
|
|
207
|
+
...v,
|
|
208
|
+
diff: diffProfiles(resolveProfile(before, name), p),
|
|
209
|
+
linked: [],
|
|
210
|
+
pruned: [],
|
|
211
|
+
newSessionNeededFor: [],
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
writes,
|
|
216
|
+
version: "1.0.0",
|
|
217
|
+
doctor: async () => o.report ?? FIXTURE_REPORT,
|
|
218
|
+
runs: () => ({ rows: runs.map(fixtureRow), warnings: [] }),
|
|
219
|
+
run(id) {
|
|
220
|
+
const d = runs.find((r) => r.summary.id === id);
|
|
221
|
+
if (!d) throw new Error(`no run "${id}"`);
|
|
222
|
+
return d;
|
|
223
|
+
},
|
|
224
|
+
async cancel(run, name) {
|
|
225
|
+
writes.push(`cancel ${run} ${name}`);
|
|
226
|
+
return `${name} cancelled`;
|
|
227
|
+
},
|
|
228
|
+
profiles: () => ({
|
|
229
|
+
names: [...docs.keys()].sort(),
|
|
230
|
+
active,
|
|
231
|
+
here: bindings.get(o.repo ?? "") ?? active,
|
|
232
|
+
repo: bound(),
|
|
233
|
+
}),
|
|
234
|
+
readProfile(name) {
|
|
235
|
+
const d = docs.get(name);
|
|
236
|
+
if (!d) throw new Error(`no profile named "${name}"`);
|
|
237
|
+
return d;
|
|
238
|
+
},
|
|
239
|
+
catalog: (billing) => ({
|
|
240
|
+
catalog: loadCatalog({ timings: false }),
|
|
241
|
+
models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
|
|
242
|
+
}),
|
|
243
|
+
validate: (p, c) => validateProfile(p, c, BACKENDS),
|
|
244
|
+
enforcement: (rung) => (rung.startsWith("codex:") ? "enforced" : "advisory"),
|
|
245
|
+
harnesses: ["codex", "claude-code", "opencode"],
|
|
246
|
+
agents: (p) =>
|
|
247
|
+
(["architect", "verifier"] as const)
|
|
248
|
+
.filter((r) => p.roles[r].enabled)
|
|
249
|
+
.flatMap((r) =>
|
|
250
|
+
p.roles[r].rungs
|
|
251
|
+
.filter((x) => x.startsWith("claude:"))
|
|
252
|
+
.map((x) => `catherd-${p.name}-${r}-${x.slice(7).replace("#", "-")}`),
|
|
253
|
+
),
|
|
254
|
+
async save(name, patch, treatLikes, shown) {
|
|
255
|
+
const before = docs.get(name) ?? defaultProfileDoc(name);
|
|
256
|
+
// the ProfileService's compare-and-swap: nothing is written over a profile the preview did not show
|
|
257
|
+
if (shown !== undefined && !isDeepStrictEqual(before, shown))
|
|
258
|
+
return {
|
|
259
|
+
saved: false,
|
|
260
|
+
errors: [
|
|
261
|
+
{
|
|
262
|
+
path: CHANGED_ON_DISK,
|
|
263
|
+
message: `profile "${name}" changed on disk since it was shown`,
|
|
264
|
+
fix: "check the changes and save again",
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
warnings: [],
|
|
268
|
+
diff: [],
|
|
269
|
+
linked: [],
|
|
270
|
+
pruned: [],
|
|
271
|
+
newSessionNeededFor: [],
|
|
272
|
+
};
|
|
273
|
+
const after = applyPatch(before, patch);
|
|
274
|
+
const r = result(name, before, after, treatLikes);
|
|
275
|
+
if (r.saved) {
|
|
276
|
+
docs.set(name, after);
|
|
277
|
+
const tl = Object.keys(treatLikes).length ? ` ${JSON.stringify(treatLikes)}` : "";
|
|
278
|
+
writes.push(`save ${name} ${JSON.stringify(patch)}${tl}`);
|
|
279
|
+
}
|
|
280
|
+
return r;
|
|
281
|
+
},
|
|
282
|
+
activate(name, repo) {
|
|
283
|
+
if (repo !== null) {
|
|
284
|
+
bindings.set(repo, name);
|
|
285
|
+
writes.push(`bind ${name} ${repo}`);
|
|
286
|
+
} else {
|
|
287
|
+
active = name;
|
|
288
|
+
writes.push(`activate ${name}`);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
linked: [],
|
|
292
|
+
pruned: [],
|
|
293
|
+
newSessionNeededFor: [`catherd-${name}-architect-claude-opus-5-5-high`],
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
create(name, from) {
|
|
297
|
+
const doc = { ...(from ? (docs.get(from) as ProfileDoc) : defaultProfileDoc(name)), name };
|
|
298
|
+
const r = result(name, doc, doc);
|
|
299
|
+
if (r.saved) {
|
|
300
|
+
docs.set(name, doc);
|
|
301
|
+
writes.push(`create ${name}${from ? ` from ${from}` : ""}`);
|
|
302
|
+
}
|
|
303
|
+
return r;
|
|
304
|
+
},
|
|
305
|
+
remove(name) {
|
|
306
|
+
if (name === active) throw new Error(`"${name}" is the active profile`);
|
|
307
|
+
const at = [...bindings].find(([, p]) => p === name);
|
|
308
|
+
if (at) throw new Error(`"${name}" is bound to ${at[0]}`);
|
|
309
|
+
docs.delete(name);
|
|
310
|
+
writes.push(`remove ${name}`);
|
|
311
|
+
return { linked: [], pruned: [], newSessionNeededFor: [] };
|
|
312
|
+
},
|
|
313
|
+
async refreshCatalog() {
|
|
314
|
+
writes.push("refresh");
|
|
315
|
+
return [{ backend: "codex", models: 14, fetchedAt: "2026-09-26T12:00:00.000Z" }];
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|