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,334 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { z } from "zod";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { assertId } from "../domain/ids.ts";
6
+ import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
7
+ import type { OutcomeRow, RouteRow } from "../domain/route.ts";
8
+ import { slug } from "../domain/util.ts";
9
+ import { withFileLock } from "../infra/filelock.ts";
10
+ import { dataDir, repoDir, runsDir } from "../infra/paths.ts";
11
+ import {
12
+ appendJsonl,
13
+ ensureJsonlHeader,
14
+ ensurePrivateDir,
15
+ PRIVATE_DIR,
16
+ appendPrivate,
17
+ readJsonl,
18
+ readVersioned,
19
+ writeJsonAtomic,
20
+ writeTextAtomic,
21
+ } from "../infra/store.ts";
22
+
23
+ /** Spec §3.3: the Claude Code session that started a run; absent on 1.0 runs and outside Claude Code. */
24
+ const StartedBySchema = z.object({
25
+ sessionId: z.string(),
26
+ hostSessionId: z.string().nullable(),
27
+ name: z.string().nullable(),
28
+ });
29
+ export type StartedBy = z.infer<typeof StartedBySchema>;
30
+
31
+ const RunMetaSchema = z.looseObject({
32
+ schema: z.literal(1),
33
+ id: z.string(),
34
+ repo: z.string(),
35
+ title: z.string(),
36
+ aLines: z.array(z.string()),
37
+ createdAt: z.string(),
38
+ catherdVersion: z.string(),
39
+ startedBy: StartedBySchema.nullable().optional(),
40
+ });
41
+ type RunMeta = z.infer<typeof RunMetaSchema>;
42
+
43
+ export interface Run {
44
+ id: string;
45
+ dir: string;
46
+ meta: RunMeta;
47
+ }
48
+
49
+ /** Spec §4.1. */
50
+ export function runPaths(dir: string) {
51
+ return {
52
+ meta: join(dir, "meta.json"),
53
+ plan: join(dir, "plan.md"),
54
+ lanes: join(dir, "lanes"),
55
+ ledger: join(dir, "ledger.md"),
56
+ state: join(dir, "state.md"),
57
+ stateJson: join(dir, "state.json"),
58
+ runs: join(dir, "runs.jsonl"),
59
+ routes: join(dir, "routes.jsonl"),
60
+ jev: join(dir, "jev.jsonl"),
61
+ outcomes: join(dir, "outcomes.jsonl"),
62
+ agents: join(dir, "agents.jsonl"),
63
+ harness: join(dir, "harness.jsonl"),
64
+ /** every session that has owned the run, in order (spec §3.3) */
65
+ sessions: join(dir, "sessions.jsonl"),
66
+ roles: join(dir, "roles"),
67
+ shots: join(dir, "shots"),
68
+ /** the admission lock's target: `admission.lock` guards dispatch admission */
69
+ admission: join(dir, "admission"),
70
+ };
71
+ }
72
+
73
+ export const LEDGER_HEADER = "milestone | what | commit | minutes | evidence";
74
+
75
+ const runSlug = (title: string) => slug(title).slice(0, 40) || "run";
76
+ const stamp = (d: Date) => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
77
+
78
+ /** Creates a run folder under the repo's runs dir. `repo` is a git toplevel; meta.json is written last. */
79
+ export function createRun(o: {
80
+ repo: string;
81
+ title: string;
82
+ aLines: string[];
83
+ version: string;
84
+ now?: Date;
85
+ startedBy?: StartedBy | null;
86
+ }): Run {
87
+ const now = o.now ?? new Date();
88
+ const root = runsDir(o.repo);
89
+ ensurePrivateDir(root);
90
+ const base = `${stamp(now)}-${runSlug(o.title)}`;
91
+ // findRun looks an id up across every repo, so an id is claimed across every repo too: the claim
92
+ // folder is atomic between processes, the scan covers runs made before claims existed
93
+ const claims = join(dataDir(), "run-ids");
94
+ ensurePrivateDir(claims);
95
+ const repos = join(dataDir(), "repos");
96
+ const taken = (id: string) =>
97
+ existsSync(repos) && readdirSync(repos).some((r) => existsSync(join(repos, r, "runs", id)));
98
+ let id = base;
99
+ for (let n = 2; ; n++) {
100
+ try {
101
+ if (!taken(id)) {
102
+ mkdirSync(join(claims, id), { mode: PRIVATE_DIR });
103
+ mkdirSync(join(root, id), { mode: PRIVATE_DIR });
104
+ break;
105
+ }
106
+ } catch (e) {
107
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
108
+ }
109
+ id = `${base}-${n}`;
110
+ }
111
+ const dir = join(root, id);
112
+ const p = runPaths(dir);
113
+ for (const d of [p.lanes, p.roles, p.shots]) ensurePrivateDir(d);
114
+ writeTextAtomic(p.ledger, `${LEDGER_HEADER}\n`);
115
+ ensureJsonlHeader(p.runs, "runs");
116
+ ensureJsonlHeader(p.routes, "routes");
117
+ ensureJsonlHeader(p.agents, "agents");
118
+ const meta: RunMeta = {
119
+ schema: 1,
120
+ id,
121
+ repo: o.repo,
122
+ title: o.title,
123
+ aLines: o.aLines,
124
+ createdAt: now.toISOString(),
125
+ catherdVersion: o.version,
126
+ ...(o.startedBy ? { startedBy: o.startedBy } : {}),
127
+ };
128
+ writeJsonAtomic(p.meta, meta);
129
+ return { id, dir, meta };
130
+ }
131
+
132
+ export interface RunListing {
133
+ runs: Run[];
134
+ /** run folders whose meta.json cannot be read: listed as warnings, never fatal (audit C10) */
135
+ corrupt: { id: string; dir: string; reason: string }[];
136
+ }
137
+
138
+ /** Every run of every repo, newest first. */
139
+ export function listRuns(): RunListing {
140
+ const root = join(dataDir(), "repos");
141
+ const out: RunListing = { runs: [], corrupt: [] };
142
+ if (!existsSync(root)) return out;
143
+ for (const repo of readdirSync(root, { withFileTypes: true })) {
144
+ const runs = join(root, repo.name, "runs");
145
+ if (!repo.isDirectory() || !existsSync(runs)) continue;
146
+ for (const e of readdirSync(runs, { withFileTypes: true })) {
147
+ if (!e.isDirectory()) continue;
148
+ const dir = join(runs, e.name);
149
+ try {
150
+ out.runs.push({ id: e.name, dir, meta: readVersioned(runPaths(dir).meta, RunMetaSchema, 1) });
151
+ } catch (err) {
152
+ out.corrupt.push({ id: e.name, dir, reason: (err as Error).message.split("\n")[0] ?? "unreadable" });
153
+ }
154
+ }
155
+ }
156
+ out.runs.sort((a, b) => b.meta.createdAt.localeCompare(a.meta.createdAt));
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * Every run's records on a backend's thread, oldest first: a role may resume a thread an earlier catherd
162
+ * run started. `run` counts even when a listing would miss it.
163
+ */
164
+ export function recordsOnThread(run: Run, backend: string, thread: string): RunRecord[] {
165
+ const runs = listRuns().runs;
166
+ return (runs.some((r) => r.dir === run.dir) ? runs : [run, ...runs])
167
+ .flatMap((r) => readRecords(r).records)
168
+ .filter((r) => r.backend === backend && r.thread === thread)
169
+ .sort((a, b) => a.endedAt.localeCompare(b.endedAt));
170
+ }
171
+
172
+ /** The fix of an unknown run id, in the MCP tools' words; the CLI prints its own (entry/cli-kit.ts). */
173
+ export const RUN_NOT_FOUND_FIX = "status() lists the runs";
174
+
175
+ export function findRun(id: string): Run {
176
+ assertId("run", id);
177
+ const { runs, corrupt } = listRuns();
178
+ const run = runs.find((r) => r.id === id);
179
+ if (run) return run;
180
+ const bad = corrupt.find((r) => r.id === id);
181
+ if (bad)
182
+ throw new CatherdError("E_RUN_CORRUPT", `run ${id} cannot be read: ${bad.reason}`, {
183
+ fix: `fix or delete ${bad.dir}`,
184
+ });
185
+ throw new CatherdError("E_RUN_NOT_FOUND", `no run "${id}"`, { fix: RUN_NOT_FOUND_FIX });
186
+ }
187
+
188
+ /** The run's records, one per dispatch; rows that are torn, invalid or repeated are skipped and counted. */
189
+ export function readRecords(run: Run): { records: RunRecord[]; corrupt: number } {
190
+ const { rows, corrupt } = readJsonl<unknown>(runPaths(run.dir).runs);
191
+ const seen = new Set<string>();
192
+ const records: RunRecord[] = [];
193
+ let bad = corrupt;
194
+ for (const row of rows) {
195
+ const r = RunRecordSchema.safeParse(row);
196
+ if (!r.success) bad++;
197
+ else if (!seen.has(r.data.dispatchId)) {
198
+ seen.add(r.data.dispatchId);
199
+ records.push(r.data);
200
+ }
201
+ }
202
+ return { records, corrupt: bad };
203
+ }
204
+
205
+ /** Appends `r` unless its dispatch already has a record, and returns the record that stands (audit C2). */
206
+ export function appendRecord(run: Run, r: RunRecord): Promise<RunRecord> {
207
+ const file = runPaths(run.dir).runs;
208
+ return withFileLock(file, () => {
209
+ const existing = readRecords(run).records.find((x) => x.dispatchId === r.dispatchId);
210
+ if (existing) return existing;
211
+ ensureJsonlHeader(file, "runs");
212
+ appendJsonl(file, RunRecordSchema.parse(r));
213
+ return r;
214
+ });
215
+ }
216
+
217
+ export function readRoutes(run: Run): RouteRow[] {
218
+ return readJsonl<RouteRow>(runPaths(run.dir).routes).rows.filter(
219
+ (r) => typeof r?.lane === "string" && Array.isArray(r.ladder) && typeof r.rung === "string",
220
+ );
221
+ }
222
+
223
+ export function appendRoute(run: Run, row: RouteRow): void {
224
+ const file = runPaths(run.dir).routes;
225
+ ensureJsonlHeader(file, "routes");
226
+ appendJsonl(file, row);
227
+ }
228
+
229
+ /** Appends; a lane may be written more than once and its last row wins (see `latestOutcomes`). */
230
+ export function appendOutcome(run: Run, row: OutcomeRow): void {
231
+ const file = runPaths(run.dir).outcomes;
232
+ ensureJsonlHeader(file, "outcomes");
233
+ appendJsonl(file, row);
234
+ }
235
+
236
+ export function readOutcomes(run: Run): OutcomeRow[] {
237
+ return readJsonl<OutcomeRow>(runPaths(run.dir).outcomes).rows.filter((r) => typeof r?.lane === "string");
238
+ }
239
+
240
+ const AgentRunSchema = z.looseObject({
241
+ at: z.string(),
242
+ name: z.string(),
243
+ role: z.string(),
244
+ rung: z.string(),
245
+ agent: z.string().nullable(),
246
+ totalTokens: z.number().nonnegative(),
247
+ costUsd: z.number().nullable(),
248
+ secs: z.number().nullable(),
249
+ status: z.enum(["ok", "failed", "cancelled"]),
250
+ /** the lane the subagent worked, so catalog timings count it under that lane's kind; rows before it lack it */
251
+ lane: z.string().nullable().optional(),
252
+ });
253
+ export type AgentRun = z.infer<typeof AgentRunSchema>;
254
+
255
+ /** Native Claude subagent runs, as the orchestrator reported them through record_agent_run. */
256
+ export function readAgentRuns(run: Run): AgentRun[] {
257
+ return readJsonl<unknown>(runPaths(run.dir).agents).rows.flatMap((row) => {
258
+ const r = AgentRunSchema.safeParse(row);
259
+ return r.success ? [r.data] : [];
260
+ });
261
+ }
262
+
263
+ export function appendAgentRun(run: Run, a: AgentRun): void {
264
+ const file = runPaths(run.dir).agents;
265
+ ensureJsonlHeader(file, "agents");
266
+ appendJsonl(file, AgentRunSchema.parse(a));
267
+ }
268
+
269
+ export function appendLedger(run: Run, row: string): void {
270
+ appendPrivate(runPaths(run.dir).ledger, `${row}\n`);
271
+ }
272
+
273
+ /** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
274
+ export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
275
+
276
+ const SERVER_OWNED = new Set([
277
+ "meta.json",
278
+ "state.md",
279
+ "state.json",
280
+ "ledger.md",
281
+ "runs.jsonl",
282
+ "routes.jsonl",
283
+ "jev.jsonl",
284
+ "agents.jsonl",
285
+ "harness.jsonl",
286
+ "verifier.jsonl",
287
+ "questions.jsonl",
288
+ "outcomes.jsonl",
289
+ "sessions.jsonl",
290
+ ]);
291
+
292
+ const present = (p: string) => {
293
+ try {
294
+ lstatSync(p);
295
+ return true;
296
+ } catch {
297
+ return false;
298
+ }
299
+ };
300
+
301
+ /**
302
+ * The absolute path of `path` inside the run folder. Refuses a path, or a symlink on its way, that
303
+ * leaves the folder; for writes, also catherd's own files, compared case-folded (audit S7).
304
+ */
305
+ export function runFile(run: Run, path: string, mode: "read" | "write"): string {
306
+ const full = resolve(run.dir, path);
307
+ const rel = relative(run.dir, full);
308
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(run.dir, rel) !== full)
309
+ throw new CatherdError("E_IO_PATH", `"${path}" is outside the run folder`, {
310
+ fix: "pass a path relative to the run folder, like lanes/M1.L1.md",
311
+ });
312
+ const folded = rel.toLowerCase();
313
+ if (
314
+ mode === "write" &&
315
+ (SERVER_OWNED.has(folded) ||
316
+ folded === "roles" ||
317
+ folded.startsWith(`roles${sep}`) ||
318
+ folded === "digests" ||
319
+ folded.startsWith(`digests${sep}`) ||
320
+ folded.endsWith(".lock"))
321
+ )
322
+ throw new CatherdError("E_IO_PATH", `${rel} is written by catherd itself`, {
323
+ fix: "write plan.md, lanes/<id>.md, a dossier or notes instead",
324
+ });
325
+ let probe = full;
326
+ while (!present(probe)) probe = dirname(probe);
327
+ const root = realpathSync(run.dir);
328
+ const real = realpathSync(probe);
329
+ if (real !== root && !real.startsWith(`${root}${sep}`))
330
+ throw new CatherdError("E_IO_PATH", `"${path}" resolves outside the run folder`, {
331
+ fix: "remove the symlink that leaves the run folder",
332
+ });
333
+ return full;
334
+ }
@@ -0,0 +1,254 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { budgetStatus } from "../domain/budget.ts";
4
+ import { ID_PATTERN } from "../domain/ids.ts";
5
+ import type { RunRecord } from "../domain/record.ts";
6
+ import { dispatchPaths } from "../infra/dispatch-dir.ts";
7
+ import { nonBlankLines } from "../infra/store.ts";
8
+ import { spendOf } from "./budget.ts";
9
+ import { dispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
10
+ import { lastActivity } from "./finalize.ts";
11
+ import type { Deps } from "./ports.ts";
12
+ import { findRun, listRuns, readRecords, type Run, runPaths } from "./run-store.ts";
13
+ import { groupRuns, type SessionGroup } from "./session-view.ts";
14
+
15
+ /**
16
+ * Spec §4: what the Runs tab shows. The top level is the sessions; a session's screen holds its runs, each with its
17
+ * milestones and every role (live ones first); a role's screen holds its brief, reply and record.
18
+ */
19
+
20
+ export interface SessionRow {
21
+ /** the session id; null for "earlier runs" */
22
+ key: string | null;
23
+ name: string;
24
+ live: boolean;
25
+ runs: number;
26
+ liveRoles: number;
27
+ landed: number;
28
+ /** ISO time of its newest activity */
29
+ lastActivity: string;
30
+ }
31
+
32
+ export interface RoleRow {
33
+ run: string;
34
+ dispatchId: string;
35
+ name: string;
36
+ role: string;
37
+ rung: string;
38
+ /** running | starting while live; once finished, the record's status (`finished` until it has one) */
39
+ status: string;
40
+ live: boolean;
41
+ /** a live role's admission time: the page ticks its elapsed time from it */
42
+ since: string;
43
+ /** a finished role's seconds, from its record */
44
+ secs: number | null;
45
+ /** a live role's last command, file edit or message line */
46
+ lastEvent: string | null;
47
+ replyStatus: string | null;
48
+ }
49
+
50
+ export interface Milestone {
51
+ name: string;
52
+ landed: boolean;
53
+ /** the ledger's "what" once landed */
54
+ what: string;
55
+ }
56
+
57
+ export interface SessionRun {
58
+ id: string;
59
+ title: string;
60
+ repo: string;
61
+ createdAt: string;
62
+ continued: "here" | "elsewhere" | null;
63
+ continuedIn: string | null;
64
+ /** the budget's spent fraction, null without a cap */
65
+ budget: number | null;
66
+ milestones: Milestone[];
67
+ roles: RoleRow[];
68
+ }
69
+
70
+ export interface SessionDetail {
71
+ session: SessionRow;
72
+ runs: SessionRun[];
73
+ /** the run folders to watch for changes */
74
+ dirs: string[];
75
+ }
76
+
77
+ export interface RoleDetail {
78
+ run: string;
79
+ runTitle: string;
80
+ dispatchId: string;
81
+ name: string;
82
+ role: string;
83
+ rung: string;
84
+ /** running | starting | finished */
85
+ state: string;
86
+ brief: string;
87
+ reply: string;
88
+ record: RunRecord | null;
89
+ }
90
+
91
+ export interface MilestoneDetail {
92
+ run: string;
93
+ runTitle: string;
94
+ name: string;
95
+ landed: boolean;
96
+ what: string;
97
+ /** R/digests/<name>.md; null when land has not written one (not landed, or landed by 1.0) or it is empty */
98
+ digest: string | null;
99
+ }
100
+
101
+ const keyOf = (g: SessionGroup): string | null => g.session?.sessionId ?? null;
102
+
103
+ /** The runs that live on in a session now (it owns them): each run's live roles and landings count once. */
104
+ const ownRuns = (g: SessionGroup): Run[] => g.runs.filter((x) => x.current).map((x) => x.run);
105
+
106
+ function rowOf(deps: Deps, g: SessionGroup): SessionRow {
107
+ const own = ownRuns(g);
108
+ return {
109
+ key: keyOf(g),
110
+ name: g.session?.name ?? "earlier runs",
111
+ live: g.session?.live ?? false,
112
+ runs: g.runs.length,
113
+ liveRoles: own.reduce((n, r) => n + liveDispatches(r, deps.now()).length, 0),
114
+ landed: own.reduce((n, r) => n + Math.max(0, nonBlankLines(runPaths(r.dir).ledger).length - 1), 0),
115
+ lastActivity: new Date(g.lastActivity).toISOString(),
116
+ };
117
+ }
118
+
119
+ /** The Runs tab's top level: every session, newest activity first, "earlier runs" last. */
120
+ export function sessionRows(deps: Deps): { rows: SessionRow[]; warnings: string[] } {
121
+ const { runs, corrupt } = listRuns();
122
+ return {
123
+ rows: groupRuns(runs).map((g) => rowOf(deps, g)),
124
+ warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`),
125
+ };
126
+ }
127
+
128
+ /** The ledger's landed milestones, then each milestone a lane file names that has not landed. */
129
+ function milestonesOf(run: Run): Milestone[] {
130
+ const landed = nonBlankLines(runPaths(run.dir).ledger)
131
+ .slice(1)
132
+ .map((l) => l.split(" | "))
133
+ .map(([name, what]) => ({ name: (name ?? "").trim(), landed: true, what: (what ?? "").trim() }));
134
+ const lanes = existsSync(runPaths(run.dir).lanes) ? readdirSync(runPaths(run.dir).lanes) : [];
135
+ const open = [...new Set(lanes.map((f) => /^(M\d+)\./.exec(f)?.[1]).filter((m): m is string => !!m))]
136
+ .filter((m) => !landed.some((l) => l.name === m))
137
+ .map((name) => ({ name, landed: false, what: "" }));
138
+ const n = (m: string) => Number(/\d+/.exec(m)?.[0] ?? 0);
139
+ return [...landed, ...open.sort((a, b) => n(a.name) - n(b.name))];
140
+ }
141
+
142
+ /** Each role's latest dispatch, live ones first, then the newest finished first. */
143
+ function rolesOf(deps: Deps, run: Run): RoleRow[] {
144
+ const now = deps.now();
145
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
146
+ const latest = new Map<string, ReturnType<typeof listDispatches>[number]>();
147
+ for (const d of listDispatches(run)) latest.set(d.admit.name, d);
148
+ const rows = [...latest.values()].map((d): RoleRow => {
149
+ const r = records.get(d.admit.dispatchId);
150
+ const state = r ? "finished" : dispatchState(d, now);
151
+ const live = state !== "finished";
152
+ return {
153
+ run: run.id,
154
+ dispatchId: d.admit.dispatchId,
155
+ name: d.admit.name,
156
+ role: d.admit.role,
157
+ rung: d.admit.rung,
158
+ status: r?.status ?? state,
159
+ live,
160
+ since: d.admit.admittedAt,
161
+ secs: r?.secs ?? null,
162
+ lastEvent: live ? lastActivity(d) : null,
163
+ replyStatus: r?.replyStatus ?? null,
164
+ };
165
+ });
166
+ return rows.sort((a, b) =>
167
+ a.live !== b.live ? (a.live ? -1 : 1) : b.dispatchId.localeCompare(a.dispatchId),
168
+ );
169
+ }
170
+
171
+ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
172
+ const run = x.run;
173
+ let budget: number | null = null;
174
+ try {
175
+ const b = budgetStatus(
176
+ spendOf(run, readRecords(run).records, liveDispatches(run, deps.now()), deps.now()),
177
+ deps.profiles.forRepo(run.meta.repo).budget,
178
+ );
179
+ budget = b?.fraction ?? null;
180
+ } catch {
181
+ // an unreadable profile: no bar, the rest of the page stands
182
+ }
183
+ return {
184
+ id: run.id,
185
+ title: run.meta.title,
186
+ repo: run.meta.repo,
187
+ createdAt: run.meta.createdAt,
188
+ continued: x.continued,
189
+ continuedIn: x.continuedIn,
190
+ budget,
191
+ milestones: milestonesOf(run),
192
+ roles: rolesOf(deps, run),
193
+ };
194
+ }
195
+
196
+ /** A session's screen: its runs, newest first, each with its milestones and roles. */
197
+ export function sessionDetail(deps: Deps, key: string | null): SessionDetail {
198
+ const g = groupRuns(listRuns().runs).find((x) => keyOf(x) === key);
199
+ if (!g) throw new Error(key === null ? "no earlier runs" : `no session "${key}" in the runs`);
200
+ return {
201
+ session: rowOf(deps, g),
202
+ runs: g.runs.map((x) => sessionRun(deps, x)),
203
+ dirs: g.runs.map((x) => x.run.dir),
204
+ };
205
+ }
206
+
207
+ const text = (file: string): string => {
208
+ try {
209
+ return readFileSync(file, "utf8");
210
+ } catch {
211
+ return "";
212
+ }
213
+ };
214
+
215
+ /** A role's screen: its brief, its reply and its record. */
216
+ export function roleDetail(deps: Deps, runId: string, dispatchId: string): RoleDetail {
217
+ const run = findRun(runId);
218
+ const d = listDispatches(run).find((x) => x.admit.dispatchId === dispatchId);
219
+ if (!d) throw new Error(`no dispatch ${dispatchId} in run ${runId}`);
220
+ const record = readRecords(run).records.find((r) => r.dispatchId === dispatchId) ?? null;
221
+ const p = dispatchPaths(d.dir);
222
+ return {
223
+ run: run.id,
224
+ runTitle: run.meta.title,
225
+ dispatchId,
226
+ name: d.admit.name,
227
+ role: d.admit.role,
228
+ rung: d.admit.rung,
229
+ state: record ? "finished" : dispatchState(d, deps.now()),
230
+ brief: text(p.brief),
231
+ reply: text(p.reply),
232
+ record,
233
+ };
234
+ }
235
+
236
+ /** Spec 1.1 §10: a milestone's screen, its digest. */
237
+ export function milestoneDetail(runId: string, name: string): MilestoneDetail {
238
+ const run = findRun(runId);
239
+ // the id rule land writes digests under (assertId): never a path out of the run folder
240
+ const valid = ID_PATTERN.test(name) && !name.includes("..");
241
+ const m = valid ? milestonesOf(run).find((x) => x.name === name) : undefined;
242
+ if (!m) throw new Error(`no milestone "${name}" in run ${runId}`);
243
+ const file = join(run.dir, "digests", `${name}.md`);
244
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
245
+ return {
246
+ run: run.id,
247
+ runTitle: run.meta.title,
248
+ name,
249
+ landed: m.landed,
250
+ what: m.what,
251
+ // an empty file is no digest either: land writes the whole digest at once
252
+ digest: text.trim() ? text : null,
253
+ };
254
+ }