catherd-cli 1.2.1 → 1.4.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 (104) hide show
  1. package/README.md +146 -36
  2. package/catalog/models.json +148 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +12 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +11 -0
  8. package/plugin/.mcp-codex.json +10 -0
  9. package/plugin/bin/catherd-mcp +1 -1
  10. package/plugin/skills/catherd/SKILL.md +45 -39
  11. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  12. package/src/adapters/access.ts +61 -18
  13. package/src/adapters/all.ts +6 -0
  14. package/src/adapters/antigravity/events.ts +131 -0
  15. package/src/adapters/antigravity/home.ts +46 -0
  16. package/src/adapters/antigravity/index.ts +320 -0
  17. package/src/adapters/antigravity/models.ts +37 -0
  18. package/src/adapters/backend.ts +34 -1
  19. package/src/adapters/claude-code/index.ts +2 -0
  20. package/src/adapters/cli.ts +1 -64
  21. package/src/adapters/codex/index.ts +2 -0
  22. package/src/adapters/cursor/events.ts +120 -0
  23. package/src/adapters/cursor/home.ts +79 -0
  24. package/src/adapters/cursor/index.ts +331 -0
  25. package/src/adapters/cursor/models.ts +30 -0
  26. package/src/adapters/discovery.ts +21 -0
  27. package/src/adapters/grok/events.ts +112 -0
  28. package/src/adapters/grok/home.ts +132 -0
  29. package/src/adapters/grok/index.ts +339 -0
  30. package/src/adapters/grok/models.ts +48 -0
  31. package/src/adapters/opencode/index.ts +2 -0
  32. package/src/cli.ts +49 -19
  33. package/src/domain/catalog.ts +10 -1
  34. package/src/domain/cost.ts +1 -0
  35. package/src/domain/errors.ts +1 -0
  36. package/src/domain/failover.ts +5 -2
  37. package/src/domain/host.ts +17 -0
  38. package/src/domain/ids.ts +2 -2
  39. package/src/domain/notice.ts +5 -2
  40. package/src/domain/profile-rules.ts +17 -0
  41. package/src/domain/profile.ts +89 -5
  42. package/src/domain/record.ts +3 -0
  43. package/src/domain/util.ts +3 -0
  44. package/src/entry/catalog-command.ts +4 -2
  45. package/src/entry/deps.ts +6 -3
  46. package/src/entry/doctor-command.ts +11 -2
  47. package/src/entry/host-arg.ts +28 -0
  48. package/src/entry/init-command.ts +18 -7
  49. package/src/entry/knowledge-command.ts +50 -0
  50. package/src/entry/lock-command.ts +7 -2
  51. package/src/entry/mcp/handshake.ts +3 -1
  52. package/src/entry/mcp/run-tools.ts +5 -2
  53. package/src/entry/mcp/server.ts +129 -40
  54. package/src/entry/mcp/setup-tools.ts +7 -2
  55. package/src/entry/profile-command.ts +108 -12
  56. package/src/entry/runs-command.ts +81 -5
  57. package/src/entry/tui/effects.ts +30 -10
  58. package/src/entry/tui/fixtures.ts +34 -10
  59. package/src/entry/tui/profile-tree.ts +10 -1
  60. package/src/entry/tui/run.tsx +10 -4
  61. package/src/entry/tui/state.ts +19 -6
  62. package/src/entry/tui/stories.ts +11 -4
  63. package/src/entry/tui/views/profile-actions.ts +4 -2
  64. package/src/entry/tui/views/profiles.tsx +16 -3
  65. package/src/entry/tui/views/save-dialog.tsx +2 -2
  66. package/src/infra/cli.ts +65 -0
  67. package/src/infra/codex-queue.ts +165 -0
  68. package/src/infra/delivery.ts +83 -0
  69. package/src/infra/dispatch-dir.ts +1 -0
  70. package/src/infra/env.ts +4 -1
  71. package/src/infra/host-context.ts +58 -0
  72. package/src/infra/paths.ts +3 -1
  73. package/src/infra/peer-inbox.ts +4 -1
  74. package/src/infra/supervisor.ts +3 -2
  75. package/src/services/admission.ts +34 -7
  76. package/src/services/agent-links.ts +30 -5
  77. package/src/services/backends.ts +49 -4
  78. package/src/services/capture.ts +112 -54
  79. package/src/services/catalog-refresh.ts +2 -1
  80. package/src/services/dispatch-service.ts +17 -7
  81. package/src/services/dispatches.ts +8 -1
  82. package/src/services/doctor-access.ts +14 -7
  83. package/src/services/doctor-backends.ts +39 -7
  84. package/src/services/doctor-checks.ts +43 -3
  85. package/src/services/doctor-push.ts +69 -90
  86. package/src/services/doctor.ts +107 -32
  87. package/src/services/finalize.ts +2 -1
  88. package/src/services/lane-service.ts +4 -10
  89. package/src/services/notifier.ts +322 -70
  90. package/src/services/peek.ts +46 -9
  91. package/src/services/ports.ts +8 -2
  92. package/src/services/profile-service.ts +117 -31
  93. package/src/services/profile-store.ts +126 -5
  94. package/src/services/routing-service.ts +6 -1
  95. package/src/services/run-debug.ts +98 -4
  96. package/src/services/run-service.ts +39 -4
  97. package/src/services/run-store.ts +21 -5
  98. package/src/services/runs-page.ts +3 -2
  99. package/src/services/session-view.ts +18 -13
  100. package/src/services/sessions.ts +32 -16
  101. package/src/services/setup.ts +31 -10
  102. package/src/services/source-derive.ts +32 -7
  103. package/src/services/summary.ts +40 -5
  104. package/src/services/treat-likes.ts +22 -9
@@ -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, validateProfile } 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 {
@@ -37,7 +40,7 @@ import {
37
40
  readProjects,
38
41
  requireProfile,
39
42
  roleEnforcement,
40
- runnableBackends,
43
+ validateHere,
41
44
  validateNamed,
42
45
  } from "./profile-store.ts";
43
46
 
@@ -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
- validateProfile(resolveProfile(doc, name), loadCatalog({ timings: false }), runnableBackends(), 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,52 @@ 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
+ );
287
337
  return { valid: v.errors.length === 0, ...v };
288
338
  },
289
339
  // a name that does not exist yet starts from the default profile
290
- set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch),
340
+ set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch, { host: host().host }),
291
341
  agentFor: (repo, role, rung) =>
292
342
  parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
293
343
  };
294
344
  }
345
+
346
+ export function resetHostDefaults(
347
+ name: string,
348
+ host: OrchestrationHost,
349
+ opts:
350
+ | { preview: true; expect?: ProfileDoc }
351
+ | { preview: false; expect: ProfileDoc; reviewed: { profile: string; host: OrchestrationHost } },
352
+ ): Saved & { expect: ProfileDoc; profile: string; host: OrchestrationHost } {
353
+ return locked(() => {
354
+ const refuse = (message: string, expect: ProfileDoc) => ({
355
+ ...unsaved({
356
+ errors: [{ path: CHANGED_ON_DISK, message, fix: "preview again and review the changes" }],
357
+ warnings: [],
358
+ }),
359
+ expect,
360
+ profile: name,
361
+ host,
362
+ });
363
+ // the reset is host-specific: a preview approves one profile on one host, never another
364
+ if (!opts.preview && (opts.reviewed.profile !== name || opts.reviewed.host !== host))
365
+ return refuse(
366
+ `the preview was for profile "${opts.reviewed.profile}" on host ${opts.reviewed.host}, not "${name}" on ${host}`,
367
+ opts.expect,
368
+ );
369
+ const before = readProfileDoc(name);
370
+ if (!opts.preview && !isDeepStrictEqual(before, opts.expect))
371
+ return refuse(`profile "${name}" changed on disk since it was shown`, before);
372
+ const after = hostDefaultsDoc(before);
373
+ const v = validate(after, name, host);
374
+ if (v.errors.length) return { ...unsaved(v), expect: before, profile: name, host };
375
+ const diff = diffProfiles(resolveProfile(before, name, host), resolveProfile(after, name, host));
376
+ return opts.preview
377
+ ? { ...unsaved(v), diff, expect: before, profile: name, host }
378
+ : { saved: true, ...v, diff, ...saveAndLink(name, after, host), expect: before, profile: name, host };
379
+ });
380
+ }
@@ -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,11 +15,13 @@ import {
14
15
  ProfileDocSchema,
15
16
  resolveProfile,
16
17
  } from "../domain/profile.ts";
17
- import { type Validation, validateProfile } from "../domain/profile-rules.ts";
18
+ import { type Issue, type Validation, validateProfile } from "../domain/profile-rules.ts";
19
+ import type { Catalog } from "../domain/catalog.ts";
18
20
  import type { Access } from "../domain/record.ts";
19
21
  import { ROLES, type Role } from "../domain/roles.ts";
20
22
  import { configDir } from "../infra/paths.ts";
21
23
  import { readJsonFile } from "../infra/store.ts";
24
+ import { standInFor } from "./backends.ts";
22
25
  import { backendOfKey, loadCatalog } from "./catalog-service.ts";
23
26
 
24
27
  // Spec §7.3, the read side of the profile service: where profiles, config.json and projects.json live,
@@ -92,7 +95,8 @@ export function readProfileDoc(name: string): ProfileDoc {
92
95
  throw new CatherdError("E_CONFIG_INVALID", `no profile named "${name}"`, { fix: "catherd profile list" });
93
96
  }
94
97
 
95
- export const getProfile = (name: string): Profile => resolveProfile(readProfileDoc(name), name);
98
+ export const getProfile = (name: string, host: OrchestrationHost): Profile =>
99
+ resolveProfile(readProfileDoc(name), name, host);
96
100
 
97
101
  /** A profile name the user typed: refused as bad input (exit 2) when no such profile exists. */
98
102
  export function requireProfile(name: string): string {
@@ -107,7 +111,8 @@ export function activeName(repo: string | null = null): string {
107
111
  return bound ?? readConfig().activeProfile ?? "default";
108
112
  }
109
113
 
110
- export const profileFor = (repo: string | null): Profile => getProfile(activeName(repo));
114
+ export const profileFor = (repo: string | null, host: OrchestrationHost): Profile =>
115
+ getProfile(activeName(repo), host);
111
116
 
112
117
  /** The backends catherd can run: every registered adapter, and the native `claude` path. */
113
118
  export const runnableBackends = (): string[] => ["claude", ...ADAPTER_IDS.filter((id) => adapterFor(id))];
@@ -116,12 +121,128 @@ export const runnableBackends = (): string[] => ["claude", ...ADAPTER_IDS.filter
116
121
  export const keyRunnable = (key: string, backends: string[] = runnableBackends()): boolean =>
117
122
  backends.includes(backendOfKey(key));
118
123
 
124
+ /**
125
+ * Spec 1.3 §8: each backend `p` isolates whose isolated run logs in with an API key that is not in `env` (the
126
+ * environment catherd runs, and starts workers, in).
127
+ */
128
+ export function isolationKeyErrors(
129
+ p: Profile,
130
+ env: Record<string, string | undefined> = process.env,
131
+ ): Issue[] {
132
+ const out: Issue[] = [];
133
+ for (const [id, h] of Object.entries(p.harness)) {
134
+ const key = h.isolated ? adapterFor(id)?.isolationKey : undefined;
135
+ if (key && !env[key])
136
+ out.push({
137
+ path: `harness.${id}.isolated`,
138
+ message: `an isolated ${id} run needs ${key}, which catherd's environment does not have`,
139
+ fix: `export ${key}=<key>, or catherd profile set harness.${id}.isolated false`,
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Spec 1.3 §9 Q2: each enabled role whose rung, or a failover stand-in of one, runs natively on a backend that
147
+ * holds the role's access only when isolated (agy has no read-only flag).
148
+ */
149
+ export function isolatedOnlyErrors(p: Profile): Issue[] {
150
+ const out: Issue[] = [];
151
+ for (const role of ROLES) {
152
+ const rc = p.roles[role];
153
+ if (!rc.enabled) continue;
154
+ // a stand-in the profile does not name is the one dispatch picks on its own (a paired backend's or the
155
+ // adapter's); one on the rung's own backend adds nothing, since the rung's own error names that backend
156
+ const runs = [
157
+ ...rc.rungs.map((r) => [r, `roles.${role}.rungs`]),
158
+ ...rc.rungs.flatMap((r) => {
159
+ if (p.failover[r]) return [[p.failover[r], `failover.${r}`, null]];
160
+ const s = standInFor(p.failover, r);
161
+ return s && tryParseRung(s)?.backend !== tryParseRung(r)?.backend
162
+ ? [[s, `roles.${role}.rungs`, r]]
163
+ : [];
164
+ }),
165
+ ] as [string, string, string | null][];
166
+ for (const [rung, path, auto] of runs) {
167
+ const b = tryParseRung(rung)?.backend;
168
+ if (!b || p.harness[b]?.isolated || !adapterFor(b)?.isolatedOnly?.includes(rc.access)) continue;
169
+ const isolate = `isolate ${b} (catherd profile set harness.${b}.isolated true)`;
170
+ out.push({
171
+ path,
172
+ message: `${rung}${auto ? `, the automatic stand-in of ${auto}` : ""}: native ${b} cannot hold the ${role} role to ${rc.access}`,
173
+ fix: auto
174
+ ? `${isolate}, or name another stand-in (catherd profile set failover.${auto} <rung>)`
175
+ : `${isolate}, or put this role on another backend`,
176
+ });
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /**
183
+ * Spec §4.6: with `budget.usd` set, one warning per backend an enabled role runs on whose adapter reports no
184
+ * dollar cost, since that cap never sees what the backend spends. Never an error: the cap still counts the rest.
185
+ */
186
+ export function budgetUsdWarnings(p: Profile): Issue[] {
187
+ if (p.budget.usd === undefined) return [];
188
+ const blind = new Map<string, Role[]>();
189
+ const note = (rung: string, role: Role) => {
190
+ const b = tryParseRung(rung)?.backend;
191
+ // no adapter (native `claude`, whose subagents report their own cost): nothing to warn about
192
+ if (!b || adapterFor(b)?.reportsCost !== false) return;
193
+ const roles = blind.get(b) ?? [];
194
+ if (!roles.includes(role)) roles.push(role);
195
+ blind.set(b, roles);
196
+ };
197
+ for (const role of ROLES) {
198
+ const rc = p.roles[role];
199
+ if (!rc.enabled) continue;
200
+ for (const rung of rc.rungs) {
201
+ note(rung, role);
202
+ // a quota failover runs the role on its stand-in (its own, paired or the backend's), which the cap must see
203
+ const standIn = standInFor(p.failover, rung);
204
+ if (standIn) note(standIn, role);
205
+ }
206
+ }
207
+ return [...blind].map(([b, roles]) => ({
208
+ path: "budget.usd",
209
+ message: `budget.usd will not see ${b}'s spend: ${b} reports no dollar cost (${roles.join(", ")} run on it)`,
210
+ fix: "cap it with budget.tokens or budget.minutes: catherd profile set budget.tokens <n>",
211
+ }));
212
+ }
213
+
214
+ /**
215
+ * Spec §7.1 validation on this machine: the backends catherd can run here, the keys isolation needs, the
216
+ * accesses a backend holds only when isolated, and the backends a dollar budget cannot see.
217
+ */
218
+ export function validateHere(
219
+ p: Profile,
220
+ c: Catalog,
221
+ doc?: ProfileDoc,
222
+ host: OrchestrationHost = "unknown",
223
+ ): Validation {
224
+ const v = validateProfile(p, c, runnableBackends(), doc, host);
225
+ return {
226
+ errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
227
+ warnings: [...v.warnings, ...budgetUsdWarnings(p)],
228
+ };
229
+ }
230
+
119
231
  /** `repo`: the git toplevel whose listing route reads (opencode lists its models per repository). */
120
- export function validateNamed(name?: string, repo: string | null = null): Validation {
232
+ export function validateNamed(
233
+ name: string | undefined,
234
+ repo: string | null,
235
+ host: OrchestrationHost,
236
+ ): Validation {
121
237
  const n = name ?? activeName(repo);
122
238
  const doc = readProfileDoc(n);
123
239
  const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
124
- return validateProfile(resolveProfile(doc, n), catalog, runnableBackends(), doc);
240
+ try {
241
+ return validateHere(resolveProfile(doc, n, host), catalog, doc, host);
242
+ } catch (e) {
243
+ if (!(e instanceof CatherdError)) throw e;
244
+ return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
245
+ }
125
246
  }
126
247
 
127
248
  /** Spec D10: how strongly the backend holds a role to its access mode. */
@@ -1,3 +1,4 @@
1
+ import { assertNativeHost } from "./backends.ts";
1
2
  import { BUDGET_CHEAP_AT } from "../domain/budget.ts";
2
3
  import { errorMessage } from "../domain/errors.ts";
3
4
  import {
@@ -171,7 +172,11 @@ async function verdict<T extends string>(
171
172
  /** The routing port over the 1.0 catalog and Jev (spec §5); `o` lets tests inject Jev's transport. */
172
173
  export function routingService(o: RoutingOpts = {}): RoutingPort {
173
174
  return {
174
- route: (req) => route(req, o),
175
+ route: async (req) => {
176
+ const result = await route(req, o);
177
+ assertNativeHost(result.rung, req.host ?? "unknown");
178
+ return result;
179
+ },
175
180
  finding: (runDir, laneText, finding, use) =>
176
181
  verdict(
177
182
  runDir,
@@ -1,15 +1,107 @@
1
- import { closeSync, fstatSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
2
+ import { sessionKey, type HostContext, type HostSessionRef } from "../domain/host.ts";
3
+ import { deliveryState, readDelivery, type DeliveryState } from "../infra/delivery.ts";
2
4
  import type { ExitInfo, RunRecord } from "../domain/record.ts";
3
- import { dispatchPaths, readExit } from "../infra/dispatch-dir.ts";
5
+ import { awaitsCollect, dispatchPaths, readExit } from "../infra/dispatch-dir.ts";
4
6
  import { redact } from "../infra/log.ts";
5
- import { listDispatches } from "./dispatches.ts";
7
+ import { type Dispatch, listDispatches } from "./dispatches.ts";
6
8
  import { registerSavedSecrets } from "./jev-service.ts";
9
+ import { runOwner } from "./sessions.ts";
7
10
  import { readRecords, type Run } from "./run-store.ts";
8
11
 
9
12
  /** How many trailing lines of stderr, events and the supervisor log `runs show --debug` prints. */
10
13
  export const TAIL_LINES = 20;
11
14
 
12
- export interface DispatchDebug {
15
+ export function inspectionHost(host: HostContext): HostContext {
16
+ const target = host.session;
17
+ return {
18
+ host: host.host,
19
+ conflict: host.conflict,
20
+ session: target
21
+ ? {
22
+ host: target.host,
23
+ sessionId: target.sessionId,
24
+ hostSessionId: target.hostSessionId,
25
+ name: target.name,
26
+ }
27
+ : null,
28
+ };
29
+ }
30
+
31
+ export interface DeliveryInspection {
32
+ eventId: string;
33
+ delivery: DeliveryState;
34
+ receipt: { msgId: string; target: HostSessionRef; at: string } | null;
35
+ }
36
+
37
+ export function inspectDelivery(
38
+ run: Run,
39
+ d: Dispatch,
40
+ recorded: boolean,
41
+ eventId = JSON.stringify([
42
+ run.id,
43
+ d.admit.dispatchId,
44
+ !recorded && existsSync(dispatchPaths(d.dir).stall) ? "stalled" : "finished",
45
+ ]),
46
+ ): DeliveryInspection {
47
+ const owner = runOwner(run);
48
+ const target = owner
49
+ ? { host: owner.host, sessionId: owner.sessionId, hostSessionId: null, name: null }
50
+ : null;
51
+ const finished = eventId === JSON.stringify([run.id, d.admit.dispatchId, "finished"]);
52
+ const collected = finished && recorded && !awaitsCollect(d.dir);
53
+ try {
54
+ const receipt = target
55
+ ? readDelivery(d.dir).findLast(
56
+ (a) =>
57
+ a.status === "accepted" &&
58
+ a.eventIds.includes(eventId) &&
59
+ sessionKey(a.target) === sessionKey(target),
60
+ )
61
+ : undefined;
62
+ return {
63
+ eventId,
64
+ delivery: collected
65
+ ? "collected"
66
+ : (recorded || !finished) && target
67
+ ? deliveryState(d.dir, target, eventId)
68
+ : "pending",
69
+ receipt: receipt?.msgId
70
+ ? {
71
+ msgId: receipt.msgId,
72
+ at: receipt.at,
73
+ target: {
74
+ host: receipt.target.host,
75
+ sessionId: receipt.target.sessionId,
76
+ hostSessionId: receipt.target.hostSessionId,
77
+ name: receipt.target.name,
78
+ },
79
+ }
80
+ : null,
81
+ };
82
+ } catch {
83
+ return { eventId, delivery: collected ? "collected" : "ambiguous", receipt: null };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Stored stalled and finished events are separate; only finished records can be collected. A stall the role
89
+ * outlived that is still pending for the current owner can never go out (the notifier drops it and retry
90
+ * refuses it), so it is not shown; one with an outcome for this owner (accepted, ambiguous) still is.
91
+ */
92
+ export function inspectDeliveries(run: Run, d: Dispatch, recorded: boolean): DeliveryInspection[] {
93
+ const exited = existsSync(dispatchPaths(d.dir).exit);
94
+ return [...(recorded ? ["finished"] : []), ...(existsSync(dispatchPaths(d.dir).stall) ? ["stalled"] : [])]
95
+ .map((kind) => ({
96
+ kind,
97
+ ...inspectDelivery(run, d, recorded, JSON.stringify([run.id, d.admit.dispatchId, kind])),
98
+ }))
99
+ .filter((e) => !(e.kind === "stalled" && exited && e.delivery === "pending"))
100
+ .map(({ kind: _kind, ...e }) => e);
101
+ }
102
+
103
+ export interface DispatchDebug extends DeliveryInspection {
104
+ deliveries: DeliveryInspection[];
13
105
  name: string;
14
106
  dispatchId: string;
15
107
  rung: string;
@@ -65,6 +157,8 @@ export function runDebug(run: Run, name?: string): DispatchDebug[] {
65
157
  .map((d) => {
66
158
  const p = dispatchPaths(d.dir);
67
159
  return redact({
160
+ ...inspectDelivery(run, d, records.has(d.admit.dispatchId)),
161
+ deliveries: inspectDeliveries(run, d, records.has(d.admit.dispatchId)),
68
162
  name: d.admit.name,
69
163
  dispatchId: d.admit.dispatchId,
70
164
  rung: d.admit.rung,
@@ -15,9 +15,11 @@ import {
15
15
  recordHints,
16
16
  } from "./dispatches.ts";
17
17
  import type { Deps } from "./ports.ts";
18
+ import { assertNativeHost } from "./backends.ts";
18
19
  import {
19
20
  type AgentRun,
20
21
  appendAgentRun,
22
+ appendKnowledge,
21
23
  createRun,
22
24
  findRun,
23
25
  knowledgeFile,
@@ -166,6 +168,7 @@ export function recordAgentRun(
166
168
  throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is not a native Claude rung`, {
167
169
  fix: "record_agent_run is for Agent subagents; dispatch records every other run itself",
168
170
  });
171
+ assertNativeHost(i.rung, deps.host.conflict ? "unknown" : deps.host.host);
169
172
  const row: AgentRun = {
170
173
  at: new Date(deps.now()).toISOString(),
171
174
  name: i.name,
@@ -182,13 +185,45 @@ export function recordAgentRun(
182
185
  return row;
183
186
  }
184
187
 
185
- /** What past runs of the repo learned; `repo` may be any path inside it. */
186
- export async function readKnowledge(repo: string): Promise<string> {
188
+ /** The git toplevel of `repo`, any path inside it; refused outside a repository. */
189
+ async function repoTop(repo: string): Promise<string> {
187
190
  const top = await gitToplevel(repo);
188
191
  if (!top)
189
192
  throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
190
193
  fix: "pass the path of the repository",
191
194
  });
192
- const file = knowledgeFile(top);
193
- return existsSync(file) ? readFileSync(file, "utf8") : "catherd: no knowledge recorded yet for this repo";
195
+ return top;
196
+ }
197
+
198
+ /** What past runs of the repo learned; `repo` may be any path inside it. */
199
+ export async function readKnowledge(repo: string): Promise<string> {
200
+ const file = knowledgeFile(await repoTop(repo));
201
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
202
+ return text.trim() ? text : "catherd: no knowledge recorded yet for this repo";
203
+ }
204
+
205
+ /** Where the repo's knowledge.md is (it may not exist yet); `repo` may be any path inside it. */
206
+ export async function knowledgePath(repo: string): Promise<{ repo: string; path: string }> {
207
+ const top = await repoTop(repo);
208
+ return { repo: top, path: knowledgeFile(top) };
209
+ }
210
+
211
+ /** The repo's knowledge.md as its lines, none when it is missing or blank; `repo` may be any path inside it. */
212
+ export async function knowledgeLines(repo: string): Promise<{ repo: string; path: string; lines: string[] }> {
213
+ const at = await knowledgePath(repo);
214
+ const text = existsSync(at.path) ? readFileSync(at.path, "utf8") : "";
215
+ return { ...at, lines: text.split("\n").filter((l) => l.trim()) };
216
+ }
217
+
218
+ /** The source a line added by hand carries, where a landing puts its run's title and milestone. */
219
+ const BY_HAND = "by hand";
220
+
221
+ /** Appends one line of the user's own to the repo's knowledge.md, as `land`'s `learned` does; returns it. */
222
+ export async function addKnowledge(repo: string, text: string, now: Date = new Date()): Promise<string> {
223
+ const top = await repoTop(repo);
224
+ if (!text.trim())
225
+ throw new CatherdError("E_INPUT_INVALID", "the knowledge line is empty", {
226
+ fix: 'pass one line of text, e.g. catherd knowledge add "the targeted test is bun test <file>"',
227
+ });
228
+ return appendKnowledge(top, now, BY_HAND, text);
194
229
  }