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,377 @@
1
+ import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { relative } from "node:path";
3
+ import type { BackendAdapter, FinishedRun, Outcome, Spent } from "../adapters/backend.ts";
4
+ import { adapterFor } from "../adapters/registry.ts";
5
+ import "../adapters/all.ts";
6
+ import { changedPaths, type Snapshot, splitChanges } from "../domain/changes.ts";
7
+ import { isCatherdError } from "../domain/errors.ts";
8
+ import { parseRung } from "../domain/ids.ts";
9
+ import {
10
+ type ExitInfo,
11
+ parseReplyStatus,
12
+ type RunRecord,
13
+ THREAD_HEAVY_INPUT,
14
+ ZERO_TOKENS,
15
+ } from "../domain/record.ts";
16
+ import { dispatchPaths, readClaimant, readExit, tryClaim } from "../infra/dispatch-dir.ts";
17
+ import { isAlive } from "../infra/proc.ts";
18
+ import { withFileLock } from "../infra/filelock.ts";
19
+ import { gitLimits, statusSnapshot } from "../infra/git.ts";
20
+ import {
21
+ appendJsonl,
22
+ ensureJsonlHeader,
23
+ makePrivate,
24
+ nonBlankLines,
25
+ writeTextAtomic,
26
+ } from "../infra/store.ts";
27
+ import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
28
+ import { tail } from "./run-debug.ts";
29
+ import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
30
+
31
+ const text = (file: string): string => {
32
+ try {
33
+ return readFileSync(file, "utf8");
34
+ } catch {
35
+ return "";
36
+ }
37
+ };
38
+
39
+ function claimAgeMs(dir: string): number {
40
+ try {
41
+ return Date.now() - statSync(dispatchPaths(dir).claim).mtimeMs;
42
+ } catch {
43
+ return 0;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Whether another finalizer's claim may be taken over: its claimant is dead, or the claim is older than
49
+ * the longest a live claimant can take: every bounded step of `compute` (the adapter's settle, the one
50
+ * `git status` snapshot, each at its own timeout) plus the margin for its unbounded file work. A claim
51
+ * that names no one (an older build's) is judged by its age alone.
52
+ */
53
+ function claimStale(dir: string): boolean {
54
+ const who = readClaimant(dir);
55
+ if (who && !isAlive(who.pid, who.startTime)) return true;
56
+ return claimAgeMs(dir) > settleLimits.timeoutMs + gitLimits.timeoutMs + settleLimits.claimMarginMs;
57
+ }
58
+
59
+ /**
60
+ * Whether another live process holds this dispatch's finalizer claim (not yet stale): that process writes its
61
+ * record and settles it, so a recovery pass (`claim`) leaves it alone rather than wait on it.
62
+ */
63
+ export function finalizingElsewhere(dir: string): boolean {
64
+ const who = readClaimant(dir);
65
+ return who !== null && who.pid !== process.pid && !claimStale(dir);
66
+ }
67
+
68
+ /** The owned paths of the run's other dispatches whose lifetime overlapped [start, end]. */
69
+ function othersOwns(run: Run, self: Dispatch, start: number, end: number): string[] {
70
+ const ended = new Map(readRecords(run).records.map((r) => [r.dispatchId, Date.parse(r.endedAt)]));
71
+ return listDispatches(run)
72
+ .filter((d) => d.admit.dispatchId !== self.admit.dispatchId)
73
+ .filter((d) => {
74
+ const from = Date.parse(d.admit.admittedAt);
75
+ const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
76
+ return from <= end && to >= start;
77
+ })
78
+ .flatMap((d) => d.admit.owns);
79
+ }
80
+
81
+ /** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
82
+ async function snapshotOrNull(repo: string): Promise<Snapshot | null> {
83
+ try {
84
+ return await statusSnapshot(repo);
85
+ } catch (e) {
86
+ if (isCatherdError(e) && e.code === "E_IO_UNEXPECTED") return null;
87
+ throw e;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * How long a backend may take to settle a finished session before its stream's own figures stand, and
93
+ * how much longer than that and the git snapshot's timeout (gitLimits) a finalizer's claim may live (its file
94
+ * work and the append) before a second finalizer takes it over.
95
+ */
96
+ export const settleLimits = { timeoutMs: 20_000, claimMarginMs: 10_000 };
97
+
98
+ /**
99
+ * What earlier records on `thread` already counted: a resumed session's totals repeat them. Every run's,
100
+ * since a role may resume a thread an earlier catherd run started.
101
+ */
102
+ function priorOn(run: Run, backend: string, thread: string, self: string): Spent {
103
+ const earlier = recordsOnThread(run, backend, thread).filter((r) => r.dispatchId !== self);
104
+ return {
105
+ tokens: {
106
+ input: earlier.reduce((n, r) => n + r.tokens.input, 0),
107
+ cached: earlier.reduce((n, r) => n + r.tokens.cached, 0),
108
+ output: earlier.reduce((n, r) => n + r.tokens.output, 0),
109
+ },
110
+ costUsd: earlier.reduce((n, r) => n + (r.costUsd ?? 0), 0),
111
+ };
112
+ }
113
+
114
+ /** The adapter's settled outcome, or `o` unchanged when it has none, throws, or is slower than the limit. */
115
+ export async function settled(
116
+ adapter: BackendAdapter,
117
+ o: Outcome,
118
+ finished: FinishedRun,
119
+ prior: () => Spent,
120
+ ): Promise<Outcome> {
121
+ if (!adapter.settle || o.thread === null) return o;
122
+ let timer: ReturnType<typeof setTimeout> | undefined;
123
+ try {
124
+ const late = new Promise<Outcome>((resolve) => {
125
+ timer = setTimeout(() => resolve(o), settleLimits.timeoutMs);
126
+ });
127
+ return await Promise.race([adapter.settle(o, finished, prior()), late]);
128
+ } catch {
129
+ return o;
130
+ } finally {
131
+ clearTimeout(timer);
132
+ }
133
+ }
134
+
135
+ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
136
+ const a = d.admit;
137
+ const p = dispatchPaths(d.dir);
138
+ // no exit.json: its supervisor died. A cancel asked for before that ended it; anything else is lost.
139
+ const exit: ExitInfo = readExit(d.dir) ?? {
140
+ code: null,
141
+ signal: null,
142
+ reason: existsSync(p.cancel) ? "cancelled" : "lost",
143
+ endedAt: new Date().toISOString(),
144
+ };
145
+ const startedAt = readProc(d.dir)?.startedAt ?? a.admittedAt;
146
+ const reply = text(p.reply);
147
+ const finished: FinishedRun = {
148
+ request: {
149
+ rung: parseRung(a.rung),
150
+ access: a.access,
151
+ thread: a.thread,
152
+ isolated: a.isolated,
153
+ repo: a.repo,
154
+ briefPath: p.brief,
155
+ replyPath: p.reply,
156
+ dispatchDir: d.dir,
157
+ },
158
+ eventLines: nonBlankLines(p.events),
159
+ reply,
160
+ stderr: text(p.stderr),
161
+ exit,
162
+ startedAtMs: Date.parse(startedAt),
163
+ };
164
+ const adapter = adapterFor(a.backend);
165
+ const first: Outcome = adapter
166
+ ? adapter.finalize(finished)
167
+ : {
168
+ status: "failed",
169
+ thread: a.thread,
170
+ tokens: { ...ZERO_TOKENS },
171
+ costUsd: null,
172
+ images: [],
173
+ error: { code: "E_BACKEND_MISSING", message: `catherd has no ${a.backend} adapter` },
174
+ };
175
+ const o = adapter
176
+ ? await settled(adapter, first, finished, () => priorOn(run, a.backend, first.thread ?? "", a.dispatchId))
177
+ : first;
178
+ // A CLI that streams its reply (claude, opencode) leaves reply.md to catherd.
179
+ if (o.reply !== undefined && o.reply !== reply) writeTextAtomic(p.reply, o.reply);
180
+ // one the CLI wrote itself (codex) has the CLI's mode
181
+ else makePrivate(p.reply);
182
+ const replyText = o.reply ?? reply;
183
+ const start = Date.parse(startedAt);
184
+ const end = Date.parse(exit.endedAt);
185
+ const after = await snapshotOrNull(a.repo);
186
+ const { changedOwned, violations } = after
187
+ ? splitChanges(
188
+ changedPaths(a.before, after),
189
+ a.owns,
190
+ // from admission, when the before-snapshot was taken, not from the worker's start
191
+ othersOwns(run, d, Date.parse(a.admittedAt), end),
192
+ a.lane !== null || a.access === "read-only",
193
+ )
194
+ : { changedOwned: [], violations: [] };
195
+ const reported = parseReplyStatus(replyText);
196
+ return {
197
+ schema: 1,
198
+ runId: run.id,
199
+ dispatchId: a.dispatchId,
200
+ name: a.name,
201
+ role: a.role,
202
+ lane: a.lane,
203
+ backend: a.backend,
204
+ rung: a.rung,
205
+ attempt: a.attempt,
206
+ failoverFrom: a.failoverFrom,
207
+ thread: o.thread,
208
+ status: o.status,
209
+ startedAt,
210
+ endedAt: exit.endedAt,
211
+ secs: Math.max(0, Math.round((end - start) / 1000)),
212
+ exitCode: exit.code,
213
+ signal: exit.signal,
214
+ cliVersion: a.cliVersion,
215
+ tokens: o.tokens,
216
+ costUsd: o.costUsd,
217
+ changedOwned,
218
+ violations,
219
+ ...(after ? {} : { gitUnavailable: true }),
220
+ replyStatus: reported.status,
221
+ replyWhy: reported.why,
222
+ threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
223
+ access: a.access,
224
+ isolated: a.isolated,
225
+ images: o.images,
226
+ error: o.error,
227
+ replyPath: relative(run.dir, p.reply),
228
+ ...(a.sessionId ? { sessionId: a.sessionId } : {}),
229
+ };
230
+ }
231
+
232
+ /**
233
+ * A fresh thread's first-turn input, for the harness-cost line of runs_summary: its first model request's
234
+ * input, from a backend whose stream reports usage per request. Codex reports it per exec: no row.
235
+ */
236
+ function recordHarness(run: Run, d: Dispatch, r: RunRecord): void {
237
+ const a = adapterFor(d.admit.backend);
238
+ if (d.admit.thread !== null || !a) return;
239
+ for (const line of nonBlankLines(dispatchPaths(d.dir).events)) {
240
+ let input;
241
+ try {
242
+ input = a.parse(line).requestInput;
243
+ } catch {
244
+ continue;
245
+ }
246
+ if (input === undefined) continue;
247
+ const file = runPaths(run.dir).harness;
248
+ ensureJsonlHeader(file, "harness");
249
+ appendJsonl(file, {
250
+ at: r.endedAt,
251
+ name: r.name,
252
+ backend: r.backend,
253
+ isolated: r.isolated,
254
+ firstTurnInput: input,
255
+ });
256
+ return;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Spec §3.3: the first finalizer claims the dispatch and writes its record; any other waits for that
262
+ * record and returns it. If the claimer died before appending, the late finalizer appends instead;
263
+ * appendRecord's per-dispatch dedupe keeps that to one record either way (audit C2). A claimer that throws
264
+ * releases its claim.
265
+ *
266
+ * In this process, a second finalizer (`cancel` beside the dispatch's watcher) joins the first one's
267
+ * promise. Across processes it waits for the claimant's record, and takes over only once the claim is
268
+ * stale (claimant dead, or past its settle window): never while a live claimant may still be settling.
269
+ */
270
+ export function finalizeDispatch(run: Run, d: Dispatch): Promise<RunRecord> {
271
+ const id = d.admit.dispatchId;
272
+ const joined = inFlight.get(id);
273
+ if (joined) return joined;
274
+ const p = finalizeOnce(run, d).finally(() => inFlight.delete(id));
275
+ inFlight.set(id, p);
276
+ return p;
277
+ }
278
+
279
+ /** This process's finalizes in flight, by dispatch id. */
280
+ const inFlight = new Map<string, Promise<RunRecord>>();
281
+
282
+ const recordOf = (run: Run, id: string): RunRecord | undefined =>
283
+ readRecords(run).records.find((r) => r.dispatchId === id);
284
+
285
+ /** Test seam: runs after a finalizer judged a claim stale, before it takes the lock to take it over. */
286
+ export const claimSeams = { beforeTakeover: async (_dir: string): Promise<void> => {} };
287
+
288
+ /**
289
+ * Takes over a stale claim, serialized by a lock on the claim: under it, reads the claim again, removes it
290
+ * only if it is still stale, and claims it. True for the one caller that now holds the claim; a caller that
291
+ * finds it live (another took it over, or its claimant woke) or loses the claim to a fresh claimant gets
292
+ * false and goes back to waiting for the record. A claim is removed only here and by its own claimant, and
293
+ * here only right before claiming it, so there is never a second live computer, and never none for long:
294
+ * a claim that vanished is claimed by the next finalizer's poll.
295
+ */
296
+ export async function takeOverStaleClaim(dir: string): Promise<boolean> {
297
+ const claim = dispatchPaths(dir).claim;
298
+ return withFileLock(claim, () => {
299
+ if (existsSync(claim)) {
300
+ if (!claimStale(dir)) return false;
301
+ rmSync(claim, { force: true });
302
+ }
303
+ return tryClaim(dir);
304
+ });
305
+ }
306
+
307
+ async function finalizeOnce(run: Run, d: Dispatch): Promise<RunRecord> {
308
+ for (;;) {
309
+ const existing = recordOf(run, d.admit.dispatchId);
310
+ if (existing) return existing;
311
+ // a claim released by a claimer that threw is gone: this claims it
312
+ if (tryClaim(d.dir)) break;
313
+ if (existsSync(dispatchPaths(d.dir).claim) && claimStale(d.dir)) {
314
+ await claimSeams.beforeTakeover(d.dir);
315
+ if (await takeOverStaleClaim(d.dir)) break;
316
+ continue;
317
+ }
318
+ await Bun.sleep(50);
319
+ }
320
+ let saved: RunRecord;
321
+ let mine: RunRecord;
322
+ try {
323
+ mine = await compute(run, d);
324
+ saved = await appendRecord(run, mine);
325
+ } catch (e) {
326
+ // no record: let the next finalizer try at once rather than wait on this claim; ours only, since a
327
+ // claim held past its window may have been taken over
328
+ if (readClaimant(d.dir)?.pid === process.pid) rmSync(dispatchPaths(d.dir).claim, { force: true });
329
+ throw e;
330
+ }
331
+ if (saved === mine) recordHarness(run, d, mine);
332
+ return saved;
333
+ }
334
+
335
+ /** How much of a role's last event `peek` and the runs page show (spec §3.7). */
336
+ export const LAST_EVENT_CHARS = 160;
337
+
338
+ const oneLine = (s: string): string => {
339
+ const line = s.split("\n").find((l) => l.trim()) ?? "";
340
+ return line.length > LAST_EVENT_CHARS ? `${line.slice(0, LAST_EVENT_CHARS - 1)}…` : line;
341
+ };
342
+
343
+ /**
344
+ * Spec §3.7: what a live role is doing, from the end of its events.jsonl: the last line its adapter reads as an
345
+ * activity (a command, a file edit, a message line), else the last event's name; null before any event.
346
+ */
347
+ export function lastActivity(d: Dispatch): string | null {
348
+ const a = adapterFor(d.admit.backend);
349
+ if (!a) return null;
350
+ let name: string | null = null;
351
+ for (const line of tail(dispatchPaths(d.dir).events, 200).toReversed()) {
352
+ let delta;
353
+ try {
354
+ delta = a.parse(line);
355
+ } catch {
356
+ continue;
357
+ }
358
+ if (delta.activity) return oneLine(delta.activity);
359
+ name ??= delta.lastEvent ?? null;
360
+ }
361
+ return name;
362
+ }
363
+
364
+ /** Waits until the dispatch finishes, polling every `pollMs`; `onPoll` runs at each poll, and never ends the wait. */
365
+ export async function waitForFinish(
366
+ d: Dispatch,
367
+ o: { pollMs: number; now: () => number; onPoll?: () => void },
368
+ ): Promise<void> {
369
+ while (dispatchState(d, o.now()) !== "finished") {
370
+ await Bun.sleep(o.pollMs);
371
+ try {
372
+ o.onPoll?.();
373
+ } catch {
374
+ // a report on the way must never stop the wait
375
+ }
376
+ }
377
+ }
@@ -0,0 +1,288 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readdirSync,
5
+ readlinkSync,
6
+ realpathSync,
7
+ type Stats,
8
+ statSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { z } from "zod";
12
+ import { CatherdError, isCatherdError } from "../domain/errors.ts";
13
+ import { normalizeOwned, overlaps } from "../domain/lane.ts";
14
+ import { git, gitHead, statusSnapshot } from "../infra/git.ts";
15
+ import { repoDir } from "../infra/paths.ts";
16
+ import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
17
+ import type { Deps } from "./ports.ts";
18
+ import { findRun, type Run } from "./run-store.ts";
19
+
20
+ // Spec 1.1 §7: the verifier's gate ledger. A gate item that passed on the same content, with the same
21
+ // command, is carried over instead of run again; every check is a verifier step `peek` and `status` show.
22
+
23
+ const GatePassSchema = z.looseObject({
24
+ at: z.string(),
25
+ run: z.string(),
26
+ item: z.string(),
27
+ command: z.string(),
28
+ paths: z.array(z.string()),
29
+ hash: z.string(),
30
+ commit: z.string(),
31
+ evidence: z.string(),
32
+ });
33
+ type GatePass = z.infer<typeof GatePassSchema>;
34
+
35
+ export interface VerifierStep {
36
+ at: string;
37
+ item: string;
38
+ carried: boolean;
39
+ /** a carried item: the commit its pass was recorded on */
40
+ commit?: string;
41
+ /** the milestone the verifier checks, when gate_check named it: the digest lists only that milestone's items */
42
+ milestone?: string;
43
+ }
44
+
45
+ /** `<data>/repos/<repo key>/gates.jsonl`, beside the repo's knowledge.md. */
46
+ export const gatesFile = (toplevel: string): string => join(repoDir(toplevel), "gates.jsonl");
47
+ const stepsFile = (run: Run): string => join(run.dir, "verifier.jsonl");
48
+
49
+ /** Repo-relative paths, `.` for the whole repo; anything that leaves the repo is refused. */
50
+ function cleanPaths(paths: string[]): string[] {
51
+ try {
52
+ return [...new Set(paths.map((p) => (p.trim() === "." ? "." : normalizeOwned(p))))].sort();
53
+ } catch (e) {
54
+ if (isCatherdError(e))
55
+ throw new CatherdError("E_INPUT_INVALID", e.message.replace("owned path", "gate path"), {
56
+ fix: "pass repo-relative paths, like src/ or package.json, or . for the whole repo",
57
+ });
58
+ throw e;
59
+ }
60
+ }
61
+
62
+ const sha = (b: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(b).digest("hex");
63
+
64
+ const tooMany = (p: string) =>
65
+ new CatherdError("E_INPUT_INVALID", `gate path ${p} holds more than ${GATE_WALK_MAX} files to hash`, {
66
+ fix: "name narrower paths: the files or directories under it that the gate item reads",
67
+ });
68
+
69
+ /** How many files one gate path's hash has read so far, against GATE_WALK_MAX, and the realpaths it walked. */
70
+ interface Budget {
71
+ path: string;
72
+ files: number;
73
+ seen: Set<string>;
74
+ }
75
+
76
+ const count = (b: Budget): void => {
77
+ if (++b.files > GATE_WALK_MAX) throw tooMany(b.path);
78
+ };
79
+
80
+ /**
81
+ * What a symlink points at, by content only (never its path, which may be outside the repo): a file by its
82
+ * type, exec bit and bytes; a directory walked, names relative to it; "dangling" when nothing is there; and
83
+ * "cycle" for a realpath this hash already walked.
84
+ */
85
+ async function targetEntry(link: string, b: Budget): Promise<string> {
86
+ let real: string;
87
+ try {
88
+ real = realpathSync(link);
89
+ } catch {
90
+ return "dangling";
91
+ }
92
+ if (b.seen.has(real)) return "cycle";
93
+ b.seen.add(real);
94
+ const s = statSync(real);
95
+ if (s.isFile()) return `${s.mode & 0o111 ? "exec" : "file"} ${sha(await Bun.file(real).bytes())}`;
96
+ if (!s.isDirectory()) return "other";
97
+ const h = new Bun.CryptoHasher("sha256");
98
+ const visit = async (rel: string): Promise<void> => {
99
+ for (const name of readdirSync(join(real, rel)).sort()) {
100
+ const r = rel ? `${rel}/${name}` : name;
101
+ let e: Stats;
102
+ try {
103
+ e = lstatSync(join(real, r));
104
+ } catch {
105
+ continue;
106
+ }
107
+ if (e.isDirectory()) {
108
+ await visit(r);
109
+ continue;
110
+ }
111
+ count(b);
112
+ h.update(`${r}=${await diskEntry(join(real, r), b)}\n`);
113
+ }
114
+ };
115
+ await visit("");
116
+ return `dir ${h.digest("hex")}`;
117
+ }
118
+
119
+ /**
120
+ * One path on disk as git would store it: its type and exec bit as well as its bytes, so a lost exec bit or a
121
+ * file turned symlink is new content; a symlink by its link text and by what it points at (targetEntry), so
122
+ * editing a link's target is new content too.
123
+ */
124
+ async function diskEntry(file: string, b: Budget): Promise<string> {
125
+ let s: Stats;
126
+ try {
127
+ s = lstatSync(file);
128
+ } catch {
129
+ return "gone";
130
+ }
131
+ if (s.isSymbolicLink()) return `link ${sha(readlinkSync(file))} -> ${await targetEntry(file, b)}`;
132
+ if (!s.isFile()) return s.isDirectory() ? "dir" : "other";
133
+ return `${s.mode & 0o111 ? "exec" : "file"} ${sha(await Bun.file(file).bytes())}`;
134
+ }
135
+
136
+ /** One dirty path's entry, with its own budget and cycle guard. */
137
+ const dirtyEntry = (file: string, path: string): Promise<string> =>
138
+ diskEntry(file, { path, files: 0, seen: new Set() });
139
+
140
+ /** Whether `file` is a symlink on disk. */
141
+ const isLink = (file: string): boolean => {
142
+ try {
143
+ return lstatSync(file).isSymbolicLink();
144
+ } catch {
145
+ return false;
146
+ }
147
+ };
148
+
149
+ /** Whether git ignores `p` (a tracked file never is). */
150
+ async function ignored(repo: string, p: string): Promise<boolean> {
151
+ return (await git(repo, ["check-ignore", "-q", "--", p.replace(/\/$/, "")])).kind === "ok";
152
+ }
153
+
154
+ /** The most files a gate path's on-disk walk hashes. */
155
+ export const GATE_WALK_MAX = 10_000;
156
+
157
+ /**
158
+ * The files under `p` on disk (itself when it is not a directory), repo-relative and sorted; a symlink is
159
+ * an entry, not followed. More than GATE_WALK_MAX is refused, not hashed in part.
160
+ */
161
+ function walk(repo: string, p: string): string[] {
162
+ const out: string[] = [];
163
+ const visit = (rel: string): void => {
164
+ let s: Stats;
165
+ try {
166
+ s = lstatSync(join(repo, rel));
167
+ } catch {
168
+ return;
169
+ }
170
+ if (!s.isDirectory()) {
171
+ out.push(rel);
172
+ if (out.length > GATE_WALK_MAX) throw tooMany(p);
173
+ return;
174
+ }
175
+ for (const name of readdirSync(join(repo, rel))) visit(`${rel}/${name}`);
176
+ };
177
+ visit(p.replace(/\/$/, ""));
178
+ return out.sort();
179
+ }
180
+
181
+ /**
182
+ * The content hash of `paths`: each one's tree entry at HEAD (mode, type and object id), plus the content of every
183
+ * uncommitted change under them, so a verifier checking a tree not yet committed gets a hash of what it ran.
184
+ * A path git ignores, or one not at HEAD, is also hashed file by file from disk; ignored files under `.` or
185
+ * under a tracked directory are not, so an ignored input is covered only when it is named.
186
+ */
187
+ async function contentHash(repo: string, paths: string[]): Promise<string> {
188
+ const h = new Bun.CryptoHasher("sha256");
189
+ for (const p of paths) {
190
+ // the tree entry, mode and type with the object id (`100755 blob <sha>`), so a committed chmod -x is new
191
+ // content; a directory's tree id already covers its children's modes
192
+ const entry = await git(
193
+ repo,
194
+ p === "."
195
+ ? ["rev-parse", "HEAD^{tree}"]
196
+ : ["ls-tree", "--full-tree", "HEAD", "--", p.replace(/\/$/, "")],
197
+ );
198
+ const at = entry.kind === "ok" ? entry.out.trim() : "";
199
+ // a mistyped path (or a glob) would hash as a constant and carry a pass forever
200
+ if (!at && !existsSync(join(repo, p)))
201
+ throw new CatherdError(
202
+ "E_INPUT_INVALID",
203
+ `gate path ${p} exists neither at HEAD nor in the working tree`,
204
+ {
205
+ fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs)",
206
+ },
207
+ );
208
+ h.update(`${p}=${at || "missing"}\n`);
209
+ // git status leaves ignored files out, so an ignored path (.env, a build output) or one not at HEAD is
210
+ // hashed by what is on disk; "." keeps to HEAD and the not-ignored status. A symlink's HEAD entry is only
211
+ // its link text, so a named symlink is hashed from disk too, by what it points at
212
+ if (p !== "." && (!at || isLink(join(repo, p)) || (await ignored(repo, p)))) {
213
+ // one budget per gate path: its walk and every symlink target it follows count against GATE_WALK_MAX
214
+ const budget: Budget = { path: p, files: 0, seen: new Set() };
215
+ for (const f of walk(repo, p)) h.update(`disk ${f}=${await diskEntry(join(repo, f), budget)}\n`);
216
+ }
217
+ }
218
+ const dirty = Object.keys(await statusSnapshot(repo))
219
+ .filter((f) => paths.includes(".") || overlaps([f], paths).length > 0)
220
+ .sort();
221
+ for (const f of dirty) h.update(`dirty ${f}=${await dirtyEntry(join(repo, f), f)}\n`);
222
+ return h.digest("hex");
223
+ }
224
+
225
+ const readPasses = (repo: string): GatePass[] =>
226
+ readJsonl<unknown>(gatesFile(repo)).rows.flatMap((row) => {
227
+ const r = GatePassSchema.safeParse(row);
228
+ return r.success ? [r.data] : [];
229
+ });
230
+
231
+ function recordStep(run: Run, step: VerifierStep): void {
232
+ const file = stepsFile(run);
233
+ ensureJsonlHeader(file, "verifier");
234
+ appendJsonl(file, step);
235
+ }
236
+
237
+ /** The verifier's latest step in this run, for `status` and `peek`; null before its first gate_check. */
238
+ export function latestVerifierStep(run: Run): VerifierStep | null {
239
+ return (readJsonl<VerifierStep>(stepsFile(run))
240
+ .rows.filter((r) => typeof r?.item === "string")
241
+ .at(-1) ?? null) as VerifierStep | null;
242
+ }
243
+
244
+ /**
245
+ * `gate_check`: carried when this repo has a pass with the same command on the same content of `paths`;
246
+ * records "verifier step: <item>" either way.
247
+ */
248
+ export async function gateCheck(
249
+ deps: Deps,
250
+ i: { run: string; item: string; command: string; paths: string[]; milestone?: string },
251
+ ): Promise<{ carried: true; passedAt: string; commit: string } | { carried: false }> {
252
+ const run = findRun(i.run);
253
+ const paths = cleanPaths(i.paths);
254
+ const hash = await contentHash(run.meta.repo, paths);
255
+ const pass = readPasses(run.meta.repo).findLast((p) => p.command === i.command && p.hash === hash);
256
+ recordStep(run, {
257
+ at: new Date(deps.now()).toISOString(),
258
+ item: i.item,
259
+ carried: pass !== undefined,
260
+ ...(pass ? { commit: pass.commit } : {}),
261
+ ...(i.milestone ? { milestone: i.milestone } : {}),
262
+ });
263
+ return pass ? { carried: true, passedAt: pass.at, commit: pass.commit } : { carried: false };
264
+ }
265
+
266
+ /** `gate_pass`: records that `command` passed on the current content of `paths`, with its evidence. */
267
+ export async function gatePass(
268
+ deps: Deps,
269
+ i: { run: string; item: string; command: string; paths: string[]; evidence: string },
270
+ ): Promise<{ recorded: true; hash: string; commit: string }> {
271
+ const run = findRun(i.run);
272
+ const paths = cleanPaths(i.paths);
273
+ const hash = await contentHash(run.meta.repo, paths);
274
+ const commit = (await gitHead(run.meta.repo)) ?? "none";
275
+ const file = gatesFile(run.meta.repo);
276
+ ensureJsonlHeader(file, "gates");
277
+ appendJsonl(file, {
278
+ at: new Date(deps.now()).toISOString(),
279
+ run: run.id,
280
+ item: i.item,
281
+ command: i.command,
282
+ paths,
283
+ hash,
284
+ commit,
285
+ evidence: i.evidence,
286
+ } satisfies GatePass);
287
+ return { recorded: true, hash, commit };
288
+ }