catherd-cli 1.3.0 → 1.5.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 (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -0,0 +1,218 @@
1
+ import { existsSync, renameSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { ROLE_ENV } from "../domain/role-scope.ts";
6
+ import { isCatherdSecret } from "../infra/env.ts";
7
+ import { withFileLock } from "../infra/filelock.ts";
8
+ import { gitToplevel } from "../infra/git.ts";
9
+ import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
10
+ import { repoDir } from "../infra/paths.ts";
11
+ import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
12
+ import { knowledgeFor } from "./run-store.ts";
13
+
14
+ // Plan 23: the gate environment a repo's verifier and preflight need (DOCKER_HOST, HTTPS_PROXY, a
15
+ // TESTCONTAINERS_* setting), kept beside the repo's knowledge.md, so no verifier sets it up by hand. A value is
16
+ // stored as is; a secret is stored by reference only: the name of the env var that holds it, read when a
17
+ // role or a check starts, never written to disk.
18
+
19
+ const GATE_ENV_SCHEMA = 1;
20
+
21
+ const EntrySchema = z.union([z.strictObject({ value: z.string() }), z.strictObject({ from: z.string() })]);
22
+ export type GateEnvEntry = z.infer<typeof EntrySchema>;
23
+
24
+ const GateEnvSchema = z.looseObject({
25
+ schema: z.literal(GATE_ENV_SCHEMA),
26
+ vars: z.record(z.string(), EntrySchema),
27
+ });
28
+ type GateEnvFile = z.infer<typeof GateEnvSchema>;
29
+
30
+ /** `<data>/repos/<repo key>/gate-env.json`: where a toplevel keyed it (before 1.5, and with no origin). */
31
+ export const gateEnvFile = (toplevel: string): string => join(repoDir(toplevel), "gate-env.json");
32
+
33
+ /**
34
+ * Spec 1.5 "Knowledge keyed by git origin": the gate-env.json beside the repo's knowledge.md, which every
35
+ * worktree and clone of one repository shares. A toplevel-keyed file this worktree left before is migrated on
36
+ * read: its variables join the shared file (the shared file's own win), and it is renamed `.migrated`.
37
+ */
38
+ export async function gateEnvFor(toplevel: string): Promise<string> {
39
+ const file = join(dirname(await knowledgeFor(toplevel)), "gate-env.json");
40
+ const legacy = gateEnvFile(toplevel);
41
+ if (file === legacy || !existsSync(legacy)) return file;
42
+ ensurePrivateDir(dirname(file));
43
+ await withFileLock(file, () => {
44
+ if (!existsSync(legacy)) return;
45
+ const vars = { ...readVarsAt(legacy), ...readVarsAt(file) };
46
+ writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
47
+ renameSync(legacy, `${legacy}.migrated`);
48
+ });
49
+ return file;
50
+ }
51
+
52
+ function readVarsAt(file: string): Record<string, GateEnvEntry> {
53
+ if (!existsSync(file)) return {};
54
+ return readVersioned<GateEnvFile>(file, GateEnvSchema, GATE_ENV_SCHEMA).vars;
55
+ }
56
+
57
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
58
+ /** A name a secret goes by: such a variable is set by reference (--from), never by value. */
59
+ const SECRET_NAME = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
60
+
61
+ /**
62
+ * The names catherd sets in a dispatch itself: the role's identity, dispatch id, testcontainers session, scratch
63
+ * and working dir, the process basics, catherd's own dirs, and the homes an isolated CLI is moved to (codex,
64
+ * grok, cursor, agy, opencode, claude). A gate env entry under one would take the role's identity or isolation.
65
+ */
66
+ const RESERVED = new Set([
67
+ ROLE_ENV,
68
+ DISPATCH_ID_ENV,
69
+ "TESTCONTAINERS_SESSION_ID",
70
+ "TMPDIR",
71
+ "PWD",
72
+ "PATH",
73
+ "HOME",
74
+ "CATHERD_HOME",
75
+ "CATHERD_CONFIG_DIR",
76
+ "CATHERD_DATA_DIR",
77
+ "CODEX_HOME",
78
+ "GROK_HOME",
79
+ "CLAUDE_CONFIG_DIR",
80
+ "CURSOR_CONFIG_DIR",
81
+ "CURSOR_DATA_DIR",
82
+ "XDG_CONFIG_HOME",
83
+ "XDG_DATA_HOME",
84
+ ]);
85
+
86
+ /** The repo's gate environment, by name; empty when none is set. */
87
+ export async function readGateEnv(toplevel: string): Promise<Record<string, GateEnvEntry>> {
88
+ return readVarsAt(await gateEnvFor(toplevel));
89
+ }
90
+
91
+ async function top(repo: string): Promise<string> {
92
+ const t = await gitToplevel(repo);
93
+ if (!t)
94
+ throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
95
+ fix: "pass the path of the repository",
96
+ });
97
+ return t;
98
+ }
99
+
100
+ function assertName(name: string): void {
101
+ if (!NAME.test(name))
102
+ throw new CatherdError("E_INPUT_INVALID", `"${name}" is not an env var name`, {
103
+ fix: "use letters, digits and _, not starting with a digit, e.g. DOCKER_HOST",
104
+ });
105
+ }
106
+
107
+ async function update(
108
+ repo: string,
109
+ change: (vars: Record<string, GateEnvEntry>) => void,
110
+ ): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
111
+ const t = await top(repo);
112
+ const file = await gateEnvFor(t);
113
+ ensurePrivateDir(dirname(file));
114
+ return withFileLock(file, () => {
115
+ const vars = { ...readVarsAt(file) };
116
+ change(vars);
117
+ writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
118
+ return { repo: t, vars };
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Sets one variable of the repo's gate environment: a value, or `from`, the name of the env var that holds a
124
+ * secret. A secret-looking name (…KEY, …TOKEN, …PASSWORD) takes `from` only, so no secret lands on disk.
125
+ */
126
+ export async function setGateEnv(
127
+ repo: string,
128
+ name: string,
129
+ entry: GateEnvEntry,
130
+ ): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
131
+ assertName(name);
132
+ if (RESERVED.has(name))
133
+ throw new CatherdError("E_INPUT_INVALID", `${name} is set by catherd for every role`, {
134
+ fix: "the gate environment holds what a gate needs (DOCKER_HOST, a proxy, a TESTCONTAINERS_* setting), not a role's identity, scratch or home",
135
+ });
136
+ if ("from" in entry) {
137
+ assertName(entry.from);
138
+ if (isCatherdSecret(entry.from))
139
+ throw new CatherdError("E_INPUT_INVALID", `${entry.from} is catherd's own secret: no role gets it`, {
140
+ fix: "reference the env var that holds the gate's own secret",
141
+ });
142
+ } else if (SECRET_NAME.test(name))
143
+ throw new CatherdError(
144
+ "E_INPUT_INVALID",
145
+ `${name} looks like a secret: catherd stores it by reference only`,
146
+ {
147
+ fix: `export it in your shell, then: catherd knowledge env set ${name} --from <the env var that holds it>`,
148
+ },
149
+ );
150
+ return update(repo, (vars) => {
151
+ vars[name] = entry;
152
+ });
153
+ }
154
+
155
+ /** Removes one variable; refused when it is not set. */
156
+ export function removeGateEnv(
157
+ repo: string,
158
+ name: string,
159
+ ): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
160
+ return update(repo, (vars) => {
161
+ if (!(name in vars))
162
+ throw new CatherdError("E_INPUT_INVALID", `${name} is not in the gate environment`, {
163
+ fix: "catherd knowledge env list shows what is set",
164
+ });
165
+ delete vars[name];
166
+ });
167
+ }
168
+
169
+ /** The variables as briefs show them: `NAME=value`, a reference as `NAME=$FROM`. */
170
+ export const gateEnvLines = (vars: Record<string, GateEnvEntry>): string[] =>
171
+ Object.entries(vars)
172
+ .sort(([a], [b]) => a.localeCompare(b))
173
+ .map(([k, e]) => ("from" in e ? `${k}=$${e.from}` : `${k}=${e.value}`));
174
+
175
+ /** The literal values, for a spec written to disk; references stay names (`refs`), resolved at spawn. */
176
+ export function gateEnvParts(vars: Record<string, GateEnvEntry>): {
177
+ values: Record<string, string>;
178
+ refs: Record<string, string>;
179
+ } {
180
+ const values: Record<string, string> = {};
181
+ const refs: Record<string, string> = {};
182
+ for (const [k, e] of Object.entries(vars)) {
183
+ if ("from" in e) refs[k] = e.from;
184
+ else values[k] = e.value;
185
+ }
186
+ return { values, refs };
187
+ }
188
+
189
+ /**
190
+ * The env the gate environment adds to `base`: its values, and each reference read from `base`, else from
191
+ * `fallback` (the login env); a reference neither holds is left out, and named in `missing`.
192
+ */
193
+ export function resolveGateEnv(
194
+ vars: Record<string, GateEnvEntry>,
195
+ base: Record<string, string | undefined>,
196
+ fallback: () => Record<string, string | undefined> = () => ({}),
197
+ ): { env: Record<string, string>; missing: string[] } {
198
+ const { values, refs } = gateEnvParts(vars);
199
+ return resolveRefs(values, refs, base, fallback);
200
+ }
201
+
202
+ /** `values` plus each of `refs` (name → the env var holding it) read from `base`, else `fallback`. */
203
+ export function resolveRefs(
204
+ values: Record<string, string>,
205
+ refs: Record<string, string>,
206
+ base: Record<string, string | undefined>,
207
+ fallback: () => Record<string, string | undefined> = () => ({}),
208
+ ): { env: Record<string, string>; missing: string[] } {
209
+ const env = { ...values };
210
+ const missing: string[] = [];
211
+ let login: Record<string, string | undefined> | null = null;
212
+ for (const [k, from] of Object.entries(refs)) {
213
+ const v = base[from] ?? (login ??= fallback())[from];
214
+ if (v === undefined) missing.push(k);
215
+ else env[k] = v;
216
+ }
217
+ return { env, missing };
218
+ }
@@ -1,19 +1,12 @@
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";
1
+ import { lstatSync, readdirSync, readlinkSync, realpathSync, type Stats, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
11
3
  import { z } from "zod";
12
4
  import { CatherdError, isCatherdError } from "../domain/errors.ts";
13
5
  import { normalizeOwned, overlaps } from "../domain/lane.ts";
14
6
  import { git, gitHead, statusSnapshot } from "../infra/git.ts";
15
7
  import { repoDir } from "../infra/paths.ts";
16
8
  import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
9
+ import { gateEnvLines, readGateEnv } from "./gate-env.ts";
17
10
  import type { Deps } from "./ports.ts";
18
11
  import { findRun, type Run } from "./run-store.ts";
19
12
 
@@ -35,6 +28,10 @@ type GatePass = z.infer<typeof GatePassSchema>;
35
28
  export interface VerifierStep {
36
29
  at: string;
37
30
  item: string;
31
+ /** the item's command; steps written before 1.5 lack it */
32
+ command?: string;
33
+ /** the item's paths, cleaned (plan 23); steps written before it lack them */
34
+ paths?: string[];
38
35
  carried: boolean;
39
36
  /** a carried item: the commit its pass was recorded on */
40
37
  commit?: string;
@@ -46,10 +43,11 @@ export interface VerifierStep {
46
43
  export const gatesFile = (toplevel: string): string => join(repoDir(toplevel), "gates.jsonl");
47
44
  const stepsFile = (run: Run): string => join(run.dir, "verifier.jsonl");
48
45
 
49
- /** Repo-relative paths, `.` for the whole repo; anything that leaves the repo is refused. */
46
+ /** Repo-relative paths, `.` (or `./`) for the whole repo; anything that leaves the repo is refused. */
50
47
  function cleanPaths(paths: string[]): string[] {
48
+ const whole = (p: string) => p.trim() === "." || p.trim() === "./";
51
49
  try {
52
- return [...new Set(paths.map((p) => (p.trim() === "." ? "." : normalizeOwned(p))))].sort();
50
+ return [...new Set(paths.map((p) => (whole(p) ? "." : normalizeOwned(p))))].sort();
53
51
  } catch (e) {
54
52
  if (isCatherdError(e))
55
53
  throw new CatherdError("E_INPUT_INVALID", e.message.replace("owned path", "gate path"), {
@@ -148,7 +146,10 @@ const isLink = (file: string): boolean => {
148
146
 
149
147
  /** Whether git ignores `p` (a tracked file never is). */
150
148
  async function ignored(repo: string, p: string): Promise<boolean> {
151
- return (await git(repo, ["check-ignore", "-q", "--", p.replace(/\/$/, "")])).kind === "ok";
149
+ const bare = p.replace(/\/$/, "");
150
+ const asked = async (q: string) => (await git(repo, ["check-ignore", "-q", "--", q])).kind === "ok";
151
+ // a directory pattern (`dist/`) matches a path that is not on disk yet only through a child of it
152
+ return (await asked(bare)) || (!onDisk(join(repo, bare)) && (await asked(`${bare}/.catherd`)));
152
153
  }
153
154
 
154
155
  /** The most files a gate path's on-disk walk hashes. */
@@ -179,47 +180,101 @@ function walk(repo: string, p: string): string[] {
179
180
  }
180
181
 
181
182
  /**
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.
183
+ * The lockfiles a gate item's dependencies come from, wherever the repo tracks them: they stand in for
184
+ * `node_modules` and the toolchain caches, which a gate path never needs to name (plan 23).
186
185
  */
187
- async function contentHash(repo: string, paths: string[]): Promise<string> {
186
+ export const LOCKFILES = [
187
+ "bun.lock",
188
+ "bun.lockb",
189
+ "package-lock.json",
190
+ "npm-shrinkwrap.json",
191
+ "pnpm-lock.yaml",
192
+ "yarn.lock",
193
+ "go.sum",
194
+ "Cargo.lock",
195
+ "poetry.lock",
196
+ "uv.lock",
197
+ "Gemfile.lock",
198
+ "composer.lock",
199
+ ];
200
+
201
+ /** `git ls-files -s -z` for `pathspecs`: each tracked file's mode, object id, stage and path, as the index holds them. */
202
+ async function tracked(repo: string, pathspecs: string[]): Promise<string> {
203
+ const r = await git(repo, ["ls-files", "-s", "-z", "--", ...pathspecs]);
204
+ if (r.kind !== "ok")
205
+ throw new CatherdError(
206
+ "E_IO_UNEXPECTED",
207
+ `git ls-files ${r.kind === "timed-out" ? "timed out" : "failed"} in ${repo}`,
208
+ { fix: `check that git works in ${repo}` },
209
+ );
210
+ return r.out;
211
+ }
212
+
213
+ /** Whether anything is at `file` on disk, a dangling symlink included. */
214
+ const onDisk = (file: string): boolean => {
215
+ try {
216
+ lstatSync(file);
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ };
222
+
223
+ /**
224
+ * The content hash of `paths` (plan 23, a monorepo gate): the tracked files under each one, from `git ls-files -s`
225
+ * (mode, object id and path, so a committed chmod -x is new content), the repo's tracked lockfiles, and the
226
+ * content of every uncommitted change under them, so a verifier checking a tree not yet committed gets a hash of
227
+ * what it ran. An ignored path named explicitly (a `.env`, a build output, a `node_modules` folder) is hashed
228
+ * from disk, file by file within GATE_WALK_MAX, and as "absent" while it is not there; ignored files under `.`
229
+ * or under a tracked directory are never walked. A named symlink is hashed by what it points at too.
230
+ */
231
+ async function contentHash(repo: string, paths: string[]): Promise<{ hash: string; dirty: boolean }> {
188
232
  const h = new Bun.CryptoHasher("sha256");
233
+ // a dependency bump reaches every gate item through its lockfile
234
+ h.update(
235
+ `locks=${sha(
236
+ await tracked(
237
+ repo,
238
+ LOCKFILES.map((l) => `:(glob)**/${l}`),
239
+ ),
240
+ )}\n`,
241
+ );
189
242
  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)))
243
+ const bare = p.replace(/\/$/, "");
244
+ const listed = await tracked(repo, [p === "." ? "." : bare]);
245
+ const there = p === "." || onDisk(join(repo, bare));
246
+ const isIgnored = p !== "." && listed === "" && (await ignored(repo, p));
247
+ // a mistyped path (or a glob) would hash as a constant and carry a pass forever; an ignored output that
248
+ // is not built yet is part of the content, as "absent"
249
+ if (listed === "" && !there && !isIgnored)
201
250
  throw new CatherdError(
202
251
  "E_INPUT_INVALID",
203
252
  `gate path ${p} exists neither at HEAD nor in the working tree`,
204
253
  {
205
- fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs)",
254
+ fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs); a git-ignored output not built yet is fine",
206
255
  },
207
256
  );
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)))) {
257
+ h.update(`${p}=${listed ? sha(listed) : there ? "untracked" : "absent"}\n`);
258
+ // git status leaves ignored files out, so an ignored path named here is hashed by what is on disk; "."
259
+ // keeps to the index and the not-ignored status. A tracked symlink's index entry is only its link text,
260
+ // so a named symlink is hashed from disk too, by what it points at
261
+ if (p !== "." && there && (isIgnored || isLink(join(repo, bare)))) {
213
262
  // one budget per gate path: its walk and every symlink target it follows count against GATE_WALK_MAX
214
263
  const budget: Budget = { path: p, files: 0, seen: new Set() };
215
264
  for (const f of walk(repo, p)) h.update(`disk ${f}=${await diskEntry(join(repo, f), budget)}\n`);
216
265
  }
217
266
  }
267
+ // an uncommitted lockfile change (a worker's `bun add`) reaches every item, as a committed one does
218
268
  const dirty = Object.keys(await statusSnapshot(repo))
219
- .filter((f) => paths.includes(".") || overlaps([f], paths).length > 0)
269
+ .filter(
270
+ (f) =>
271
+ paths.includes(".") ||
272
+ LOCKFILES.includes(basename(f.replace(/\/$/, ""))) ||
273
+ overlaps([f], paths).length > 0,
274
+ )
220
275
  .sort();
221
276
  for (const f of dirty) h.update(`dirty ${f}=${await dirtyEntry(join(repo, f), f)}\n`);
222
- return h.digest("hex");
277
+ return { hash: h.digest("hex"), dirty: dirty.length > 0 };
223
278
  }
224
279
 
225
280
  const readPasses = (repo: string): GatePass[] =>
@@ -241,37 +296,153 @@ export function latestVerifierStep(run: Run): VerifierStep | null {
241
296
  .at(-1) ?? null) as VerifierStep | null;
242
297
  }
243
298
 
299
+ /** One item the verifier checked for a milestone in this run: its name, its command, and whether it passed. */
300
+ export interface RecordedItem {
301
+ item: string;
302
+ command: string | null;
303
+ /** gate_check would carry it now: its last check's command has a pass on the current content of its paths */
304
+ passed: boolean;
305
+ }
306
+
307
+ const milestoneSteps = (run: Run, m: string): VerifierStep[] =>
308
+ readJsonl<VerifierStep>(stepsFile(run)).rows.filter(
309
+ (s) => typeof s?.item === "string" && s.milestone === m,
310
+ );
311
+
312
+ /** Each item's last step, in the order the items were first checked. */
313
+ const lastSteps = (steps: VerifierStep[]): VerifierStep[] =>
314
+ [...new Set(steps.map((s) => s.item))].map(
315
+ (item) => steps.findLast((s) => s.item === item) as VerifierStep,
316
+ );
317
+
318
+ /** The pass gate_check carries for `command` on `paths` as the tree is now, if any. */
319
+ async function carryingPass(repo: string, command: string, paths: string[]): Promise<GatePass | undefined> {
320
+ const { hash } = await contentHash(repo, paths);
321
+ return readPasses(repo).findLast((p) => p.command === command && p.hash === hash);
322
+ }
323
+
324
+ /**
325
+ * The items checked for milestone `m` in this run, in the order they were first checked (plan 23): a new
326
+ * verifier reuses their names, so the ledger carries what passed, and a re-check starts from the failed ones.
327
+ * An item counts as passed only when gate_check would carry it now, by the same rule: its last check's command
328
+ * has a pass on the current content of its paths, so a fix round that changed what it reads opens it again.
329
+ */
330
+ export async function recordedItems(run: Run, m: string): Promise<RecordedItem[]> {
331
+ const repo = run.meta.repo;
332
+ const passes = readPasses(repo);
333
+ return Promise.all(
334
+ lastSteps(milestoneSteps(run, m)).map(async (last) => {
335
+ const command = last.command ?? null;
336
+ // a step written before its paths were recorded: those of this run's latest pass of the item stand in
337
+ const paths =
338
+ last.paths ??
339
+ passes.findLast((p) => p.run === run.id && p.item === last.item && p.command === command)?.paths;
340
+ let passed = false;
341
+ if (command !== null && paths !== undefined) {
342
+ try {
343
+ passed = (await carryingPass(repo, command, paths)) !== undefined;
344
+ } catch (e) {
345
+ // a path gone from the tree carries no pass
346
+ if (!isCatherdError(e)) throw e;
347
+ }
348
+ }
349
+ return { item: last.item, command, passed };
350
+ }),
351
+ );
352
+ }
353
+
354
+ /**
355
+ * The milestone's checked items with no pass recorded since their last check, from the step history alone
356
+ * (sync, for the protocol's next step); the verifier's listing and brief use recordedItems, which also re-opens
357
+ * a pass whose content has changed since.
358
+ */
359
+ export function failedItems(run: Run, m: string): string[] {
360
+ const passes = readPasses(run.meta.repo).filter((p) => p.run === run.id);
361
+ return lastSteps(milestoneSteps(run, m))
362
+ .filter(
363
+ (last) =>
364
+ !last.carried && !passes.some((p) => p.item === last.item && Date.parse(p.at) >= Date.parse(last.at)),
365
+ )
366
+ .map((last) => last.item);
367
+ }
368
+
244
369
  /**
245
370
  * `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.
371
+ * records "verifier step: <item>" either way. With `milestone` it also lists the milestone's recorded items
372
+ * (plan 23), so a verifier reuses the names an earlier one checked.
247
373
  */
248
374
  export async function gateCheck(
249
375
  deps: Deps,
250
376
  i: { run: string; item: string; command: string; paths: string[]; milestone?: string },
251
- ): Promise<{ carried: true; passedAt: string; commit: string } | { carried: false }> {
377
+ ): Promise<
378
+ ({ carried: true; passedAt: string; commit: string } | { carried: false }) & { recorded?: RecordedItem[] }
379
+ > {
252
380
  const run = findRun(i.run);
253
381
  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);
382
+ const pass = await carryingPass(run.meta.repo, i.command, paths);
256
383
  recordStep(run, {
257
384
  at: new Date(deps.now()).toISOString(),
258
385
  item: i.item,
386
+ command: i.command,
387
+ paths,
259
388
  carried: pass !== undefined,
260
389
  ...(pass ? { commit: pass.commit } : {}),
261
390
  ...(i.milestone ? { milestone: i.milestone } : {}),
262
391
  });
263
- return pass ? { carried: true, passedAt: pass.at, commit: pass.commit } : { carried: false };
392
+ const carried = pass
393
+ ? { carried: true as const, passedAt: pass.at, commit: pass.commit }
394
+ : { carried: false as const };
395
+ return i.milestone ? { ...carried, recorded: await recordedItems(run, i.milestone) } : carried;
396
+ }
397
+
398
+ /**
399
+ * `gate_check` with a milestone and no item: the milestone's recorded items, recording no step, and the
400
+ * repo's gate environment as `NAME=value` lines (a secret as `NAME=$FROM`), which a native verifier exports.
401
+ */
402
+ export async function gateList(i: {
403
+ run: string;
404
+ milestone: string;
405
+ }): Promise<{ recorded: RecordedItem[]; env: string[] }> {
406
+ const run = findRun(i.run);
407
+ return {
408
+ recorded: await recordedItems(run, i.milestone),
409
+ env: gateEnvLines(await readGateEnv(run.meta.repo)),
410
+ };
264
411
  }
265
412
 
266
- /** `gate_pass`: records that `command` passed on the current content of `paths`, with its evidence. */
413
+ /** `gate_check` as both MCP servers expose it: item, command and paths together check an item; none of them,
414
+ * with a milestone, lists the milestone's recorded items. */
415
+ export async function gateCheckOrList(
416
+ deps: Deps,
417
+ a: { run: string; item?: string; command?: string; paths?: string[]; milestone?: string },
418
+ ): Promise<unknown> {
419
+ if (a.item === undefined && a.command === undefined && a.paths === undefined) {
420
+ if (!a.milestone)
421
+ throw new CatherdError("E_INPUT_INVALID", "gate_check needs an item, or a milestone to list", {
422
+ fix: "pass item, command and paths to check an item, or only run and milestone to list the recorded items",
423
+ });
424
+ return gateList({ run: a.run, milestone: a.milestone });
425
+ }
426
+ if (a.item === undefined || a.command === undefined || a.paths === undefined)
427
+ throw new CatherdError("E_INPUT_INVALID", "gate_check needs item, command and paths together", {
428
+ fix: "pass all three to check an item, or none of them (with milestone) to list the recorded items",
429
+ });
430
+ return gateCheck(deps, { ...a, item: a.item, command: a.command, paths: a.paths });
431
+ }
432
+
433
+ /**
434
+ * `gate_pass`: records that `command` passed on the current content of `paths`, with its evidence. A pass on
435
+ * uncommitted changes under `paths` names its commit `<HEAD>+uncommitted`: HEAD alone is not what it ran on.
436
+ */
267
437
  export async function gatePass(
268
438
  deps: Deps,
269
439
  i: { run: string; item: string; command: string; paths: string[]; evidence: string },
270
440
  ): Promise<{ recorded: true; hash: string; commit: string }> {
271
441
  const run = findRun(i.run);
272
442
  const paths = cleanPaths(i.paths);
273
- const hash = await contentHash(run.meta.repo, paths);
274
- const commit = (await gitHead(run.meta.repo)) ?? "none";
443
+ const { hash, dirty } = await contentHash(run.meta.repo, paths);
444
+ const head = (await gitHead(run.meta.repo)) ?? "none";
445
+ const commit = dirty ? `${head}+uncommitted` : head;
275
446
  const file = gatesFile(run.meta.repo);
276
447
  ensureJsonlHeader(file, "gates");
277
448
  appendJsonl(file, {
@@ -23,25 +23,51 @@ export type GlobalInstall =
23
23
  | { state: "shadowed"; onPath: string | null }
24
24
  | { state: "failed"; reason: string };
25
25
 
26
- async function run(cmd: string[]): Promise<{ ok: boolean; stdout: string; output: string }> {
26
+ /** How long `bun add -g` may take: a blackholed or proxied registry would otherwise hold `init` for as long as Bun retries. */
27
+ export const installLimits = { timeoutMs: 120_000 };
28
+
29
+ /** How long a `catherd --version` probe may take before its version counts as unknown. */
30
+ export const versionLimits = { timeoutMs: 10_000 };
31
+
32
+ /**
33
+ * `cmd` with its output. Past `timeoutMs` it is killed and reported as failed with the reason; its output is
34
+ * not awaited then, since a child it started may still hold the pipes open.
35
+ */
36
+ export async function run(
37
+ cmd: string[],
38
+ timeoutMs?: number,
39
+ ): Promise<{ ok: boolean; stdout: string; output: string }> {
27
40
  const p = Bun.spawn(cmd, {
28
41
  env: scrubSecrets(process.env),
29
42
  stdin: "ignore",
30
43
  stdout: "pipe",
31
44
  stderr: "pipe",
32
45
  });
33
- const [out, err, code] = await Promise.all([
34
- new Response(p.stdout).text(),
35
- new Response(p.stderr).text(),
36
- p.exited,
37
- ]);
38
- return { ok: code === 0, stdout: out, output: `${err}${out}` };
46
+ const out = new Response(p.stdout).text().catch(() => "");
47
+ const err = new Response(p.stderr).text().catch(() => "");
48
+ let timer: ReturnType<typeof setTimeout> | undefined;
49
+ const late = new Promise<"late">((resolve) => {
50
+ if (timeoutMs !== undefined) timer = setTimeout(() => resolve("late"), timeoutMs);
51
+ });
52
+ const code = await Promise.race([p.exited, late]).finally(() => clearTimeout(timer));
53
+ if (code === "late") {
54
+ p.kill("SIGKILL");
55
+ const what = cmd.slice(1).join(" ");
56
+ return {
57
+ ok: false,
58
+ stdout: "",
59
+ output: `${what} timed out after ${Math.round((timeoutMs ?? 0) / 1000)} s`,
60
+ };
61
+ }
62
+ const [stdout, stderr] = await Promise.all([out, err]);
63
+ return { ok: code === 0, stdout, output: `${stderr}${stdout}` };
39
64
  }
40
65
 
41
66
  /** what `bin --version` prints on stdout (a warning on stderr must not make it look like another version) */
42
67
  async function versionOf(bin: string | null): Promise<string | null> {
43
68
  if (!bin) return null;
44
- const r = await run([bin, "--version"]).catch(() => null);
69
+ // a wedged `catherd` on PATH must not hold `init`: past the limit its version is unknown
70
+ const r = await run([bin, "--version"], versionLimits.timeoutMs).catch(() => null);
45
71
  return r?.ok ? r.stdout.trim() || null : null;
46
72
  }
47
73
 
@@ -71,10 +97,12 @@ export const realGlobalInstall: GlobalInstallDeps = {
71
97
  return versionOf(Bun.which("catherd", { PATH }));
72
98
  },
73
99
  install: (version) =>
74
- run([process.execPath, "add", "-g", `catherd-cli@${version}`]).catch((e: unknown) => ({
75
- ok: false,
76
- output: errorMessage(e),
77
- })),
100
+ run([process.execPath, "add", "-g", `catherd-cli@${version}`], installLimits.timeoutMs).catch(
101
+ (e: unknown) => ({
102
+ ok: false,
103
+ output: errorMessage(e),
104
+ }),
105
+ ),
78
106
  };
79
107
 
80
108
  /**
@@ -86,7 +114,12 @@ export async function ensureGlobal(
86
114
  deps: GlobalInstallDeps,
87
115
  installing: () => void,
88
116
  ): Promise<GlobalInstall> {
89
- if ((await deps.globalVersion()) === version) return { state: "current" };
117
+ if ((await deps.globalVersion()) === version) {
118
+ // current, but the launcher runs the catherd PATH finds first: another version there (or none) still
119
+ // makes it fall back to bunx, so say so as after an install
120
+ const onPath = await deps.pathVersion().catch(() => null);
121
+ return onPath === version ? { state: "current" } : { state: "shadowed", onPath };
122
+ }
90
123
  installing();
91
124
  // never rejects: a failed install is reported, and init goes on
92
125
  const r = await deps.install(version).catch((e: unknown) => ({ ok: false, output: errorMessage(e) }));