catherd-cli 0.2.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +141 -33
  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 +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,585 @@
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 { RunRecord } from "../../domain/record.ts";
12
+ import type { DoctorReport } from "../../services/doctor.ts";
13
+ import type { RunSummary } from "../../services/summary.ts";
14
+ import {
15
+ CHANGED_ON_DISK,
16
+ type Effects,
17
+ type RoleDetail,
18
+ type RoleRow,
19
+ rowOf,
20
+ type SessionRow,
21
+ type SessionRun,
22
+ } from "./effects.ts";
23
+ import { withStaged } from "./profile-tree.ts";
24
+
25
+ /**
26
+ * Fixed data for the storybook (CATHERD_STORY=1), the frame snapshots and the view tests: a setup with
27
+ * one backend missing and one not logged in, two runs (one live), and profiles kept in memory. Only
28
+ * the catalog is the shipped one, read through the catalog service.
29
+ */
30
+ export const FIXTURE_REPORT: DoctorReport = {
31
+ ready: false,
32
+ version: "1.0.0",
33
+ checks: [
34
+ { id: "bun", label: "Bun", state: "ok", word: "ready", detail: "1.4.2" },
35
+ { id: "config", label: "config", state: "ok", word: "ready", detail: "active profile default" },
36
+ { id: "profile", label: "profile default", state: "ok", word: "ready", detail: "valid" },
37
+ {
38
+ id: "backend:codex",
39
+ label: "codex",
40
+ state: "ok",
41
+ word: "ready",
42
+ detail: "0.156.1, logged in, 14 models",
43
+ },
44
+ {
45
+ id: "backend:claude-code",
46
+ label: "claude-code",
47
+ state: "ok",
48
+ word: "ready",
49
+ detail: "2.1.282, logged in, 6 models",
50
+ },
51
+ {
52
+ id: "backend:opencode",
53
+ label: "opencode",
54
+ state: "warn",
55
+ word: "not logged in",
56
+ detail: "2.0.16 (a failover stand-in uses it)",
57
+ fix: "opencode auth login",
58
+ },
59
+ {
60
+ id: "jev",
61
+ label: "Jev",
62
+ state: "skip",
63
+ word: "no key",
64
+ detail: "optional: routing uses the lane files",
65
+ },
66
+ {
67
+ id: "plugin",
68
+ label: "Claude Code plugin",
69
+ state: "fail",
70
+ word: "missing",
71
+ detail: "not installed",
72
+ fix: "claude plugin marketplace add 47vigen/catherd && claude plugin install catherd@catherd",
73
+ },
74
+ { id: "agents", label: "Claude agents", state: "ok", word: "ready", detail: "2 linked" },
75
+ { id: "mcp", label: "MCP server", state: "ok", word: "ready", detail: "21 tools" },
76
+ ],
77
+ };
78
+
79
+ const summary = (o: Partial<RunSummary> & Pick<RunSummary, "id" | "title">): RunSummary => ({
80
+ repo: "/home/me/app",
81
+ createdAt: "2026-09-26T11:48:00.000Z",
82
+ session: null,
83
+ continuedIn: null,
84
+ stateTail: [],
85
+ live: [],
86
+ totals: {
87
+ runs: 0,
88
+ ok: 0,
89
+ notOk: [],
90
+ tokens: { input: 0, cached: 0, output: 0 },
91
+ costUsd: 0,
92
+ wallMinutes: 12,
93
+ },
94
+ agents: { runs: 0, totalTokens: 0, costUsd: 0 },
95
+ jev: { decisions: 0, fallbacks: 0 },
96
+ harness: [],
97
+ budget: null,
98
+ milestones: [],
99
+ verifier: null,
100
+ questions: [],
101
+ warnings: [],
102
+ ...o,
103
+ });
104
+
105
+ const JOBS = "20260926-114800-jobs-screen";
106
+ const AUTH = "20260925-090000-auth-refactor";
107
+ const PLAN4 = "20260924-080000-auth-plan-4";
108
+
109
+ const FIXTURE_RUNS: RunSummary[] = [
110
+ summary({
111
+ id: JOBS,
112
+ title: "Jobs screen",
113
+ session: { sessionId: "s-jobs", hostSessionId: null, name: "jobs screen", live: true },
114
+ stateTail: ["M1 in review", "Next: fix round for M1.L2"],
115
+ live: [
116
+ {
117
+ name: "worker-M1.L2",
118
+ rung: "codex:gpt-6-sol#medium",
119
+ state: "running",
120
+ secs: 252,
121
+ dispatchId: "d2",
122
+ },
123
+ { name: "reviewer-M1", rung: "codex:gpt-6-sol#high", state: "starting", secs: 4, dispatchId: "d3" },
124
+ ],
125
+ totals: {
126
+ runs: 3,
127
+ ok: 3,
128
+ notOk: [],
129
+ tokens: { input: 812_000, cached: 640_000, output: 41_000 },
130
+ costUsd: 0,
131
+ wallMinutes: 12,
132
+ },
133
+ jev: { decisions: 2, fallbacks: 0 },
134
+ budget: { fraction: 0.52, minutes: { spent: 31, cap: 60 } },
135
+ milestones: ["M0 | scaffold the jobs screen | 3f2a9c1 | 9 | bun test test/jobs"],
136
+ }),
137
+ summary({
138
+ id: AUTH,
139
+ title: "Auth refactor",
140
+ repo: "/home/me/api",
141
+ createdAt: "2026-09-25T09:00:00.000Z",
142
+ session: { sessionId: "s-auth", hostSessionId: null, name: "auth build", live: false },
143
+ continuedIn: "kit follow-up",
144
+ stateTail: ["Next: done"],
145
+ totals: {
146
+ runs: 9,
147
+ ok: 8,
148
+ notOk: ["worker-M2.L1 (limit)"],
149
+ tokens: { input: 3_100_000, cached: 2_400_000, output: 160_000 },
150
+ costUsd: 1.84,
151
+ wallMinutes: 96,
152
+ },
153
+ milestones: ["M1 | tokens | a1 | 30 | ok", "M2 | sessions | b2 | 41 | ok", "M3 | cleanup | c3 | 25 | ok"],
154
+ }),
155
+ ];
156
+
157
+ /** A role of the fixtures; `since` minutes before the fixed clock (2026-09-26 12:00 UTC) for a live one. */
158
+ const role = (o: Partial<RoleRow> & Pick<RoleRow, "run" | "dispatchId" | "name" | "rung">): RoleRow => ({
159
+ role: o.name.split("-")[0] ?? "worker",
160
+ status: "ok",
161
+ live: false,
162
+ since: "2026-09-26T11:48:10.000Z",
163
+ secs: null,
164
+ lastEvent: null,
165
+ replyStatus: "complete",
166
+ ...o,
167
+ });
168
+
169
+ const auth = (continued: SessionRun["continued"]): SessionRun => ({
170
+ id: AUTH,
171
+ title: "Auth refactor",
172
+ repo: "/home/me/api",
173
+ createdAt: "2026-09-25T09:00:00.000Z",
174
+ continued,
175
+ continuedIn: continued === "elsewhere" ? "kit follow-up" : null,
176
+ budget: null,
177
+ milestones: [
178
+ { name: "M1", landed: true, what: "tokens" },
179
+ { name: "M2", landed: true, what: "sessions" },
180
+ { name: "M3", landed: true, what: "cleanup" },
181
+ ],
182
+ roles: [
183
+ role({ run: AUTH, dispatchId: "a3", name: "reviewer-M3", rung: "codex:gpt-6-sol#high", secs: 188 }),
184
+ role({ run: AUTH, dispatchId: "a2", name: "worker-M3.L1", rung: "codex:gpt-6-luna#high", secs: 402 }),
185
+ role({
186
+ run: AUTH,
187
+ dispatchId: "a1",
188
+ name: "worker-M2.L1",
189
+ rung: "codex:gpt-6-sol#medium",
190
+ status: "limit",
191
+ secs: 95,
192
+ replyStatus: null,
193
+ }),
194
+ ],
195
+ });
196
+
197
+ /** The Runs tab's sessions: a live one with three live roles, one whose run another session continued, and it. */
198
+ export const FIXTURE_SESSIONS: { row: SessionRow; runs: SessionRun[] }[] = [
199
+ {
200
+ row: {
201
+ key: "s-jobs",
202
+ name: "jobs screen",
203
+ live: true,
204
+ runs: 1,
205
+ liveRoles: 3,
206
+ landed: 1,
207
+ lastActivity: "2026-09-26T11:59:40.000Z",
208
+ },
209
+ runs: [
210
+ {
211
+ id: JOBS,
212
+ title: "Jobs screen",
213
+ repo: "/home/me/app",
214
+ createdAt: "2026-09-26T11:48:00.000Z",
215
+ continued: null,
216
+ continuedIn: null,
217
+ budget: 0.52,
218
+ milestones: [
219
+ { name: "M0", landed: true, what: "scaffold the jobs screen" },
220
+ { name: "M1", landed: false, what: "" },
221
+ ],
222
+ roles: [
223
+ role({
224
+ run: JOBS,
225
+ dispatchId: "d2",
226
+ name: "worker-M1.L2",
227
+ rung: "codex:gpt-6-sol#medium",
228
+ status: "running",
229
+ live: true,
230
+ since: "2026-09-26T11:55:48.000Z",
231
+ lastEvent: "$ bun test test/jobs --bail",
232
+ replyStatus: null,
233
+ }),
234
+ role({
235
+ run: JOBS,
236
+ dispatchId: "d4",
237
+ name: "worker-M1.L3",
238
+ rung: "codex:gpt-6-luna#high",
239
+ status: "running",
240
+ live: true,
241
+ since: "2026-09-26T11:57:00.000Z",
242
+ lastEvent: "edit src/jobs/list.tsx",
243
+ replyStatus: null,
244
+ }),
245
+ role({
246
+ run: JOBS,
247
+ dispatchId: "d3",
248
+ name: "reviewer-M1",
249
+ rung: "codex:gpt-6-sol#high",
250
+ status: "starting",
251
+ live: true,
252
+ since: "2026-09-26T11:59:56.000Z",
253
+ replyStatus: null,
254
+ }),
255
+ role({
256
+ run: JOBS,
257
+ dispatchId: "d1",
258
+ name: "worker-M1.L1",
259
+ rung: "codex:gpt-6-luna#high",
260
+ secs: 312,
261
+ }),
262
+ ],
263
+ },
264
+ ],
265
+ },
266
+ {
267
+ row: {
268
+ key: "s-kit",
269
+ name: "kit follow-up",
270
+ live: false,
271
+ runs: 1,
272
+ liveRoles: 0,
273
+ landed: 3,
274
+ lastActivity: "2026-09-25T11:20:00.000Z",
275
+ },
276
+ runs: [auth("here")],
277
+ },
278
+ {
279
+ row: {
280
+ key: "s-auth",
281
+ name: "auth build",
282
+ live: false,
283
+ runs: 2,
284
+ liveRoles: 0,
285
+ landed: 5,
286
+ lastActivity: "2026-09-25T10:36:00.000Z",
287
+ },
288
+ runs: [
289
+ auth("elsewhere"),
290
+ {
291
+ id: PLAN4,
292
+ title: "Auth plan 4",
293
+ repo: "/home/me/api",
294
+ createdAt: "2026-09-24T08:00:00.000Z",
295
+ continued: null,
296
+ continuedIn: null,
297
+ budget: null,
298
+ milestones: [
299
+ { name: "M1", landed: true, what: "schema" },
300
+ { name: "M2", landed: true, what: "handlers" },
301
+ ],
302
+ roles: [
303
+ role({
304
+ run: PLAN4,
305
+ dispatchId: "p2",
306
+ name: "reviewer-M2",
307
+ rung: "codex:gpt-6-sol#high",
308
+ secs: 140,
309
+ }),
310
+ role({
311
+ run: PLAN4,
312
+ dispatchId: "p1",
313
+ name: "worker-M2.L1",
314
+ rung: "codex:gpt-6-sol#medium",
315
+ secs: 610,
316
+ }),
317
+ ],
318
+ },
319
+ ],
320
+ },
321
+ ];
322
+
323
+ /** The role screen's text: the brief, the reply and the record behind worker-M1.L1 of the jobs screen. */
324
+ const FIXTURE_ROLE: Omit<RoleDetail, "run" | "runTitle" | "dispatchId" | "name" | "role" | "rung" | "state"> =
325
+ {
326
+ brief:
327
+ "Read /home/me/.local/share/catherd/runs/jobs-screen/lanes/M1.L1.md\nFast check: bun test test/jobs/list",
328
+ reply:
329
+ "Done: the list renders every job with its state.\nsrc/jobs/list.tsx:12 — the list\nsrc/jobs/list.test.tsx:4 — its test\nSTATUS: complete — list in place, fast check green",
330
+ record: null,
331
+ };
332
+
333
+ /** The digest `land` writes for M0 of the jobs screen (spec 1.1 §10): what a landed milestone's screen shows. */
334
+ export const FIXTURE_DIGEST = [
335
+ "# M0 — scaffold the jobs screen",
336
+ "",
337
+ "Commit 3f2a9c1 · 9 min · landed 2026-09-26T11:57:00.000Z",
338
+ "A-lines: A1 the jobs screen lists every job with its state",
339
+ "",
340
+ "Lanes:",
341
+ "- M0.L1 · codex:gpt-6-luna#high",
342
+ "- M0.L2 · codex:gpt-6-luna#medium → codex:gpt-6-sol#medium · climbs: fast check red twice",
343
+ "",
344
+ "Reviewer: reviewer-M0 · 1 finding(s): 0 BLOCKER, 0 BUG, 1 NIT",
345
+ "Verifier: PASS (verifier-M0)",
346
+ "Evidence: bun test test/jobs",
347
+ "Tokens: 1204k in (960k cached) · 58k out · Claude subagents 31k (reported)",
348
+ "",
349
+ ].join("\n");
350
+
351
+ /** The record a finished fixture role would have: what the role screen shows under RECORD. */
352
+ function fixtureRecord(r: SessionRun, x: RoleRow): RunRecord {
353
+ const start = Date.parse(x.since);
354
+ return {
355
+ schema: 1,
356
+ runId: r.id,
357
+ dispatchId: x.dispatchId,
358
+ name: x.name,
359
+ role: x.role,
360
+ lane: x.name.includes("-M") && x.name.includes(".") ? x.name.slice(x.name.indexOf("-") + 1) : null,
361
+ backend: x.rung.slice(0, x.rung.indexOf(":")),
362
+ rung: x.rung,
363
+ attempt: 1,
364
+ failoverFrom: null,
365
+ thread: "01a0d0d4-d0a6-71a1-983c-82a9169200b4",
366
+ status: x.status as RunRecord["status"],
367
+ startedAt: x.since,
368
+ endedAt: new Date(start + (x.secs ?? 0) * 1000).toISOString(),
369
+ secs: x.secs ?? 0,
370
+ exitCode: 0,
371
+ signal: null,
372
+ cliVersion: "0.157.0",
373
+ tokens: { input: 812_000, cached: 640_000, output: 41_000 },
374
+ costUsd: null,
375
+ changedOwned: ["src/jobs/list.tsx", "src/jobs/list.test.tsx"],
376
+ violations: [],
377
+ replyStatus: x.replyStatus as RunRecord["replyStatus"],
378
+ replyWhy: x.replyStatus ? "list in place, fast check green" : null,
379
+ threadHeavy: false,
380
+ access: "workspace-write",
381
+ isolated: false,
382
+ images: [],
383
+ error: null,
384
+ replyPath: `roles/${x.name}/${x.dispatchId}/reply.md`,
385
+ };
386
+ }
387
+
388
+ const BACKENDS = ["claude", "codex", "claude-code", "opencode"];
389
+
390
+ /**
391
+ * Effects over the fixtures; profile writes stay in memory and are recorded in `writes`. `repo` is the
392
+ * directory the TUI stands in and `bindings` the repo bindings, as `liveEffects(repo)` reads them.
393
+ */
394
+ export interface FixtureOptions {
395
+ runs?: RunSummary[];
396
+ sessions?: { row: SessionRow; runs: SessionRun[] }[];
397
+ report?: DoctorReport;
398
+ repo?: string;
399
+ bindings?: Record<string, string>;
400
+ /** false: the run folders cannot be watched here, as on a platform without fs.watch */
401
+ watchable?: boolean;
402
+ }
403
+
404
+ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
405
+ writes: string[];
406
+ /** what a change to a watched run file does: every open watch hears of it */
407
+ touch(): void;
408
+ /** the run folders watched now */
409
+ watched: string[][];
410
+ } {
411
+ const runs = o.runs ?? FIXTURE_RUNS;
412
+ const sessions = o.sessions ?? FIXTURE_SESSIONS;
413
+ const listeners = new Set<() => void>();
414
+ const watched: string[][] = [];
415
+ const docs = new Map<string, ProfileDoc>([["default", defaultProfileDoc()]]);
416
+ let active = "default";
417
+ const bindings = new Map(Object.entries(o.bindings ?? {}));
418
+ const bound = () => (o.repo && bindings.has(o.repo) ? o.repo : null);
419
+ const writes: string[] = [];
420
+ /** what the ProfileService returns for writing `after` over `before` */
421
+ const result = (
422
+ name: string,
423
+ before: ProfileDoc,
424
+ after: ProfileDoc,
425
+ staged: Record<string, string> = {},
426
+ ) => {
427
+ const p = resolveProfile(after, name);
428
+ const v = validateProfile(p, withStaged(loadCatalog({ timings: false }), staged), BACKENDS);
429
+ return {
430
+ saved: v.errors.length === 0,
431
+ ...v,
432
+ diff: diffProfiles(resolveProfile(before, name), p),
433
+ linked: [],
434
+ pruned: [],
435
+ newSessionNeededFor: [],
436
+ };
437
+ };
438
+ return {
439
+ writes,
440
+ watched,
441
+ touch: () => {
442
+ for (const l of listeners) l();
443
+ },
444
+ version: "1.0.0",
445
+ doctor: async () => o.report ?? FIXTURE_REPORT,
446
+ runs: () => ({ rows: runs.map(rowOf), warnings: [] }),
447
+ sessions: () => ({ rows: sessions.map((x) => x.row), warnings: [] }),
448
+ session(key) {
449
+ const x = sessions.find((y) => y.row.key === key);
450
+ if (!x) throw new Error(`no session "${key}"`);
451
+ return { session: x.row, runs: x.runs, dirs: x.runs.map((r) => `/runs/${r.id}`) };
452
+ },
453
+ role(run, dispatchId) {
454
+ for (const x of sessions)
455
+ for (const r of x.runs) {
456
+ const role = r.id === run ? r.roles.find((y) => y.dispatchId === dispatchId) : undefined;
457
+ if (!role) continue;
458
+ return {
459
+ run,
460
+ runTitle: r.title,
461
+ dispatchId,
462
+ name: role.name,
463
+ role: role.role,
464
+ rung: role.rung,
465
+ state: role.live ? role.status : "finished",
466
+ brief: FIXTURE_ROLE.brief,
467
+ reply: role.live ? "" : FIXTURE_ROLE.reply,
468
+ record: role.live ? null : fixtureRecord(r, role),
469
+ };
470
+ }
471
+ throw new Error(`no dispatch ${dispatchId} in run ${run}`);
472
+ },
473
+ milestone(run, name) {
474
+ for (const x of sessions)
475
+ for (const r of x.runs) {
476
+ const m = r.id === run ? r.milestones.find((y) => y.name === name) : undefined;
477
+ if (!m) continue;
478
+ return { run, runTitle: r.title, ...m, digest: m.landed ? FIXTURE_DIGEST : null };
479
+ }
480
+ throw new Error(`no milestone "${name}" in run ${run}`);
481
+ },
482
+ watch(dirs, onChange) {
483
+ if (o.watchable === false) return null;
484
+ watched.push(dirs);
485
+ listeners.add(onChange);
486
+ return () => {
487
+ listeners.delete(onChange);
488
+ watched.splice(watched.indexOf(dirs), 1);
489
+ };
490
+ },
491
+ async cancel(run, name) {
492
+ writes.push(`cancel ${run} ${name}`);
493
+ return `${name} cancelled`;
494
+ },
495
+ profiles: () => ({
496
+ names: [...docs.keys()].sort(),
497
+ active,
498
+ here: bindings.get(o.repo ?? "") ?? active,
499
+ repo: bound(),
500
+ }),
501
+ readProfile(name) {
502
+ const d = docs.get(name);
503
+ if (!d) throw new Error(`no profile named "${name}"`);
504
+ return d;
505
+ },
506
+ catalog: (billing) => ({
507
+ catalog: loadCatalog({ timings: false }),
508
+ models: catalogQuery({ scoredOnly: false, limit: Number.MAX_SAFE_INTEGER }, billing).models,
509
+ }),
510
+ validate: (p, c) => validateProfile(p, c, BACKENDS),
511
+ enforcement: (rung) => (rung.startsWith("codex:") ? "enforced" : "advisory"),
512
+ harnesses: ["codex", "claude-code", "opencode"],
513
+ agents: (p) =>
514
+ (["architect", "verifier"] as const)
515
+ .filter((r) => p.roles[r].enabled)
516
+ .flatMap((r) =>
517
+ p.roles[r].rungs
518
+ .filter((x) => x.startsWith("claude:"))
519
+ .map((x) => `catherd-${p.name}-${r}-${x.slice(7).replace("#", "-")}`),
520
+ ),
521
+ async save(name, patch, treatLikes, shown) {
522
+ const before = docs.get(name) ?? defaultProfileDoc(name);
523
+ // the ProfileService's compare-and-swap: nothing is written over a profile the preview did not show
524
+ if (shown !== undefined && !isDeepStrictEqual(before, shown))
525
+ return {
526
+ saved: false,
527
+ errors: [
528
+ {
529
+ path: CHANGED_ON_DISK,
530
+ message: `profile "${name}" changed on disk since it was shown`,
531
+ fix: "check the changes and save again",
532
+ },
533
+ ],
534
+ warnings: [],
535
+ diff: [],
536
+ linked: [],
537
+ pruned: [],
538
+ newSessionNeededFor: [],
539
+ };
540
+ const after = applyPatch(before, patch);
541
+ const r = result(name, before, after, treatLikes);
542
+ if (r.saved) {
543
+ docs.set(name, after);
544
+ const tl = Object.keys(treatLikes).length ? ` ${JSON.stringify(treatLikes)}` : "";
545
+ writes.push(`save ${name} ${JSON.stringify(patch)}${tl}`);
546
+ }
547
+ return r;
548
+ },
549
+ activate(name, repo) {
550
+ if (repo !== null) {
551
+ bindings.set(repo, name);
552
+ writes.push(`bind ${name} ${repo}`);
553
+ } else {
554
+ active = name;
555
+ writes.push(`activate ${name}`);
556
+ }
557
+ return {
558
+ linked: [],
559
+ pruned: [],
560
+ newSessionNeededFor: [`catherd-${name}-architect-claude-opus-5-5-high`],
561
+ };
562
+ },
563
+ create(name, from) {
564
+ const doc = { ...(from ? (docs.get(from) as ProfileDoc) : defaultProfileDoc(name)), name };
565
+ const r = result(name, doc, doc);
566
+ if (r.saved) {
567
+ docs.set(name, doc);
568
+ writes.push(`create ${name}${from ? ` from ${from}` : ""}`);
569
+ }
570
+ return r;
571
+ },
572
+ remove(name) {
573
+ if (name === active) throw new Error(`"${name}" is the active profile`);
574
+ const at = [...bindings].find(([, p]) => p === name);
575
+ if (at) throw new Error(`"${name}" is bound to ${at[0]}`);
576
+ docs.delete(name);
577
+ writes.push(`remove ${name}`);
578
+ return { linked: [], pruned: [], newSessionNeededFor: [] };
579
+ },
580
+ async refreshCatalog() {
581
+ writes.push("refresh");
582
+ return [{ backend: "codex", models: 14, fetchedAt: "2026-09-26T12:00:00.000Z" }];
583
+ },
584
+ };
585
+ }