catherd-cli 0.2.1 → 1.1.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 +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Catalog,
|
|
3
|
+
capableFor,
|
|
4
|
+
effortOffered,
|
|
5
|
+
ROLE_NEEDS,
|
|
6
|
+
type RungInfo,
|
|
7
|
+
rungInfo,
|
|
8
|
+
scoresOf,
|
|
9
|
+
} from "./catalog.ts";
|
|
10
|
+
import {
|
|
11
|
+
catalogRungs,
|
|
12
|
+
claudeBilled,
|
|
13
|
+
downgradeDims,
|
|
14
|
+
ladderDropDims,
|
|
15
|
+
quotaOf,
|
|
16
|
+
rankStandIns,
|
|
17
|
+
} from "./failover.ts";
|
|
18
|
+
import { tryParseRung } from "./ids.ts";
|
|
19
|
+
import { DIFFICULTIES, KINDS } from "./lane.ts";
|
|
20
|
+
import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
|
|
21
|
+
import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
|
|
22
|
+
import { candidates, clearsBar, type RoutingProfile } from "./select.ts";
|
|
23
|
+
|
|
24
|
+
export { quotaOf };
|
|
25
|
+
|
|
26
|
+
/** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
|
|
27
|
+
export interface Issue {
|
|
28
|
+
path: string;
|
|
29
|
+
message: string;
|
|
30
|
+
fix?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Validation {
|
|
34
|
+
errors: Issue[];
|
|
35
|
+
warnings: Issue[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TREAT_LIKE_FIX = (rung: string) =>
|
|
39
|
+
`map it with: catherd catalog treat-like ${rung} <a scored rung>; catalog_query lists them`;
|
|
40
|
+
|
|
41
|
+
export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
|
|
42
|
+
objective: p.objective,
|
|
43
|
+
billing: p.billing,
|
|
44
|
+
role: p.roles[role],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Whether a rung's scores are catherd's guess: borrowed through a treat-like, or only `inferred` ones. */
|
|
48
|
+
export function inferredScores(c: Catalog, info: RungInfo): { inferred: boolean; via: string | null } {
|
|
49
|
+
const s = scoresOf(c, info.canonical);
|
|
50
|
+
if (!s) return { inferred: false, via: null };
|
|
51
|
+
const records = Object.values(s.records);
|
|
52
|
+
return { inferred: s.via !== null || records.every((r) => r.confidence === "inferred"), via: s.via };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Spec §7.1. Errors: the worker disabled; an enabled role with no usable rung; an unscored rung without a
|
|
57
|
+
* treat-like; a failover stand-in unscored or on the same quota; a rung whose backend catherd cannot run
|
|
58
|
+
* (`backends` lists those it can, `claude` included). Everything else worth knowing is a warning: an access
|
|
59
|
+
* mode other than the role's default, an effort or model the last listing does not offer, a stand-in
|
|
60
|
+
* that never runs, and (spec 1.1 §11) a stand-in that downgrades its rung, one that spends Claude quota
|
|
61
|
+
* while another plan could stand in, and a ladder that goes down. With the stored `doc` `p` came from, a value this catherd does not know is also a
|
|
62
|
+
* warning, one that says how the value is read.
|
|
63
|
+
*/
|
|
64
|
+
export function validateProfile(
|
|
65
|
+
p: Profile,
|
|
66
|
+
c: Catalog,
|
|
67
|
+
backends: readonly string[],
|
|
68
|
+
doc?: ProfileDoc,
|
|
69
|
+
): Validation {
|
|
70
|
+
const errors: Issue[] = [];
|
|
71
|
+
const warnings: Issue[] = [];
|
|
72
|
+
for (const u of doc ? unknownValues(doc) : [])
|
|
73
|
+
warnings.push({
|
|
74
|
+
path: u.path,
|
|
75
|
+
message: `"${u.value}" is not a value this catherd knows (a newer one wrote it?); it is read as ${u.readAs}`,
|
|
76
|
+
fix: "upgrade catherd (bun add -g catherd-cli@latest), or set a value this version knows",
|
|
77
|
+
});
|
|
78
|
+
if (!p.roles.worker.enabled)
|
|
79
|
+
errors.push({
|
|
80
|
+
path: "roles.worker.enabled",
|
|
81
|
+
message: "the worker cannot be disabled",
|
|
82
|
+
fix: "catherd profile set roles.worker.enabled true",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for (const role of ROLES) {
|
|
86
|
+
const rc = p.roles[role];
|
|
87
|
+
const at = `roles.${role}`;
|
|
88
|
+
if (rc.access !== DEFAULT_ACCESS[role])
|
|
89
|
+
warnings.push({
|
|
90
|
+
path: `${at}.access`,
|
|
91
|
+
message: `${role} runs ${rc.access}; catherd's default for it is ${DEFAULT_ACCESS[role]}`,
|
|
92
|
+
});
|
|
93
|
+
if (!rc.enabled) continue;
|
|
94
|
+
for (const rung of rc.rungs) {
|
|
95
|
+
const r = tryParseRung(rung);
|
|
96
|
+
if (!r) {
|
|
97
|
+
errors.push({
|
|
98
|
+
path: `${at}.rungs`,
|
|
99
|
+
message: `"${rung}" is not a rung`,
|
|
100
|
+
fix: "write it as <backend>:<model>#<effort>",
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!backends.includes(r.backend)) {
|
|
105
|
+
errors.push({
|
|
106
|
+
path: `${at}.rungs`,
|
|
107
|
+
message: `${rung}: catherd cannot run ${r.backend} yet`,
|
|
108
|
+
fix: `use a rung on ${backends.join(", ")}`,
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const info = rungInfo(c, rung);
|
|
113
|
+
if (!capableFor(c, info, role))
|
|
114
|
+
errors.push({
|
|
115
|
+
path: `${at}.rungs`,
|
|
116
|
+
message: `${rung} cannot fill the ${role} role (it needs ${Object.keys(ROLE_NEEDS[role]).join(" + ")})`,
|
|
117
|
+
fix: `catalog_query({ role: "${role}" }) lists the models that can`,
|
|
118
|
+
});
|
|
119
|
+
if (info.efforts.length > 0 && !effortOffered(info))
|
|
120
|
+
errors.push({
|
|
121
|
+
path: `${at}.rungs`,
|
|
122
|
+
message: `${r.model} has no effort "${r.effort}" on ${r.backend} (it has ${info.efforts.join(", ")})`,
|
|
123
|
+
});
|
|
124
|
+
else if (info.efforts.length === 0 && r.effort !== "default")
|
|
125
|
+
warnings.push({
|
|
126
|
+
path: `${at}.rungs`,
|
|
127
|
+
message: `${rung}: catherd cannot check its effort until ${r.backend} lists its models`,
|
|
128
|
+
fix: "catherd catalog refresh",
|
|
129
|
+
});
|
|
130
|
+
if (info.listed === false)
|
|
131
|
+
warnings.push({
|
|
132
|
+
path: `${at}.rungs`,
|
|
133
|
+
message: `${r.backend}'s last listing does not offer ${r.model}; routing skips it`,
|
|
134
|
+
});
|
|
135
|
+
if (!scoresOf(c, info.canonical))
|
|
136
|
+
errors.push({ path: `${at}.rungs`, message: `${rung} is unscored`, fix: TREAT_LIKE_FIX(rung) });
|
|
137
|
+
}
|
|
138
|
+
rc.rungs.forEach((rung, i) => {
|
|
139
|
+
const prev = rc.rungs[i - 1];
|
|
140
|
+
const down = prev === undefined ? [] : ladderDropDims(c, prev, rung);
|
|
141
|
+
if (down.length)
|
|
142
|
+
warnings.push({
|
|
143
|
+
path: `${at}.rungs`,
|
|
144
|
+
message: `the ladder goes down at ${rung}: it scores below ${prev} on ${down.join(", ")}`,
|
|
145
|
+
fix: `order ${at}.rungs weakest first`,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
if (rc.defaultRung !== undefined && !rc.rungs.includes(rc.defaultRung))
|
|
149
|
+
errors.push({
|
|
150
|
+
path: `${at}.defaultRung`,
|
|
151
|
+
message: `${rc.defaultRung} is not one of the ${role}'s rungs`,
|
|
152
|
+
fix: `catherd profile set ${at}.defaultRung null, or add it to ${at}.rungs`,
|
|
153
|
+
});
|
|
154
|
+
const usable = candidates(c, routingProfileOf(p, role), role);
|
|
155
|
+
if (usable.length === 0)
|
|
156
|
+
errors.push({
|
|
157
|
+
path: `${at}.rungs`,
|
|
158
|
+
message: `the ${role} role has no usable rung`,
|
|
159
|
+
fix: `enable a scored rung its backend offers, or turn the role off with: catherd profile set ${at}.enabled false`,
|
|
160
|
+
});
|
|
161
|
+
if (usable.length > 1)
|
|
162
|
+
// Claude rungs have no honesty score, so they clear no shipped bar: select() starts on one only as the
|
|
163
|
+
// default rung (when nothing clears) and never climbs onto it
|
|
164
|
+
for (const x of usable)
|
|
165
|
+
if (
|
|
166
|
+
quotaOf(x.info.parsed) === "claude-code" &&
|
|
167
|
+
!KINDS.some((k) => DIFFICULTIES.some((d) => clearsBar(c, x, k, d)))
|
|
168
|
+
)
|
|
169
|
+
warnings.push({
|
|
170
|
+
path: `${at}.rungs`,
|
|
171
|
+
message: `${x.rung} clears no routing bar, so a lane starts on it only as the role's default rung and never climbs onto it`,
|
|
172
|
+
});
|
|
173
|
+
if (role === "worker" && usable.length > 1) {
|
|
174
|
+
const missed = KINDS.flatMap((k) =>
|
|
175
|
+
DIFFICULTIES.filter((d) => !usable.some((x) => clearsBar(c, x, k, d))).map((d) => `${k}/${d}`),
|
|
176
|
+
);
|
|
177
|
+
if (missed.length)
|
|
178
|
+
warnings.push({
|
|
179
|
+
path: `${at}.rungs`,
|
|
180
|
+
message: `no worker rung clears the bar for ${missed.join(", ")}; those lanes start at the default rung`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
|
|
186
|
+
// the stand-ins catherd could run here, for the fixes below
|
|
187
|
+
const pool = catalogRungs(c).filter((x) => {
|
|
188
|
+
const r = tryParseRung(x);
|
|
189
|
+
return r !== null && backends.includes(r.backend);
|
|
190
|
+
});
|
|
191
|
+
for (const [from, to] of Object.entries(p.failover)) {
|
|
192
|
+
const at = `failover.${from}`;
|
|
193
|
+
const a = tryParseRung(from);
|
|
194
|
+
const b = tryParseRung(to);
|
|
195
|
+
if (!a || !b) {
|
|
196
|
+
errors.push({
|
|
197
|
+
path: at,
|
|
198
|
+
message: `"${!a ? from : to}" is not a rung`,
|
|
199
|
+
fix: "write it as <backend>:<model>#<effort>",
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!backends.includes(b.backend)) {
|
|
204
|
+
errors.push({ path: at, message: `stand-in ${to}: catherd cannot run ${b.backend} yet` });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!scoresOf(c, rungInfo(c, to).canonical))
|
|
208
|
+
errors.push({ path: at, message: `stand-in ${to} is unscored`, fix: TREAT_LIKE_FIX(to) });
|
|
209
|
+
if (quotaOf(a) === quotaOf(b))
|
|
210
|
+
errors.push({
|
|
211
|
+
path: at,
|
|
212
|
+
message: `stand-in ${to} draws on the same quota as ${from}, which is out when ${from} hits its limit`,
|
|
213
|
+
fix: "name a stand-in on another backend or plan",
|
|
214
|
+
});
|
|
215
|
+
const ranked = rankStandIns(c, p.billing, from, pool);
|
|
216
|
+
const down = downgradeDims(c, from, to);
|
|
217
|
+
if (down.length)
|
|
218
|
+
warnings.push({
|
|
219
|
+
path: at,
|
|
220
|
+
message: `downgrade: ${to} stands in for ${from}, scoring below it on ${down.join(", ")}`,
|
|
221
|
+
fix: `catherd profile set failover.${from} ${ranked[0] ?? "null"}`,
|
|
222
|
+
});
|
|
223
|
+
const other = ranked.find((x) => !claudeBilled(x));
|
|
224
|
+
if (claudeBilled(to) && other)
|
|
225
|
+
warnings.push({
|
|
226
|
+
path: at,
|
|
227
|
+
message: `stand-in ${to} spends Claude quota, while ${other} could stand in on another plan`,
|
|
228
|
+
fix: `catherd profile set failover.${from} ${other}`,
|
|
229
|
+
});
|
|
230
|
+
if (b.backend === "claude")
|
|
231
|
+
warnings.push({
|
|
232
|
+
path: at,
|
|
233
|
+
message: `stand-in ${to} is a native subagent: the orchestrator must start it, dispatch cannot`,
|
|
234
|
+
});
|
|
235
|
+
if (!ladders.has(from))
|
|
236
|
+
warnings.push({ path: at, message: `${from} is on no enabled role's ladder, so this never runs` });
|
|
237
|
+
}
|
|
238
|
+
return { errors, warnings };
|
|
239
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Budget } from "./budget.ts";
|
|
3
|
+
import { BILLING_KEYS, type BillingKey } from "./catalog.ts";
|
|
4
|
+
import { BILLING_MODES, type BillingMode, DEFAULT_BILLING } from "./cost.ts";
|
|
5
|
+
import { CatherdError } from "./errors.ts";
|
|
6
|
+
import { ADAPTER_IDS, parseRung, tryParseRung } from "./ids.ts";
|
|
7
|
+
import { ACCESS, type Access } from "./record.ts";
|
|
8
|
+
import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
|
|
9
|
+
import { isPlain, slug } from "./util.ts";
|
|
10
|
+
|
|
11
|
+
/** Spec §3.4: every file carries its schema version; a profile is schema 1. */
|
|
12
|
+
const PROFILE_SCHEMA = 1;
|
|
13
|
+
|
|
14
|
+
/** Profile names end up in agent names (`catherd-<profile>-…`), which Claude Code wants lowercase. */
|
|
15
|
+
export const PROFILE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
16
|
+
|
|
17
|
+
export function assertProfileName(name: string): string {
|
|
18
|
+
if (!PROFILE_NAME.test(name))
|
|
19
|
+
throw new CatherdError("E_INPUT_INVALID", `bad profile name "${name}"`, {
|
|
20
|
+
fix: "use lowercase letters, digits and '-', at most 32, starting with a letter or digit",
|
|
21
|
+
});
|
|
22
|
+
return name;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const NOTIFY = ["milestone", "finish", "blocked"] as const;
|
|
26
|
+
export type NotifyMoment = (typeof NOTIFY)[number];
|
|
27
|
+
|
|
28
|
+
/** The harnesses a profile can isolate (spec §7.1 `harness`); the native `claude` path has none. */
|
|
29
|
+
export const HARNESS_KEYS = ADAPTER_IDS;
|
|
30
|
+
|
|
31
|
+
const isRung = (s: string): boolean => tryParseRung(s) !== null;
|
|
32
|
+
/** A `<backend>:<model>#<effort>` rung, for input schemas; stored profiles keep any string and validate. */
|
|
33
|
+
const RungSchema = z.string().refine(isRung, "a rung is <backend>:<model>#<effort>");
|
|
34
|
+
|
|
35
|
+
const positive = z.number().positive();
|
|
36
|
+
|
|
37
|
+
// Stored files: every level is loose, so fields a newer catherd wrote survive a rewrite (spec §3.4), and
|
|
38
|
+
// rung strings are checked by validateProfile rather than making the whole file unreadable. The same holds
|
|
39
|
+
// for values: a stored enum takes any string, resolveProfile reads one it does not know as STORED_FALLBACK
|
|
40
|
+
// says, validateProfile warns, and the file keeps it.
|
|
41
|
+
const RoleDocSchema = z.looseObject({
|
|
42
|
+
enabled: z.boolean().optional(),
|
|
43
|
+
access: z.string().optional(),
|
|
44
|
+
rungs: z.array(z.string()).optional(),
|
|
45
|
+
defaultRung: z.string().optional(),
|
|
46
|
+
network: z.boolean().optional(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const ProfileDocSchema = z.looseObject({
|
|
50
|
+
schema: z.literal(PROFILE_SCHEMA),
|
|
51
|
+
name: z.string().optional(),
|
|
52
|
+
objective: z.string().optional(),
|
|
53
|
+
jev: z.looseObject({ use: z.string().optional() }).optional(),
|
|
54
|
+
billing: z.record(z.string(), z.string()).optional(),
|
|
55
|
+
roles: z.record(z.string(), RoleDocSchema).optional(),
|
|
56
|
+
harness: z.record(z.string(), z.looseObject({ isolated: z.boolean().optional() })).optional(),
|
|
57
|
+
failover: z.record(z.string(), z.string()).optional(),
|
|
58
|
+
budget: z
|
|
59
|
+
.looseObject({ minutes: positive.optional(), tokens: positive.optional(), usd: positive.optional() })
|
|
60
|
+
.optional(),
|
|
61
|
+
timeouts: z.looseObject({ idleMin: positive.optional(), wallMin: positive.optional() }).optional(),
|
|
62
|
+
preflight: z.looseObject({ confirm: z.boolean().optional() }).optional(),
|
|
63
|
+
lock: z
|
|
64
|
+
.looseObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]).optional() })
|
|
65
|
+
.optional(),
|
|
66
|
+
notify: z.array(z.string()).optional(),
|
|
67
|
+
});
|
|
68
|
+
export type ProfileDoc = z.infer<typeof ProfileDocSchema>;
|
|
69
|
+
|
|
70
|
+
const OBJECTIVES = ["cost", "speed"] as const;
|
|
71
|
+
const JEV_USES = ["auto", "off"] as const;
|
|
72
|
+
const known = <T extends string>(values: readonly T[], v: string | undefined): v is T =>
|
|
73
|
+
v !== undefined && (values as readonly string[]).includes(v);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How a stored value this catherd does not know (a newer one wrote it) is read, always the cautious way:
|
|
77
|
+
* access as read-only, billing as the key's default mode, Jev as off, so nothing is sent or written that
|
|
78
|
+
* the value may not allow; an unknown notify moment is skipped.
|
|
79
|
+
*/
|
|
80
|
+
const STORED_FALLBACK = {
|
|
81
|
+
objective: "cost",
|
|
82
|
+
jevUse: "off",
|
|
83
|
+
access: "read-only",
|
|
84
|
+
billing: (key: string): BillingMode => DEFAULT_BILLING[key as BillingKey] ?? "metered",
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
/** A stored value this catherd does not know, with what it is read as. */
|
|
88
|
+
export interface UnknownValue {
|
|
89
|
+
path: string;
|
|
90
|
+
value: string;
|
|
91
|
+
readAs: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every stored enum value in `doc` this catherd does not know (spec §3.4: newer files stay readable). */
|
|
95
|
+
export function unknownValues(doc: ProfileDoc): UnknownValue[] {
|
|
96
|
+
const out: UnknownValue[] = [];
|
|
97
|
+
const check = (path: string, values: readonly string[], v: string | undefined, readAs: string) => {
|
|
98
|
+
if (v !== undefined && !values.includes(v)) out.push({ path, value: v, readAs });
|
|
99
|
+
};
|
|
100
|
+
check("objective", OBJECTIVES, doc.objective, STORED_FALLBACK.objective);
|
|
101
|
+
check("jev.use", JEV_USES, doc.jev?.use, STORED_FALLBACK.jevUse);
|
|
102
|
+
for (const [k, v] of Object.entries(doc.billing ?? {}))
|
|
103
|
+
check(`billing.${k}`, BILLING_MODES, v, STORED_FALLBACK.billing(k));
|
|
104
|
+
for (const role of ROLES)
|
|
105
|
+
check(`roles.${role}.access`, ACCESS, doc.roles?.[role]?.access, STORED_FALLBACK.access);
|
|
106
|
+
for (const n of doc.notify ?? []) check("notify", NOTIFY, n, "skipped");
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// a type, not an interface, so the built-in roles can be written into a loose (indexed) document
|
|
111
|
+
export type RoleConfig = {
|
|
112
|
+
enabled: boolean;
|
|
113
|
+
access: Access;
|
|
114
|
+
/** in ladder order, each `backend:model#effort`; `claude:` runs as a native subagent, `claude-code:` headless */
|
|
115
|
+
rungs: string[];
|
|
116
|
+
defaultRung?: string;
|
|
117
|
+
/** spec §5: false drops a workspace-write role's network, loopback and Docker grants; absent means granted */
|
|
118
|
+
network?: false;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
|
|
122
|
+
export interface Profile {
|
|
123
|
+
name: string;
|
|
124
|
+
objective: "cost" | "speed";
|
|
125
|
+
jev: { use: "auto" | "off" };
|
|
126
|
+
billing: Record<string, BillingMode>;
|
|
127
|
+
roles: Record<Role, RoleConfig>;
|
|
128
|
+
harness: Record<string, { isolated: boolean }>;
|
|
129
|
+
failover: Record<string, string>;
|
|
130
|
+
budget: Budget;
|
|
131
|
+
timeouts: { idleMin: number; wallMin: number };
|
|
132
|
+
preflight: { confirm: boolean };
|
|
133
|
+
lock: { heavy: number | "cpus/2" };
|
|
134
|
+
notify: NotifyMoment[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const LUNA_HIGH = "codex:gpt-6-luna#high";
|
|
138
|
+
const SOL = (effort: string) => `codex:gpt-6-sol#${effort}`;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Spec §7.2, the owner's setup (Codex on a ChatGPT plan, a Claude plan, OpenCode Go). A role missing
|
|
142
|
+
* from a profile takes its entry here. Architect and verifier run as native Claude subagents (D3).
|
|
143
|
+
*/
|
|
144
|
+
export const BUILTIN_ROLES: Record<Role, RoleConfig> = {
|
|
145
|
+
architect: { enabled: true, access: DEFAULT_ACCESS.architect, rungs: ["claude:claude-opus-5-5#high"] },
|
|
146
|
+
verifier: { enabled: true, access: DEFAULT_ACCESS.verifier, rungs: ["claude:claude-opus-5-5#low"] },
|
|
147
|
+
worker: {
|
|
148
|
+
enabled: true,
|
|
149
|
+
access: DEFAULT_ACCESS.worker,
|
|
150
|
+
rungs: [LUNA_HIGH, SOL("medium"), SOL("high"), SOL("xhigh")],
|
|
151
|
+
defaultRung: SOL("medium"),
|
|
152
|
+
},
|
|
153
|
+
reviewer: { enabled: true, access: DEFAULT_ACCESS.reviewer, rungs: [SOL("high")] },
|
|
154
|
+
"ui-reviewer": { enabled: true, access: DEFAULT_ACCESS["ui-reviewer"], rungs: [SOL("medium")] },
|
|
155
|
+
artist: { enabled: true, access: DEFAULT_ACCESS.artist, rungs: [SOL("medium")] },
|
|
156
|
+
writer: { enabled: true, access: DEFAULT_ACCESS.writer, rungs: [LUNA_HIGH] },
|
|
157
|
+
researcher: { enabled: true, access: DEFAULT_ACCESS.researcher, rungs: [LUNA_HIGH] },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Spec 1.1 §11: each shipped worker rung fails over to the best stand-in the shipped catalog offers
|
|
162
|
+
* (`rankStandIns` over `catalogRungs`, pinned by test/domain/failover.test.ts): on another quota, paid
|
|
163
|
+
* from a plan, and no downgrade on the dims the rung's bars use. Go serves GPT-6 Luna itself; Kimi K3
|
|
164
|
+
* borrows Sol medium's scores (a shipped treat-like). Sol high and xhigh have no such stand-in on the
|
|
165
|
+
* owner's plans, so they have none: a limit on them pauses the lane instead of dropping it a tier.
|
|
166
|
+
*/
|
|
167
|
+
export const DEFAULT_FAILOVER: Record<string, string> = {
|
|
168
|
+
[LUNA_HIGH]: "opencode:opencode-go/gpt-6-luna#high",
|
|
169
|
+
[SOL("medium")]: "opencode:opencode-go/kimi-k3#max",
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
|
|
173
|
+
const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
|
|
174
|
+
|
|
175
|
+
/** The full default profile document, every field written out: what `init` and `profile new` save. */
|
|
176
|
+
export function defaultProfileDoc(name = "default"): ProfileDoc {
|
|
177
|
+
return {
|
|
178
|
+
schema: PROFILE_SCHEMA,
|
|
179
|
+
name,
|
|
180
|
+
objective: "cost",
|
|
181
|
+
jev: { use: "auto" },
|
|
182
|
+
billing: Object.fromEntries(WRITTEN_BILLING.map((k) => [k, DEFAULT_BILLING[k]])),
|
|
183
|
+
roles: structuredClone(BUILTIN_ROLES),
|
|
184
|
+
harness: {
|
|
185
|
+
codex: { isolated: false },
|
|
186
|
+
"claude-code": { isolated: false },
|
|
187
|
+
opencode: { isolated: false },
|
|
188
|
+
},
|
|
189
|
+
failover: { ...DEFAULT_FAILOVER },
|
|
190
|
+
budget: {},
|
|
191
|
+
timeouts: { idleMin: 15, wallMin: 90 },
|
|
192
|
+
preflight: { confirm: false },
|
|
193
|
+
lock: { heavy: "cpus/2" },
|
|
194
|
+
notify: [...NOTIFY],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Spec §7.1: every field a document leaves out takes its default; a missing role takes the built-in one. */
|
|
199
|
+
export function resolveProfile(doc: ProfileDoc, name: string): Profile {
|
|
200
|
+
const roles = {} as Record<Role, RoleConfig>;
|
|
201
|
+
for (const role of ROLES) {
|
|
202
|
+
const d = doc.roles?.[role];
|
|
203
|
+
const b = BUILTIN_ROLES[role];
|
|
204
|
+
// a role that lists its own rungs never inherits the built-in default rung, which may not be among them
|
|
205
|
+
const defaultRung = d?.defaultRung ?? (d?.rungs === undefined ? b.defaultRung : undefined);
|
|
206
|
+
roles[role] = {
|
|
207
|
+
enabled: d?.enabled ?? b.enabled,
|
|
208
|
+
access:
|
|
209
|
+
d?.access === undefined
|
|
210
|
+
? DEFAULT_ACCESS[role]
|
|
211
|
+
: known(ACCESS, d.access)
|
|
212
|
+
? d.access
|
|
213
|
+
: STORED_FALLBACK.access,
|
|
214
|
+
rungs: [...(d?.rungs ?? b.rungs)],
|
|
215
|
+
...(defaultRung ? { defaultRung } : {}),
|
|
216
|
+
...(d?.network === false ? { network: false as const } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const harness: Profile["harness"] = {};
|
|
220
|
+
for (const k of new Set([...HARNESS_KEYS, ...Object.keys(doc.harness ?? {})]))
|
|
221
|
+
harness[k] = { isolated: doc.harness?.[k]?.isolated ?? false };
|
|
222
|
+
const budget: Budget = {};
|
|
223
|
+
for (const k of ["minutes", "tokens", "usd"] as const) {
|
|
224
|
+
const v = doc.budget?.[k];
|
|
225
|
+
if (v !== undefined) budget[k] = v;
|
|
226
|
+
}
|
|
227
|
+
const billing: Record<string, BillingMode> = { ...DEFAULT_BILLING };
|
|
228
|
+
for (const [k, v] of Object.entries(doc.billing ?? {}))
|
|
229
|
+
billing[k] = known(BILLING_MODES, v) ? v : STORED_FALLBACK.billing(k);
|
|
230
|
+
const use = doc.jev?.use;
|
|
231
|
+
return {
|
|
232
|
+
name,
|
|
233
|
+
objective: known(OBJECTIVES, doc.objective) ? doc.objective : STORED_FALLBACK.objective,
|
|
234
|
+
jev: { use: use === undefined ? "auto" : known(JEV_USES, use) ? use : STORED_FALLBACK.jevUse },
|
|
235
|
+
billing,
|
|
236
|
+
roles,
|
|
237
|
+
harness,
|
|
238
|
+
failover: { ...doc.failover },
|
|
239
|
+
budget,
|
|
240
|
+
timeouts: { idleMin: doc.timeouts?.idleMin ?? 15, wallMin: doc.timeouts?.wallMin ?? 90 },
|
|
241
|
+
preflight: { confirm: doc.preflight?.confirm ?? false },
|
|
242
|
+
lock: { heavy: doc.lock?.heavy ?? "cpus/2" },
|
|
243
|
+
notify: doc.notify === undefined ? [...NOTIFY] : doc.notify.filter((n) => known(NOTIFY, n)),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Spec §7.3: `catherd-<profile>-<role>-<model slug>-<effort>`, the native subagent for a `claude:` rung. */
|
|
248
|
+
export function agentName(profile: string, role: Role, rung: string): string {
|
|
249
|
+
const r = parseRung(rung);
|
|
250
|
+
return `catherd-${profile}-${role}-${slug(r.model)}-${slug(r.effort)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Patches: what profile_set and `catherd profile set` accept. Strict at every level, so a misspelt key is
|
|
254
|
+
// refused with E_INPUT_INVALID instead of dropped (plan-2 review m9). `null` removes a map entry or a nullable
|
|
255
|
+
// field (a role's `network` among them: absent means granted).
|
|
256
|
+
const RolePatchSchema = z
|
|
257
|
+
.strictObject({
|
|
258
|
+
enabled: z.boolean(),
|
|
259
|
+
access: z.enum(ACCESS),
|
|
260
|
+
rungs: z.array(RungSchema),
|
|
261
|
+
defaultRung: RungSchema.nullable(),
|
|
262
|
+
network: z.boolean().nullable(),
|
|
263
|
+
})
|
|
264
|
+
.partial();
|
|
265
|
+
|
|
266
|
+
export const ProfilePatchSchema = z.strictObject({
|
|
267
|
+
objective: z.enum(["cost", "speed"]).optional(),
|
|
268
|
+
jev: z.strictObject({ use: z.enum(["auto", "off"]) }).optional(),
|
|
269
|
+
billing: z.partialRecord(z.enum(BILLING_KEYS), z.enum(BILLING_MODES).nullable()).optional(),
|
|
270
|
+
roles: z.partialRecord(z.enum(ROLES), RolePatchSchema).optional(),
|
|
271
|
+
harness: z.partialRecord(z.enum(HARNESS_KEYS), z.strictObject({ isolated: z.boolean() })).optional(),
|
|
272
|
+
failover: z.record(RungSchema, RungSchema.nullable()).optional(),
|
|
273
|
+
budget: z
|
|
274
|
+
.strictObject({ minutes: positive.nullable(), tokens: positive.nullable(), usd: positive.nullable() })
|
|
275
|
+
.partial()
|
|
276
|
+
.optional(),
|
|
277
|
+
timeouts: z.strictObject({ idleMin: positive, wallMin: positive }).partial().optional(),
|
|
278
|
+
preflight: z.strictObject({ confirm: z.boolean() }).optional(),
|
|
279
|
+
lock: z.strictObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]) }).optional(),
|
|
280
|
+
notify: z.array(z.enum(NOTIFY)).optional(),
|
|
281
|
+
});
|
|
282
|
+
export type ProfilePatch = z.infer<typeof ProfilePatchSchema>;
|
|
283
|
+
|
|
284
|
+
/** RFC 7396 merge: objects merge key by key, `null` deletes, anything else (arrays too) replaces. */
|
|
285
|
+
function mergePatch(target: unknown, patch: unknown): unknown {
|
|
286
|
+
if (!isPlain(patch)) return patch;
|
|
287
|
+
const out: Record<string, unknown> = isPlain(target) ? { ...target } : {};
|
|
288
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
289
|
+
if (v === null) delete out[k];
|
|
290
|
+
else out[k] = mergePatch(out[k], v);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The document with `patch` applied; every field the patch does not name, known or not, is kept. */
|
|
296
|
+
export function applyPatch(doc: ProfileDoc, patch: ProfilePatch): ProfileDoc {
|
|
297
|
+
return ProfileDocSchema.parse(mergePatch(doc, patch));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Map fields whose keys are rungs or billing keys, which may hold dots: the rest of the path is one key. */
|
|
301
|
+
const MAP_FIELDS = new Set(["failover", "billing"]);
|
|
302
|
+
const LIST_FIELDS = (keys: string[]) =>
|
|
303
|
+
(keys[0] === "roles" && keys[2] === "rungs") || (keys[0] === "notify" && keys.length === 1);
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `catherd profile set <path> <value>` as a patch. The value is JSON when it parses (numbers, booleans,
|
|
307
|
+
* `null`, arrays), else the plain word; a rung list may also be comma-separated.
|
|
308
|
+
*/
|
|
309
|
+
export function patchAt(path: string, raw: string): ProfilePatch {
|
|
310
|
+
const segs = path.split(".");
|
|
311
|
+
const keys =
|
|
312
|
+
MAP_FIELDS.has(segs[0] ?? "") && segs.length > 1 ? [segs[0] as string, segs.slice(1).join(".")] : segs;
|
|
313
|
+
let value: unknown;
|
|
314
|
+
try {
|
|
315
|
+
value = JSON.parse(raw);
|
|
316
|
+
} catch {
|
|
317
|
+
value = raw;
|
|
318
|
+
}
|
|
319
|
+
if (LIST_FIELDS(keys) && typeof value === "string")
|
|
320
|
+
value = value
|
|
321
|
+
.split(",")
|
|
322
|
+
.map((s) => s.trim())
|
|
323
|
+
.filter(Boolean);
|
|
324
|
+
const patch = keys.reduceRight<unknown>((inner, k) => ({ [k]: inner }), value);
|
|
325
|
+
const r = keys.some((k) => !k) ? null : ProfilePatchSchema.safeParse(patch);
|
|
326
|
+
if (!r?.success)
|
|
327
|
+
throw new CatherdError(
|
|
328
|
+
"E_INPUT_INVALID",
|
|
329
|
+
`cannot set ${path} to ${raw}${r ? `: ${z.prettifyError(r.error).replace(/\n\s*/g, " ")}` : ""}`,
|
|
330
|
+
{
|
|
331
|
+
fix: "catherd profile show --json lists the fields; e.g. catherd profile set roles.worker.access read-only",
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
return r.data;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface Change {
|
|
338
|
+
path: string;
|
|
339
|
+
before: unknown;
|
|
340
|
+
after: unknown;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Leaves by dotted path; an empty object has none, so `budget: {}` → `budget.usd: 5` is one change. */
|
|
344
|
+
function flatten(v: unknown, prefix: string, out: Map<string, unknown>): void {
|
|
345
|
+
if (isPlain(v)) for (const [k, x] of Object.entries(v)) flatten(x, prefix ? `${prefix}.${k}` : k, out);
|
|
346
|
+
else if (v !== undefined) out.set(prefix, v);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Every leaf that differs between two profiles; lists compare whole. */
|
|
350
|
+
export function diffProfiles(a: Profile, b: Profile): Change[] {
|
|
351
|
+
const fa = new Map<string, unknown>();
|
|
352
|
+
const fb = new Map<string, unknown>();
|
|
353
|
+
flatten({ ...a, name: undefined }, "", fa);
|
|
354
|
+
flatten({ ...b, name: undefined }, "", fb);
|
|
355
|
+
const changes: Change[] = [];
|
|
356
|
+
for (const path of [...new Set([...fa.keys(), ...fb.keys()])].sort()) {
|
|
357
|
+
const before = fa.get(path) ?? null;
|
|
358
|
+
const after = fb.get(path) ?? null;
|
|
359
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) changes.push({ path, before, after });
|
|
360
|
+
}
|
|
361
|
+
return changes;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The patch that turns `a` into `b`: the leaves that differ, lists whole, a removed key as `null`. Fields
|
|
366
|
+
* both documents share unchanged, known or not, are left out, so applying it over a newer copy of `a`
|
|
367
|
+
* keeps whatever else changed there. Throws E_INPUT_INVALID when `b` holds what no patch may set.
|
|
368
|
+
*/
|
|
369
|
+
export function patchBetween(a: ProfileDoc, b: ProfileDoc): ProfilePatch {
|
|
370
|
+
const diff = (x: unknown, y: unknown): unknown => {
|
|
371
|
+
if (!isPlain(x) || !isPlain(y)) return y;
|
|
372
|
+
const out: Record<string, unknown> = {};
|
|
373
|
+
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
|
|
374
|
+
if (!(k in y)) out[k] = null;
|
|
375
|
+
else if (JSON.stringify(x[k]) !== JSON.stringify(y[k])) out[k] = diff(x[k], y[k]);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
};
|
|
379
|
+
const r = ProfilePatchSchema.safeParse(diff(a, b));
|
|
380
|
+
if (!r.success)
|
|
381
|
+
throw new CatherdError(
|
|
382
|
+
"E_INPUT_INVALID",
|
|
383
|
+
`these changes cannot be saved: ${z.prettifyError(r.error).replace(/\n\s*/g, " ")}`,
|
|
384
|
+
{
|
|
385
|
+
fix: "undo the last change, or edit the field with catherd profile set",
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
return r.data;
|
|
389
|
+
}
|