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.
- package/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- 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 +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- 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 +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- 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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
332
|
+
outcome,
|
|
210
333
|
exitCode: r.code,
|
|
211
334
|
tail: r.tail,
|
|
212
|
-
note:
|
|
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
|
-
|
|
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,53 @@ 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
|
+
{ 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 {
|
|
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 =>
|
|
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 =>
|
|
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(
|
|
216
|
-
|
|
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(
|
|
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
|
-
|
|
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. */
|