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
@@ -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
+ }