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.
- package/README.md +146 -36
- package/catalog/models.json +148 -5
- package/catalog/scores.json +24 -0
- package/catalog/sources.json +12 -2
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +45 -39
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +61 -18
- package/src/adapters/all.ts +6 -0
- package/src/adapters/antigravity/events.ts +131 -0
- package/src/adapters/antigravity/home.ts +46 -0
- package/src/adapters/antigravity/index.ts +320 -0
- package/src/adapters/antigravity/models.ts +37 -0
- package/src/adapters/backend.ts +34 -1
- package/src/adapters/claude-code/index.ts +2 -0
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/index.ts +2 -0
- package/src/adapters/cursor/events.ts +120 -0
- package/src/adapters/cursor/home.ts +79 -0
- package/src/adapters/cursor/index.ts +331 -0
- package/src/adapters/cursor/models.ts +30 -0
- package/src/adapters/discovery.ts +21 -0
- package/src/adapters/grok/events.ts +112 -0
- package/src/adapters/grok/home.ts +132 -0
- package/src/adapters/grok/index.ts +339 -0
- package/src/adapters/grok/models.ts +48 -0
- package/src/adapters/opencode/index.ts +2 -0
- package/src/cli.ts +49 -19
- package/src/domain/catalog.ts +10 -1
- package/src/domain/cost.ts +1 -0
- package/src/domain/errors.ts +1 -0
- package/src/domain/failover.ts +5 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/ids.ts +2 -2
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +89 -5
- package/src/domain/record.ts +3 -0
- package/src/domain/util.ts +3 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/knowledge-command.ts +50 -0
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +30 -10
- package/src/entry/tui/fixtures.ts +34 -10
- package/src/entry/tui/profile-tree.ts +10 -1
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +16 -3
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/paths.ts +3 -1
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +34 -7
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +49 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +8 -1
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +39 -7
- package/src/services/doctor-checks.ts +43 -3
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +107 -32
- package/src/services/finalize.ts +2 -1
- package/src/services/lane-service.ts +4 -10
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +117 -31
- package/src/services/profile-store.ts +126 -5
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +39 -4
- package/src/services/run-store.ts +21 -5
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +32 -7
- package/src/services/summary.ts +40 -5
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const diff = diffProfiles(resolveProfile(before, n),
|
|
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
|
|
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
|
|
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
|
|
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, ...
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =>
|
|
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 =>
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
/**
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
}
|