catherd-cli 1.0.0 → 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 (82) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +63 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/state.ts +26 -6
  44. package/src/entry/tui/stories.ts +36 -4
  45. package/src/entry/tui/text.ts +12 -0
  46. package/src/entry/tui/theme.ts +1 -0
  47. package/src/entry/tui/views/runs.tsx +409 -117
  48. package/src/entry/tui/views/status.tsx +5 -2
  49. package/src/entry/tui/widgets/list.tsx +4 -3
  50. package/src/infra/claude-session.ts +98 -0
  51. package/src/infra/dispatch-dir.ts +14 -6
  52. package/src/infra/env.ts +26 -2
  53. package/src/infra/peer-inbox.ts +92 -0
  54. package/src/infra/supervisor.ts +33 -3
  55. package/src/services/admission.ts +38 -12
  56. package/src/services/dispatch-service.ts +338 -197
  57. package/src/services/dispatches.ts +30 -1
  58. package/src/services/doctor-access.ts +231 -0
  59. package/src/services/doctor-backends.ts +3 -18
  60. package/src/services/doctor-checks.ts +46 -1
  61. package/src/services/doctor-push.ts +109 -0
  62. package/src/services/doctor.ts +59 -79
  63. package/src/services/finalize.ts +43 -25
  64. package/src/services/gate-service.ts +288 -0
  65. package/src/services/global-install.ts +99 -0
  66. package/src/services/lane-service.ts +150 -8
  67. package/src/services/milestones.ts +208 -0
  68. package/src/services/notifier.ts +296 -0
  69. package/src/services/peek.ts +103 -0
  70. package/src/services/ports.ts +7 -4
  71. package/src/services/preflight.ts +17 -3
  72. package/src/services/protocol.ts +212 -0
  73. package/src/services/questions.ts +102 -0
  74. package/src/services/reconcile.ts +40 -11
  75. package/src/services/reentry.ts +22 -0
  76. package/src/services/run-service.ts +42 -9
  77. package/src/services/run-store.ts +18 -0
  78. package/src/services/runs-page.ts +254 -0
  79. package/src/services/session-view.ts +152 -0
  80. package/src/services/sessions.ts +92 -0
  81. package/src/services/state.ts +10 -3
  82. package/src/services/summary.ts +14 -0
@@ -1,13 +1,14 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { adapterFor } from "../adapters/registry.ts";
3
3
  import "../adapters/all.ts";
4
- import type { Profile } from "../domain/profile.ts";
5
- import { ROLES } from "../domain/roles.ts";
4
+ import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
5
+ import { DEFAULT_ACCESS, ROLES } from "../domain/roles.ts";
6
6
  import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
7
7
  import type { JevTransport } from "../infra/jev-client.ts";
8
- import { locksDir } from "../infra/paths.ts";
9
8
  import { linkedProfiles } from "./agent-links.ts";
10
- import { backendChecks, usedBackends, workspaceWriteBackends } from "./doctor-backends.ts";
9
+ import { accessChecks } from "./doctor-access.ts";
10
+ import { backendChecks, usedBackends } from "./doctor-backends.ts";
11
+ import { type PushProbe, pushCheck } from "./doctor-push.ts";
11
12
  import {
12
13
  agentsCheck,
13
14
  type Check,
@@ -15,6 +16,7 @@ import {
15
16
  fixOf,
16
17
  guarded,
17
18
  locksCheck,
19
+ mcpCheck,
18
20
  pluginCheck,
19
21
  } from "./doctor-checks.ts";
20
22
  import { credentialsPath, jevKey, savedJevKey, testJevKey } from "./jev-service.ts";
@@ -40,14 +42,18 @@ export interface Handshake {
40
42
  ok: boolean;
41
43
  tools: string[];
42
44
  error?: string;
45
+ /** the tail of what the server (or its launcher) wrote to stderr */
46
+ stderr?: string;
43
47
  }
44
48
 
45
49
  export interface DoctorDeps {
46
50
  bunVersion: string;
47
51
  /** the package version, which the Claude Code plugin must pin */
48
52
  version: string;
49
- /** starts `catherd mcp` over stdio and asks it for tools/list */
53
+ /** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
50
54
  handshake: () => Promise<Handshake>;
55
+ /** spec §3.9: sends this Claude Code session a test message; without it (the dashboard) there is no `push` row */
56
+ push?: () => Promise<PushProbe>;
51
57
  jev?: JevTransport;
52
58
  }
53
59
 
@@ -145,7 +151,8 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
145
151
  }
146
152
 
147
153
  const used = usedBackends(profiles);
148
- checks.push(...(await backendChecks(used, profiles)));
154
+ const installed = new Set<string>();
155
+ checks.push(...(await backendChecks(used, profiles, installed)));
149
156
 
150
157
  if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
151
158
  checks.push({
@@ -200,88 +207,59 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
200
207
  const h = await d
201
208
  .handshake()
202
209
  .catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
203
- checks.push(
204
- h.ok && h.tools.includes("status")
205
- ? {
206
- id: "mcp",
207
- label: "MCP server",
208
- state: "ok",
209
- word: "ready",
210
- detail: `answers tools/list with ${h.tools.length} tools`,
211
- }
212
- : {
213
- id: "mcp",
214
- label: "MCP server",
215
- state: "fail",
216
- word: "no answer",
217
- detail: h.error ?? "tools/list has no status tool",
218
- fix: "run catherd mcp to see why it does not start",
219
- },
220
- );
210
+ checks.push(mcpCheck(h, d.version));
221
211
 
222
- checks.push(locksCheck());
223
- for (const id of workspaceWriteBackends(profiles)) {
224
- const a = adapterFor(id);
225
- if (!a?.canWrite) continue;
226
- const r = await a.canWrite(locksDir()).catch(() => null);
227
- const base = { id: `sandbox:${id}`, label: `heavy-lock dir from ${id}'s sandbox` };
212
+ if (d.push)
228
213
  checks.push(
229
- r === null
230
- ? {
231
- ...base,
232
- state: "skip",
233
- word: "not tested",
234
- detail: `no ${id} sandbox to test with on this machine`,
235
- }
236
- : r.ok
237
- ? {
238
- ...base,
239
- state: "ok",
240
- word: "ready",
241
- detail: "a workspace-write worker can take the heavy lock",
242
- }
243
- : {
244
- ...base,
245
- state: "warn",
246
- word: "not writable",
247
- detail: `a workspace-write ${id} worker cannot write ${locksDir()}, so catherd lock fails inside it`,
248
- ...(r.fix ? { fix: r.fix } : {}),
249
- },
214
+ pushCheck(await d.push().catch((e: unknown): PushProbe => ({ outcome: "failed", detail: errText(e) }))),
250
215
  );
251
- }
252
216
 
253
- const full: string[] = [];
254
- const advisory: string[] = [];
217
+ checks.push(locksCheck());
218
+ // spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
219
+ checks.push(...(await accessChecks(profiles, installed)));
220
+
221
+ // spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
222
+ const full = { changed: [] as string[], shipped: [] as string[] };
223
+ const advisory = { changed: [] as string[], shipped: [] as string[] };
224
+ const backendOf = (r: string) => r.slice(0, r.indexOf(":"));
255
225
  for (const p of profiles)
256
226
  for (const role of ROLES) {
257
227
  const rc = p.roles[role];
258
228
  if (!rc.enabled) continue;
259
- if (rc.access === "full") full.push(`${role} (${p.name})`);
229
+ const asShipped = rc.access === DEFAULT_ACCESS[role];
230
+ if (rc.access === "full") (asShipped ? full.shipped : full.changed).push(`${role} (${p.name})`);
260
231
  const soft = [
261
- ...new Set(
262
- rc.rungs
263
- .filter((r) => enforcementOf(r, rc.access) === "advisory")
264
- .map((r) => r.slice(0, r.indexOf(":"))),
265
- ),
232
+ ...new Set(rc.rungs.filter((r) => enforcementOf(r, rc.access) === "advisory").map(backendOf)),
266
233
  ];
267
- if (soft.length) advisory.push(`${role} on ${soft.join(", ")} (${p.name})`);
234
+ const shippedOn = new Set(BUILTIN_ROLES[role].rungs.map(backendOf));
235
+ const ours = soft.filter((b) => asShipped && shippedOn.has(b));
236
+ const theirs = soft.filter((b) => !ours.includes(b));
237
+ if (ours.length) advisory.shipped.push(`${role} on ${ours.join(", ")} (${p.name})`);
238
+ if (theirs.length) advisory.changed.push(`${role} on ${theirs.join(", ")} (${p.name})`);
268
239
  }
269
- if (full.length)
270
- checks.push({
271
- id: "access:full",
272
- label: "full access",
273
- state: "warn",
274
- word: "warning",
275
- detail: `no sandbox for: ${full.join(", ")}`,
276
- });
277
- if (advisory.length)
278
- checks.push({
279
- id: "access:advisory",
280
- label: "advisory access",
281
- state: "warn",
282
- word: "warning",
283
- detail: `the backend asks but cannot force: ${advisory.join(", ")}`,
284
- });
240
+ const accessRow = (
241
+ id: string,
242
+ label: string,
243
+ rows: { changed: string[]; shipped: string[] },
244
+ what: string,
245
+ ): Check | null => {
246
+ if (!rows.changed.length && !rows.shipped.length) return null;
247
+ const shipped = rows.shipped.join(", ");
248
+ return rows.changed.length
249
+ ? {
250
+ id,
251
+ label,
252
+ state: "warn",
253
+ word: "warning",
254
+ detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
255
+ }
256
+ : { id, label, state: "info", word: "default", detail: `${what}: ${shipped}` };
257
+ };
258
+ for (const row of [
259
+ accessRow("access:full", "full access", full, "no sandbox for"),
260
+ accessRow("access:advisory", "advisory access", advisory, "the backend asks but cannot force"),
261
+ ])
262
+ if (row) checks.push(row);
285
263
  for (const id of used.keys()) {
286
264
  const note = adapterFor(id)?.isolationNote;
287
265
  if (note)
@@ -322,13 +300,15 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
322
300
  );
323
301
  }
324
302
 
325
- if (!Bun.which("bunx", { PATH: process.env.PATH ?? "" }))
303
+ // the plugin's launcher needs bunx only when no global catherd is installed
304
+ const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH ?? "" });
305
+ if (!onPath("bunx") && !onPath("catherd"))
326
306
  checks.push({
327
307
  id: "bunx",
328
308
  label: "bunx",
329
309
  state: "warn",
330
310
  word: "missing",
331
- detail: "the plugin starts the MCP server with bunx",
311
+ detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
332
312
  fix: "put Bun's bin folder (~/.bun/bin) on PATH",
333
313
  });
334
314
  let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
@@ -25,6 +25,7 @@ import {
25
25
  writeTextAtomic,
26
26
  } from "../infra/store.ts";
27
27
  import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
28
+ import { tail } from "./run-debug.ts";
28
29
  import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
29
30
 
30
31
  const text = (file: string): string => {
@@ -55,6 +56,15 @@ function claimStale(dir: string): boolean {
55
56
  return claimAgeMs(dir) > settleLimits.timeoutMs + gitLimits.timeoutMs + settleLimits.claimMarginMs;
56
57
  }
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
+
58
68
  /** The owned paths of the run's other dispatches whose lifetime overlapped [start, end]. */
59
69
  function othersOwns(run: Run, self: Dispatch, start: number, end: number): string[] {
60
70
  const ended = new Map(readRecords(run).records.map((r) => [r.dispatchId, Date.parse(r.endedAt)]));
@@ -215,6 +225,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
215
225
  images: o.images,
216
226
  error: o.error,
217
227
  replyPath: relative(run.dir, p.reply),
228
+ ...(a.sessionId ? { sessionId: a.sessionId } : {}),
218
229
  };
219
230
  }
220
231
 
@@ -252,7 +263,7 @@ function recordHarness(run: Run, d: Dispatch, r: RunRecord): void {
252
263
  * appendRecord's per-dispatch dedupe keeps that to one record either way (audit C2). A claimer that throws
253
264
  * releases its claim.
254
265
  *
255
- * In this process, a second finalizer (a `wait` beside the dispatch's watcher) joins the first one's
266
+ * In this process, a second finalizer (`cancel` beside the dispatch's watcher) joins the first one's
256
267
  * promise. Across processes it waits for the claimant's record, and takes over only once the claim is
257
268
  * stale (claimant dead, or past its settle window): never while a live claimant may still be settling.
258
269
  */
@@ -321,39 +332,46 @@ async function finalizeOnce(run: Run, d: Dispatch): Promise<RunRecord> {
321
332
  return saved;
322
333
  }
323
334
 
324
- /** The last event of a running dispatch worth showing in a progress line, if any. */
325
- export function lastEvent(d: Dispatch): string | null {
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 {
326
348
  const a = adapterFor(d.admit.backend);
327
- const line = nonBlankLines(dispatchPaths(d.dir).events).at(-1);
328
- if (!a || !line) return null;
329
- try {
330
- return a.parse(line).lastEvent ?? null;
331
- } catch {
332
- return null;
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;
333
360
  }
361
+ return name;
334
362
  }
335
363
 
336
- /** Waits until the dispatch finishes, calling `onTick` every `tickMs`; a throwing onTick never ends the wait. */
364
+ /** Waits until the dispatch finishes, polling every `pollMs`; `onPoll` runs at each poll, and never ends the wait. */
337
365
  export async function waitForFinish(
338
366
  d: Dispatch,
339
- o: {
340
- pollMs: number;
341
- tickMs: number;
342
- now: () => number;
343
- onTick?: (secs: number, lastEvent: string | null) => void;
344
- },
367
+ o: { pollMs: number; now: () => number; onPoll?: () => void },
345
368
  ): Promise<void> {
346
- const started = Date.now();
347
- let ticked = started;
348
369
  while (dispatchState(d, o.now()) !== "finished") {
349
370
  await Bun.sleep(o.pollMs);
350
- if (o.onTick && Date.now() - ticked >= o.tickMs) {
351
- ticked = Date.now();
352
- try {
353
- o.onTick(Math.round((ticked - started) / 1000), lastEvent(d));
354
- } catch {
355
- // a progress report must never stop the wait
356
- }
371
+ try {
372
+ o.onPoll?.();
373
+ } catch {
374
+ // a report on the way must never stop the wait
357
375
  }
358
376
  }
359
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
+ }