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
@@ -1,16 +1,19 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CatherdError, errorMessage } from "../domain/errors.ts";
4
- import { assertLaneHeader, LANE_HEADER_FIX, parseLaneHeader } from "../domain/lane.ts";
4
+ import { assertLaneHeader, LANE_HEADER_FIX, onlyAllowedHits, parseLaneHeader } from "../domain/lane.ts";
5
5
  import { checkEnv } from "../infra/env.ts";
6
- import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
6
+ import { heavySlots, withHeavySlotWithin } from "../infra/heavy-lock.ts";
7
+ import { loginEnv } from "../infra/login-env.ts";
7
8
  import { killGroup } from "../infra/proc.ts";
9
+ import { readGateEnv, resolveGateEnv } from "./gate-env.ts";
10
+ import { landedMilestones } from "./milestones.ts";
8
11
  import type { Deps } from "./ports.ts";
9
12
  import { TAIL_LINES } from "./run-debug.ts";
10
13
  import { findRun, type Run, runPaths } from "./run-store.ts";
11
14
 
12
- /** Spec §4.7. Only `cannot-start` blocks the run. */
13
- export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start";
15
+ /** Spec §4.7. Only `cannot-start` blocks the run; `lock-busy` (plan 23) means: run preflight again. */
16
+ export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start" | "lock-busy";
14
17
 
15
18
  interface PreflightResult {
16
19
  lane: string;
@@ -28,6 +31,8 @@ export type PreflightReport =
28
31
  commands: { lane: string; check: string | null }[];
29
32
  results: PreflightResult[];
30
33
  blocked: boolean;
34
+ /** plan 23: lanes whose fast check runs no linter while the repo has one */
35
+ warnings: string[];
31
36
  };
32
37
 
33
38
  const CHECK_TIMEOUT_MS = 120_000;
@@ -47,6 +52,8 @@ interface LaneCheck {
47
52
  lane: string;
48
53
  check: string | null;
49
54
  owns: string[];
55
+ /** spec 1.5: the Allow: exceptions of the lane's absence check */
56
+ allow: string[];
50
57
  problem: string | null;
51
58
  /** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
52
59
  invalid: string | null;
@@ -73,15 +80,19 @@ function laneChecks(run: Run): LaneCheck[] {
73
80
  lane,
74
81
  check: h.fastCheck,
75
82
  owns: h.owns,
83
+ allow: h.allow,
76
84
  problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
77
85
  invalid,
78
86
  };
79
87
  } catch (e) {
80
- return { lane, check: null, owns: [], problem: (e as Error).message, invalid };
88
+ return { lane, check: null, owns: [], allow: [], problem: (e as Error).message, invalid };
81
89
  }
82
90
  });
83
91
  }
84
92
 
93
+ /** How many output lines a check's Allow: exceptions are matched against; past it, the check fails as before. */
94
+ const ALLOW_LINES = 1_000;
95
+
85
96
  /** How long the pipes may stay open after the check's own process exits. */
86
97
  export const DRAIN_MS = 500;
87
98
 
@@ -116,10 +127,11 @@ export async function runCheck(
116
127
  repo: string,
117
128
  check: string,
118
129
  timeoutMs: number,
119
- ): Promise<{ code: number | null; timedOut: boolean; tail: string[] }> {
130
+ env: Record<string, string> = checkEnv(process.env, repo),
131
+ ): Promise<{ code: number | null; timedOut: boolean; tail: string[]; lines: string[] }> {
120
132
  const p = Bun.spawn(["sh", "-c", check], {
121
133
  cwd: repo,
122
- env: checkEnv(process.env, repo),
134
+ env,
123
135
  stdin: "ignore",
124
136
  stdout: "pipe",
125
137
  stderr: "pipe",
@@ -141,11 +153,10 @@ export async function runCheck(
141
153
  Bun.sleep(DRAIN_MS).then(() => false),
142
154
  ]);
143
155
  if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the check's group
144
- const tail = `${out.text()}${err.text()}`
145
- .split("\n")
146
- .filter((l) => l.trim())
147
- .slice(-TAIL_LINES);
148
- return { code: timedOut ? null : code, timedOut, tail };
156
+ // every line, up to a bound, for a lane's Allow: exceptions; the tail is what the report shows
157
+ const all = `${out.text()}${err.text()}`.split("\n").filter((l) => l.trim());
158
+ const lines = all.length <= ALLOW_LINES ? all : [];
159
+ return { code: timedOut ? null : code, timedOut, tail: all.slice(-TAIL_LINES), lines };
149
160
  } finally {
150
161
  clearTimeout(timer);
151
162
  out.stop();
@@ -153,10 +164,100 @@ export async function runCheck(
153
164
  }
154
165
  }
155
166
 
156
- export function classify(r: { code: number | null; timedOut: boolean; tail: string[] }): PreflightOutcome {
167
+ /**
168
+ * Plan 23: a check that failed on the machine, not on the code: no Docker (or none at DOCKER_HOST), no DNS, a
169
+ * permission the sandbox or the OS denied. Such a check cannot start; it never fails as expected.
170
+ */
171
+ const ENVIRONMENT = [
172
+ /command not found/i,
173
+ /Cannot connect to the Docker daemon/i,
174
+ /rootless Docker not found/i,
175
+ /Could not find a valid Docker environment/i,
176
+ /docker\.sock: connect: no such file or directory/i,
177
+ /Temporary failure in name resolution|Could not resolve host|no such host|Name or service not known|nodename nor servname|getaddrinfo (ENOTFOUND|EAI_AGAIN)/i,
178
+ /permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/i,
179
+ ];
180
+
181
+ /** pnpm exits 0 on a `--filter` that matches no package: the lane creates it, so there is nothing to run yet. */
182
+ const EMPTY_FILTER = /No projects matched the filters/i;
183
+
184
+ /** The first tail line an ENVIRONMENT pattern matches, or null. */
185
+ export const environmentLine = (tail: string[]): string | null =>
186
+ tail.find((l) => ENVIRONMENT.some((re) => re.test(l))) ?? null;
187
+
188
+ /** One `pnpm … --filter …` command, nothing chained, piped or backgrounded beside it. */
189
+ const SINGLE_PNPM_FILTER = /^\s*pnpm\s(?:[^;&|\n`$()<>]*\s)?(?:--filter|-F)(?:[\s=])[^;&|\n`$()<>]*$/;
190
+
191
+ /**
192
+ * An unmatched pnpm filter is "skipped" only when it is provably the check's sole failure: the whole check
193
+ * exited 0, or the check is a single pnpm --filter command. In a compound check (`pnpm --filter new test;
194
+ * docker compose up`) a later command's failure decides.
195
+ */
196
+ export function classify(
197
+ r: { code: number | null; timedOut: boolean; tail: string[] },
198
+ command?: string,
199
+ ): PreflightOutcome {
157
200
  if (r.timedOut || r.code === 126 || r.code === 127) return "cannot-start";
201
+ const soleFailure = r.code === 0 || (command !== undefined && SINGLE_PNPM_FILTER.test(command));
202
+ if (soleFailure && r.tail.some((l) => EMPTY_FILTER.test(l))) return "skipped";
158
203
  if (r.code === 0) return "pass";
159
- return r.tail.some((l) => /command not found/i.test(l)) ? "cannot-start" : "fails-as-expected";
204
+ return environmentLine(r.tail) !== null ? "cannot-start" : "fails-as-expected";
205
+ }
206
+
207
+ /** Why a classified result says what it says, for its note. */
208
+ function noteOf(
209
+ r: { code: number | null; timedOut: boolean; tail: string[] },
210
+ outcome: PreflightOutcome,
211
+ timeoutMs: number,
212
+ ): string | null {
213
+ if (r.timedOut) return `timed out after ${timeoutMs / 1000} s`;
214
+ if (outcome === "skipped") return "the pnpm filter matches no package yet; the lane creates it";
215
+ const env = outcome === "cannot-start" ? environmentLine(r.tail) : null;
216
+ return env ? `environment: ${env.trim()}` : null;
217
+ }
218
+
219
+ /** How long one check waits for a heavy slot before it reports lock-busy; tests shorten it. */
220
+ export const preflightLimits = { lockWaitMs: 60_000 };
221
+
222
+ /**
223
+ * Plan 23: a check runs in the user's login environment (DOCKER_HOST and the rest a login shell sets), through
224
+ * the allowlist, plus the repo's gate environment. A login PATH comes first, with the server's own after it.
225
+ */
226
+ export async function checkEnvFor(repo: string): Promise<Record<string, string>> {
227
+ const login = loginEnv();
228
+ const base: Record<string, string | undefined> = { ...process.env, ...login };
229
+ const path = [
230
+ ...new Set([...(login.PATH ?? "").split(":"), ...(process.env.PATH ?? "").split(":")].filter(Boolean)),
231
+ ];
232
+ if (path.length) base.PATH = path.join(":");
233
+ return { ...checkEnv(base, repo), ...resolveGateEnv(await readGateEnv(repo), base).env };
234
+ }
235
+
236
+ /** What a fast check names when it lints: a lint script, or a linter by name. */
237
+ const LINT_STEP = /\b(lint|eslint|biome|oxlint|golangci-lint|ruff|clippy|vet|check)\b/i;
238
+
239
+ /** Root files that say the repo has a linter, by the linter they configure. */
240
+ const LINT_CONFIGS: [RegExp, string][] = [
241
+ [/^\.golangci\.(ya?ml|toml|json)$/, "golangci-lint"],
242
+ [/^(biome\.jsonc?)$/, "biome"],
243
+ [/^(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?)$/, "eslint"],
244
+ [/^\.?oxlintrc\.json$/, "oxlint"],
245
+ [/^\.?ruff\.toml$/, "ruff"],
246
+ ];
247
+
248
+ /** The linter the repo has, by name, or null: a root lint config, or a `lint` script in package.json. */
249
+ export function repoLinter(repo: string): string | null {
250
+ for (const name of existsSync(repo) ? readdirSync(repo) : [])
251
+ for (const [re, linter] of LINT_CONFIGS) if (re.test(name)) return linter;
252
+ try {
253
+ const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")) as {
254
+ scripts?: Record<string, unknown>;
255
+ };
256
+ if (typeof pkg.scripts?.lint === "string") return "the lint script";
257
+ } catch {
258
+ // no package.json, or one that does not parse: no lint script
259
+ }
260
+ return null;
160
261
  }
161
262
 
162
263
  /**
@@ -165,11 +266,16 @@ export function classify(r: { code: number | null; timedOut: boolean; tail: stri
165
266
  */
166
267
  export async function preflight(
167
268
  deps: Deps,
168
- i: { run: string; confirmed?: boolean; timeoutMs?: number },
269
+ i: { run: string; confirmed?: boolean; timeoutMs?: number; milestone?: string },
169
270
  ): Promise<PreflightReport> {
170
271
  const run = findRun(i.run);
171
272
  const profile = deps.profiles.forRepo(run.meta.repo);
172
- const lanes = laneChecks(run);
273
+ // plan 23: one milestone's lanes, or by default every lane whose milestone has not landed
274
+ const landed = new Set(landedMilestones(run));
275
+ const milestoneOf = (lane: string) => lane.split(".")[0] as string;
276
+ const lanes = laneChecks(run).filter((l) =>
277
+ i.milestone ? milestoneOf(l.lane) === i.milestone : !landed.has(milestoneOf(l.lane)),
278
+ );
173
279
  // spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
174
280
  const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
175
281
  if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
@@ -177,6 +283,8 @@ export async function preflight(
177
283
  if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
178
284
  const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
179
285
  const results: PreflightResult[] = [];
286
+ // the login env is captured once per server; the gate env is read once per preflight
287
+ let env: Record<string, string> | undefined;
180
288
  for (const l of lanes) {
181
289
  const check = l.check;
182
290
  if (!check) {
@@ -202,20 +310,46 @@ export async function preflight(
202
310
  results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
203
311
  continue;
204
312
  }
205
- const r = await withHeavySlot(heavySlots(profile.heavy), () => runCheck(run.meta.repo, check, timeoutMs));
313
+ env ??= await checkEnvFor(run.meta.repo);
314
+ const checkEnvironment = env;
315
+ // plan 23: each check waits for its slot within its own budget, and says lock-busy past it
316
+ const slot = await withHeavySlotWithin(heavySlots(profile.heavy), preflightLimits.lockWaitMs, () =>
317
+ runCheck(run.meta.repo, check, timeoutMs, checkEnvironment),
318
+ );
319
+ if (slot.busy) {
320
+ const note = `every heavy slot stayed busy for ${preflightLimits.lockWaitMs / 1000} s (other lanes' checks); run preflight again when they finish`;
321
+ results.push({ lane: l.lane, check, outcome: "lock-busy", exitCode: null, tail: [], note });
322
+ continue;
323
+ }
324
+ const r = slot.value;
325
+ // spec 1.5: an absence check whose every hit is one of the lane's Allow: exceptions has passed
326
+ const classified = classify(r, check);
327
+ const allowed = classified === "fails-as-expected" && onlyAllowedHits(r.lines, l.allow);
328
+ const outcome = allowed ? "pass" : classified;
206
329
  results.push({
207
330
  lane: l.lane,
208
331
  check,
209
- outcome: classify(r),
332
+ outcome,
210
333
  exitCode: r.code,
211
334
  tail: r.tail,
212
- note: r.timedOut ? `timed out after ${timeoutMs / 1000} s` : null,
335
+ note: allowed ? `every hit is an Allow: exception (${r.lines.length})` : noteOf(r, outcome, timeoutMs),
213
336
  });
214
337
  }
338
+ // plan 23: lint reaches the worker only through its fast check
339
+ const linter = repoLinter(run.meta.repo);
340
+ const warnings = linter
341
+ ? lanes
342
+ .filter((l) => l.check && !LINT_STEP.test(l.check))
343
+ .map(
344
+ (l) =>
345
+ `${l.lane}: its fast check runs no linter, and the repo has one (${linter}): add the linter of every package the lane touches to its Fast check: line`,
346
+ )
347
+ : [];
215
348
  return {
216
349
  needsConfirmation: false,
217
350
  commands,
218
351
  results,
219
352
  blocked: results.some((r) => r.outcome === "cannot-start"),
353
+ warnings,
220
354
  };
221
355
  }
@@ -1,3 +1,5 @@
1
+ import type { HostContext, OrchestrationHost } from "../domain/host.ts";
2
+ import { agentFiles } from "../domain/agents.ts";
1
3
  import { existsSync, readFileSync, rmSync } from "node:fs";
2
4
  import { join } from "node:path";
3
5
  import { isDeepStrictEqual } from "node:util";
@@ -9,18 +11,19 @@ import {
9
11
  assertProfileName,
10
12
  type Change,
11
13
  defaultProfileDoc,
14
+ hostDefaultsDoc,
12
15
  diffProfiles,
13
16
  type Profile,
14
17
  type ProfileDoc,
15
18
  type ProfilePatch,
16
19
  resolveProfile,
17
20
  } from "../domain/profile.ts";
18
- import { repairs, type Validation } from "../domain/profile-rules.ts";
21
+ import { nativeClaudeIssue, repairs, type Validation } from "../domain/profile-rules.ts";
19
22
  import { ROLES } from "../domain/roles.ts";
20
23
  import { withFileLockSync } from "../infra/filelock.ts";
21
24
  import { configDir } from "../infra/paths.ts";
22
25
  import { ensurePrivateDir, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
23
- import { apply, plan, type Synced } from "./agent-links.ts";
26
+ import { apply, plan, pruneProfileAgents, type Synced } from "./agent-links.ts";
24
27
  import { loadCatalog } from "./catalog-service.ts";
25
28
  import type { ProfilePort, ProfileSaved, ProfileView } from "./ports.ts";
26
29
  import {
@@ -54,11 +57,18 @@ const locked = withProfilesLock;
54
57
  const writeDoc = (name: string, doc: ProfileDoc) => writeJsonAtomic(profileFile(name), { ...doc, name });
55
58
 
56
59
  /** Writes `doc` as profile `name` and relinks; when the relink refuses, puts the profile back as it was. */
57
- function saveAndLink(name: string, doc: ProfileDoc): Synced {
60
+ /** `repairing`: a saved repair with errors still open, whose remaining native roles are among those errors. */
61
+ function saveAndLink(name: string, doc: ProfileDoc, host: OrchestrationHost, repairing = false): Synced {
58
62
  const before = existsSync(profileFile(name)) ? readFileSync(profileFile(name), "utf8") : null;
63
+ const nativeOn = (h: OrchestrationHost) => agentFiles(getProfile(name, h), "").length > 0;
64
+ const hadNative = before !== null && nativeOn(host);
65
+ // omitted defaults are native only on claude-code: the artifacts that resolution made count too
66
+ const hadClaudeNative = before !== null && host !== "claude-code" && nativeOn("claude-code");
59
67
  writeDoc(name, doc);
60
68
  try {
61
- return apply(plan([name]));
69
+ // a profile left with no native role on any host owns no agents; one still native there keeps them
70
+ const allowPrune = host === "claude-code" || hadNative || (hadClaudeNative && !nativeOn("claude-code"));
71
+ return syncFor(name, host, [name], allowPrune, repairing);
62
72
  } catch (e) {
63
73
  if (before === null) rmSync(profileFile(name), { force: true });
64
74
  else writeTextAtomic(profileFile(name), before);
@@ -66,8 +76,34 @@ function saveAndLink(name: string, doc: ProfileDoc): Synced {
66
76
  }
67
77
  }
68
78
 
69
- const validate = (doc: ProfileDoc, name: string): Validation =>
70
- validateHere(resolveProfile(doc, name), loadCatalog({ timings: false }), doc);
79
+ const validate = (doc: ProfileDoc, name: string, host: OrchestrationHost): Validation => {
80
+ try {
81
+ return validateHere(resolveProfile(doc, name, host), loadCatalog({ timings: false }), doc, host);
82
+ } catch (e) {
83
+ if (!(e instanceof CatherdError)) throw e;
84
+ return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
85
+ }
86
+ };
87
+ function nativeAgents(name: string, host: OrchestrationHost) {
88
+ const files = agentFiles(getProfile(name, host), "");
89
+ const first = files[0];
90
+ const issue = first && nativeClaudeIssue(first.rung, host, `roles.${first.role}.rungs`);
91
+ if (issue) throw new CatherdError("E_CONFIG_INVALID", issue.message, { fix: issue.fix });
92
+ return files;
93
+ }
94
+ function syncFor(
95
+ name: string,
96
+ host: OrchestrationHost,
97
+ extra: string[] = [],
98
+ allowPrune = host === "claude-code",
99
+ repairing = false,
100
+ ): Synced {
101
+ // a partial repair keeps its open native-role errors: link and prune nothing until they are fixed
102
+ if (repairing && agentFiles(getProfile(name, host), "").some((f) => nativeClaudeIssue(f.rung, host, "")))
103
+ return { linked: [], pruned: [], newSessionNeededFor: [] };
104
+ if (nativeAgents(name, host).length) return apply(plan(host, extra));
105
+ return allowPrune ? pruneProfileAgents(name) : { linked: [], pruned: [], newSessionNeededFor: [] };
106
+ }
71
107
 
72
108
  /** What a save returns: the port's ProfileSaved (one type for the CLI, the TUI and the MCP tools). */
73
109
  export type Saved = ProfileSaved;
@@ -95,7 +131,7 @@ export const CHANGED_ON_DISK = "profile";
95
131
  export function patchProfile(
96
132
  name: string | undefined,
97
133
  patch: ProfilePatch,
98
- opts: { expect?: ProfileDoc } = {},
134
+ opts: { host: OrchestrationHost; expect?: ProfileDoc },
99
135
  ): Saved {
100
136
  return locked(() => {
101
137
  const n = assertProfileName(name ?? activeName());
@@ -111,17 +147,18 @@ export function patchProfile(
111
147
  ],
112
148
  warnings: [],
113
149
  });
150
+ const host = opts.host;
114
151
  const after = applyPatch(before, patch);
115
- const resolved = resolveProfile(after, n);
116
- const v = validate(after, n);
117
- if (v.errors.length && !repairs(profileExists(n) ? validate(before, n) : null, v)) return unsaved(v);
118
- const diff = diffProfiles(resolveProfile(before, n), resolved);
119
- return { saved: true, ...v, diff, ...saveAndLink(n, after) };
152
+ const v = validate(after, n, host);
153
+ if (v.errors.length && !repairs(profileExists(n) ? validate(before, n, host) : null, v))
154
+ return unsaved(v);
155
+ const diff = diffProfiles(resolveProfile(before, n, host), resolveProfile(after, n, host));
156
+ return { saved: true, ...v, diff, ...saveAndLink(n, after, host, v.errors.length > 0) };
120
157
  });
121
158
  }
122
159
 
123
160
  /** Spec §7.3 `create` and `copy`: a new profile from `from` (default: the default profile). */
124
- export function createProfile(name: string, from?: string): Saved {
161
+ export function createProfile(name: string, from: string | undefined, host: OrchestrationHost): Saved {
125
162
  return locked(() => {
126
163
  // `default` exists without a file: resetProfile is the only way to write it
127
164
  if (profileExists(assertProfileName(name)))
@@ -133,20 +170,20 @@ export function createProfile(name: string, from?: string): Saved {
133
170
  fix: "catherd profile list",
134
171
  });
135
172
  const doc = from === undefined ? defaultProfileDoc(name) : readProfileDoc(from);
136
- const v = validate(doc, name);
173
+ const v = validate(doc, name, host);
137
174
  if (v.errors.length) return unsaved(v);
138
- return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
175
+ return { saved: true, ...v, diff: [], ...saveAndLink(name, doc, host) };
139
176
  });
140
177
  }
141
178
 
142
179
  /** Writes the default profile under `name`, replacing what is there (`catherd init` when asked to). */
143
- export function resetProfile(name: string): Saved {
180
+ export function resetProfile(name: string, host: OrchestrationHost): Saved {
144
181
  return locked(() => {
145
182
  assertProfileName(name);
146
183
  const doc = defaultProfileDoc(name);
147
- const v = validate(doc, name);
184
+ const v = validate(doc, name, host);
148
185
  if (v.errors.length) return unsaved(v);
149
- return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
186
+ return { saved: true, ...v, diff: [], ...saveAndLink(name, doc, host) };
150
187
  });
151
188
  }
152
189
 
@@ -181,7 +218,7 @@ export function deleteProfile(name: string): Synced {
181
218
  bindings: Object.fromEntries(Object.entries(projects.bindings).filter(([r]) => !gone.has(r))),
182
219
  });
183
220
  try {
184
- return apply(plan(), [name]);
221
+ return pruneProfileAgents(name);
185
222
  } catch (e) {
186
223
  writeTextAtomic(profileFile(name), text);
187
224
  if (gone.size) writeJsonAtomic(projectsFile(), projects);
@@ -193,7 +230,8 @@ export function deleteProfile(name: string): Synced {
193
230
  /** Spec §7.3 `activate(name, repo?)`: the active profile, or the one bound to a repo; relinks the agents. */
194
231
  export function activate(
195
232
  name: string,
196
- repo: string | null = null,
233
+ repo: string | null,
234
+ host: OrchestrationHost,
197
235
  ): Synced & { active: string; repo: string | null } {
198
236
  return locked(() => {
199
237
  assertProfileName(name);
@@ -201,6 +239,7 @@ export function activate(
201
239
  throw new CatherdError("E_INPUT_INVALID", `no profile named "${name}"`, {
202
240
  fix: "catherd profile list",
203
241
  });
242
+ nativeAgents(name, host);
204
243
  const config = readConfig();
205
244
  const projects = readProjects();
206
245
  const write = () =>
@@ -213,7 +252,7 @@ export function activate(
213
252
  });
214
253
  write();
215
254
  try {
216
- return { active: name, repo, ...apply(plan([name])) };
255
+ return { active: name, repo, ...syncFor(name, host, [name]) };
217
256
  } catch (e) {
218
257
  if (repo === null) writeJsonAtomic(configFile(), config);
219
258
  else writeJsonAtomic(projectsFile(), projects);
@@ -223,7 +262,7 @@ export function activate(
223
262
  }
224
263
 
225
264
  /** `catherd profile use --repo --clear`: removes `repo`'s binding, so it runs on the active profile again. */
226
- export function unbind(repo: string): Synced & { repo: string; was: string } {
265
+ export function unbind(repo: string, host: OrchestrationHost): Synced & { repo: string; was: string } {
227
266
  return locked(() => {
228
267
  const projects = readProjects();
229
268
  const was = projects.bindings[repo];
@@ -235,7 +274,11 @@ export function unbind(repo: string): Synced & { repo: string; was: string } {
235
274
  delete bindings[repo];
236
275
  writeJsonAtomic(projectsFile(), { ...projects, schema: 1, bindings });
237
276
  try {
238
- return { repo, was, ...apply(plan()) };
277
+ // a binding to a deleted profile is what doctor sends here to clear: there is nothing of it to relink
278
+ const synced = profileExists(was)
279
+ ? syncFor(was, host)
280
+ : { linked: [], pruned: [], newSessionNeededFor: [] };
281
+ return { repo, was, ...synced };
239
282
  } catch (e) {
240
283
  writeJsonAtomic(projectsFile(), projects);
241
284
  throw e;
@@ -243,8 +286,8 @@ export function unbind(repo: string): Synced & { repo: string; was: string } {
243
286
  });
244
287
  }
245
288
 
246
- export function diffNamed(a: string, b: string): Change[] {
247
- return diffProfiles(getProfile(a), getProfile(b));
289
+ export function diffNamed(a: string, b: string, host: OrchestrationHost): Change[] {
290
+ return diffProfiles(getProfile(a, host), getProfile(b, host));
248
291
  }
249
292
 
250
293
  function viewOf(p: Profile): ProfileView {
@@ -267,13 +310,16 @@ function viewOf(p: Profile): ProfileView {
267
310
  }
268
311
 
269
312
  /** The ProfilePort the run engine and the MCP tools use (spec §7.3: the single writer). */
270
- export function profileService(): ProfilePort {
313
+ export function profileService(host: () => HostContext): ProfilePort {
271
314
  // without a name, each tool acts on the profile `repo` runs on (the active one outside a repo)
272
315
  return {
273
- forRepo: (repo) => viewOf(profileFor(repo)),
316
+ withHost: profileService,
317
+ raw: (name, repo = null) => readProfileDoc(name ?? activeName(repo)),
318
+ budgetFor: (repo) => readProfileDoc(activeName(repo)).budget ?? {},
319
+ forRepo: (repo) => viewOf(profileFor(repo, host().host)),
274
320
  get(name, repo = null) {
275
321
  const here = activeName(repo);
276
- const p = getProfile(name === undefined ? here : requireProfile(name));
322
+ const p = getProfile(name === undefined ? here : requireProfile(name), host().host);
277
323
  return {
278
324
  active: activeName(),
279
325
  here,
@@ -283,12 +329,53 @@ export function profileService(): ProfilePort {
283
329
  };
284
330
  },
285
331
  validate(name, repo = null) {
286
- const v = validateNamed(name === undefined ? activeName(repo) : requireProfile(name), repo);
332
+ const v = validateNamed(
333
+ name === undefined ? activeName(repo) : requireProfile(name),
334
+ repo,
335
+ host().host,
336
+ { reach: true },
337
+ );
287
338
  return { valid: v.errors.length === 0, ...v };
288
339
  },
289
340
  // a name that does not exist yet starts from the default profile
290
- set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch),
341
+ set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch, { host: host().host }),
291
342
  agentFor: (repo, role, rung) =>
292
343
  parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
293
344
  };
294
345
  }
346
+
347
+ export function resetHostDefaults(
348
+ name: string,
349
+ host: OrchestrationHost,
350
+ opts:
351
+ | { preview: true; expect?: ProfileDoc }
352
+ | { preview: false; expect: ProfileDoc; reviewed: { profile: string; host: OrchestrationHost } },
353
+ ): Saved & { expect: ProfileDoc; profile: string; host: OrchestrationHost } {
354
+ return locked(() => {
355
+ const refuse = (message: string, expect: ProfileDoc) => ({
356
+ ...unsaved({
357
+ errors: [{ path: CHANGED_ON_DISK, message, fix: "preview again and review the changes" }],
358
+ warnings: [],
359
+ }),
360
+ expect,
361
+ profile: name,
362
+ host,
363
+ });
364
+ // the reset is host-specific: a preview approves one profile on one host, never another
365
+ if (!opts.preview && (opts.reviewed.profile !== name || opts.reviewed.host !== host))
366
+ return refuse(
367
+ `the preview was for profile "${opts.reviewed.profile}" on host ${opts.reviewed.host}, not "${name}" on ${host}`,
368
+ opts.expect,
369
+ );
370
+ const before = readProfileDoc(name);
371
+ if (!opts.preview && !isDeepStrictEqual(before, opts.expect))
372
+ return refuse(`profile "${name}" changed on disk since it was shown`, before);
373
+ const after = hostDefaultsDoc(before);
374
+ const v = validate(after, name, host);
375
+ if (v.errors.length) return { ...unsaved(v), expect: before, profile: name, host };
376
+ const diff = diffProfiles(resolveProfile(before, name, host), resolveProfile(after, name, host));
377
+ return opts.preview
378
+ ? { ...unsaved(v), diff, expect: before, profile: name, host }
379
+ : { saved: true, ...v, diff, ...saveAndLink(name, after, host), expect: before, profile: name, host };
380
+ });
381
+ }
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "../domain/host.ts";
1
2
  import { existsSync, readdirSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { z } from "zod";
@@ -14,7 +15,12 @@ import {
14
15
  ProfileDocSchema,
15
16
  resolveProfile,
16
17
  } from "../domain/profile.ts";
17
- import { type Issue, type Validation, validateProfile } from "../domain/profile-rules.ts";
18
+ import {
19
+ type Issue,
20
+ type ValidateOptions,
21
+ type Validation,
22
+ validateProfile,
23
+ } from "../domain/profile-rules.ts";
18
24
  import type { Catalog } from "../domain/catalog.ts";
19
25
  import type { Access } from "../domain/record.ts";
20
26
  import { ROLES, type Role } from "../domain/roles.ts";
@@ -94,7 +100,8 @@ export function readProfileDoc(name: string): ProfileDoc {
94
100
  throw new CatherdError("E_CONFIG_INVALID", `no profile named "${name}"`, { fix: "catherd profile list" });
95
101
  }
96
102
 
97
- export const getProfile = (name: string): Profile => resolveProfile(readProfileDoc(name), name);
103
+ export const getProfile = (name: string, host: OrchestrationHost): Profile =>
104
+ resolveProfile(readProfileDoc(name), name, host);
98
105
 
99
106
  /** A profile name the user typed: refused as bad input (exit 2) when no such profile exists. */
100
107
  export function requireProfile(name: string): string {
@@ -109,7 +116,8 @@ export function activeName(repo: string | null = null): string {
109
116
  return bound ?? readConfig().activeProfile ?? "default";
110
117
  }
111
118
 
112
- export const profileFor = (repo: string | null): Profile => getProfile(activeName(repo));
119
+ export const profileFor = (repo: string | null, host: OrchestrationHost): Profile =>
120
+ getProfile(activeName(repo), host);
113
121
 
114
122
  /** The backends catherd can run: every registered adapter, and the native `claude` path. */
115
123
  export const runnableBackends = (): string[] => ["claude", ...ADAPTER_IDS.filter((id) => adapterFor(id))];
@@ -212,8 +220,14 @@ export function budgetUsdWarnings(p: Profile): Issue[] {
212
220
  * Spec §7.1 validation on this machine: the backends catherd can run here, the keys isolation needs, the
213
221
  * accesses a backend holds only when isolated, and the backends a dollar budget cannot see.
214
222
  */
215
- export function validateHere(p: Profile, c: Catalog, doc?: ProfileDoc): Validation {
216
- const v = validateProfile(p, c, runnableBackends(), doc);
223
+ export function validateHere(
224
+ p: Profile,
225
+ c: Catalog,
226
+ doc?: ProfileDoc,
227
+ host: OrchestrationHost = "unknown",
228
+ o: ValidateOptions = {},
229
+ ): Validation {
230
+ const v = validateProfile(p, c, runnableBackends(), doc, host, o);
217
231
  return {
218
232
  errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
219
233
  warnings: [...v.warnings, ...budgetUsdWarnings(p)],
@@ -221,11 +235,21 @@ export function validateHere(p: Profile, c: Catalog, doc?: ProfileDoc): Validati
221
235
  }
222
236
 
223
237
  /** `repo`: the git toplevel whose listing route reads (opencode lists its models per repository). */
224
- export function validateNamed(name?: string, repo: string | null = null): Validation {
238
+ export function validateNamed(
239
+ name: string | undefined,
240
+ repo: string | null,
241
+ host: OrchestrationHost,
242
+ o: ValidateOptions = {},
243
+ ): Validation {
225
244
  const n = name ?? activeName(repo);
226
245
  const doc = readProfileDoc(n);
227
246
  const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
228
- return validateHere(resolveProfile(doc, n), catalog, doc);
247
+ try {
248
+ return validateHere(resolveProfile(doc, n, host), catalog, doc, host, o);
249
+ } catch (e) {
250
+ if (!(e instanceof CatherdError)) throw e;
251
+ return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
252
+ }
229
253
  }
230
254
 
231
255
  /** Spec D10: how strongly the backend holds a role to its access mode. */