claudeup 6.6.0 → 6.8.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/package.json +4 -4
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +1 -1
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/model-visuals.test.tsx +1698 -25
- package/src/__tests__/models-adapter.test.ts +21 -6
- package/src/__tests__/models-cli.test.ts +100 -0
- package/src/__tests__/models-core.test.ts +273 -111
- package/src/__tests__/models-manager.test.ts +15 -12
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/data/models-presets.ts +72 -13
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/claude-settings.ts +1 -1
- package/src/services/community-styles.ts +1 -1
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/models-core.ts +371 -30
- package/src/ui/adapters/modelsAdapter.ts +58 -15
- package/src/ui/components/layout/ScreenLayout.tsx +6 -1
- package/src/ui/renderers/modelRenderers.tsx +413 -108
- package/src/ui/renderers/modelVisuals.tsx +694 -145
- package/src/ui/screens/ModelsScreen.tsx +74 -10
- package/src/ui/state/reducer.ts +20 -0
- package/src/ui/state/types.ts +31 -0
- package/src/ui/theme-mode.ts +128 -14
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent tier table and the workflow list are keyed by agent id, and nothing else
|
|
3
|
+
* checks those ids.
|
|
4
|
+
*
|
|
5
|
+
* `DEFAULT_AGENT_GRADES` in `data/models-presets.ts` names
|
|
6
|
+
* agents as `plugin:name`. A rename in the marketplace (`code-search:analyze` →
|
|
7
|
+
* something else, `dev:synthesizer` → `dev:aggregator`) breaks the key with no signal: the
|
|
8
|
+
* renamed agent silently falls to the preset's fallback, and the stale key routes nothing.
|
|
9
|
+
* This test reads the marketplace source tree beside this repo and fails on either drift.
|
|
10
|
+
*
|
|
11
|
+
* Two directions:
|
|
12
|
+
* (a) every preset key names an agent that exists (or is a Claude Code built-in);
|
|
13
|
+
* (b) every marketplace agent is classified — in the tier table, in the frontmatter map,
|
|
14
|
+
* a vision agent, or explicitly left to the fallback in FALLBACK_BY_DESIGN below.
|
|
15
|
+
* Absence is a legitimate routing choice (fallback = `normal`), but it must be a
|
|
16
|
+
* choice: a new agent fails here until someone decides.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from "bun:test";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_AGENT_GRADES,
|
|
23
|
+
VISION_AGENTS,
|
|
24
|
+
WORKFLOWS,
|
|
25
|
+
} from "../data/models-presets.js";
|
|
26
|
+
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
27
|
+
|
|
28
|
+
const REPO = resolve(import.meta.dir, "..", "..", "..", "..");
|
|
29
|
+
const MARKETPLACE = join(REPO, ".claude-plugin", "marketplace.json");
|
|
30
|
+
const inMagusSrc = existsSync(MARKETPLACE) && existsSync(join(REPO, "plugins"));
|
|
31
|
+
|
|
32
|
+
/** Claude Code's own agents: not in any marketplace, addressed by bare name. */
|
|
33
|
+
const BUILT_IN = new Set([
|
|
34
|
+
"Explore",
|
|
35
|
+
"Plan",
|
|
36
|
+
"general-purpose",
|
|
37
|
+
"statusline-setup",
|
|
38
|
+
"claude-code-guide",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Marketplace agents deliberately left to the preset's `fallback` grade. Each entry is a
|
|
43
|
+
* decision, not an omission: remove the line when the agent gets a tier. A NEW agent is not
|
|
44
|
+
* on this list, so adding one to the marketplace fails (b) until it is placed somewhere.
|
|
45
|
+
*/
|
|
46
|
+
const FALLBACK_BY_DESIGN = new Set([
|
|
47
|
+
"dev:developer",
|
|
48
|
+
"dev:devops",
|
|
49
|
+
"dev:frontend-developer",
|
|
50
|
+
"dev:researcher",
|
|
51
|
+
"dev:qa-engineer",
|
|
52
|
+
"madbench:bench",
|
|
53
|
+
"video-editing:timeline-builder",
|
|
54
|
+
"video-editing:transcriber",
|
|
55
|
+
"video-editing:video-processor",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
interface MarketplaceAgent {
|
|
59
|
+
id: string;
|
|
60
|
+
file: string;
|
|
61
|
+
frontmatterModel: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every `plugins/<p>/agents/*.md` of every plugin the marketplace lists, by frontmatter name. */
|
|
65
|
+
function marketplaceAgents(): MarketplaceAgent[] {
|
|
66
|
+
const manifest = JSON.parse(readFileSync(MARKETPLACE, "utf8")) as {
|
|
67
|
+
plugins: { name: string; source: string }[];
|
|
68
|
+
};
|
|
69
|
+
const out: MarketplaceAgent[] = [];
|
|
70
|
+
for (const plugin of manifest.plugins) {
|
|
71
|
+
const agentsDir = join(REPO, plugin.source, "agents");
|
|
72
|
+
if (!existsSync(agentsDir)) continue;
|
|
73
|
+
for (const entry of readdirSync(agentsDir)) {
|
|
74
|
+
if (!entry.endsWith(".md")) continue;
|
|
75
|
+
const file = join(agentsDir, entry);
|
|
76
|
+
const text = readFileSync(file, "utf8");
|
|
77
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
78
|
+
if (!fm) continue;
|
|
79
|
+
const name = fm[1].match(/^name:\s*(.+?)\s*$/m)?.[1];
|
|
80
|
+
if (!name) continue;
|
|
81
|
+
const model = fm[1].match(/^model:\s*(.+?)\s*$/m)?.[1];
|
|
82
|
+
out.push({
|
|
83
|
+
id: `${plugin.name}:${name}`,
|
|
84
|
+
file: join(plugin.source, "agents", entry),
|
|
85
|
+
frontmatterModel: model,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe.skipIf(!inMagusSrc)(
|
|
93
|
+
"models-presets agent ids track the marketplace",
|
|
94
|
+
() => {
|
|
95
|
+
const agents = marketplaceAgents();
|
|
96
|
+
const ids = new Set(agents.map((a) => a.id));
|
|
97
|
+
const presetKeys = [...Object.keys(DEFAULT_AGENT_GRADES)];
|
|
98
|
+
|
|
99
|
+
test("reads a non-trivial marketplace", () => {
|
|
100
|
+
expect(agents.length).toBeGreaterThan(10);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("(a) every preset key names an existing agent or a built-in", () => {
|
|
104
|
+
const dead = presetKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
|
|
105
|
+
expect(dead).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* (c) `WORKFLOWS` names agent ids too, and until now nothing checked them.
|
|
110
|
+
*
|
|
111
|
+
* These ids drive the per-workflow spread graphic. A stale one matches no agent, so
|
|
112
|
+
* its segment silently vanishes and the chart goes on drawing a confident answer
|
|
113
|
+
* about a workflow it has mis-measured — the same silent-drift failure (a) exists to
|
|
114
|
+
* catch, one array over. Measured: the 13.0.0 `code-analysis` → `code-search` rename
|
|
115
|
+
* reached this array through a rebase conflict rather than through this gate.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT folded into `presetKeys`. Being named in a workflow is not a
|
|
118
|
+
* routing classification, so counting it in (b) would let a workflow mention excuse
|
|
119
|
+
* an agent from ever being given a tier.
|
|
120
|
+
*/
|
|
121
|
+
test("(c) every workflow agent names an existing agent or a built-in", () => {
|
|
122
|
+
const workflowKeys = WORKFLOWS.flatMap((w) => [
|
|
123
|
+
...w.agents,
|
|
124
|
+
...w.external.map((step) => step.agent),
|
|
125
|
+
]);
|
|
126
|
+
const dead = workflowKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
|
|
127
|
+
expect(dead).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("(b) every marketplace agent is classified somewhere", () => {
|
|
131
|
+
const classified = new Set<string>([
|
|
132
|
+
...presetKeys,
|
|
133
|
+
...VISION_AGENTS,
|
|
134
|
+
...FALLBACK_BY_DESIGN,
|
|
135
|
+
]);
|
|
136
|
+
const unplaced = agents
|
|
137
|
+
.filter((a) => !classified.has(a.id))
|
|
138
|
+
.map((a) => a.id);
|
|
139
|
+
expect(unplaced).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("FALLBACK_BY_DESIGN and VISION_AGENTS name agents that still exist", () => {
|
|
143
|
+
const stale = [...FALLBACK_BY_DESIGN, ...VISION_AGENTS].filter(
|
|
144
|
+
(k) => !ids.has(k),
|
|
145
|
+
);
|
|
146
|
+
expect(stale).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// A profile's magusPlugins is an install list, not documentation: resolver.ts expands
|
|
150
|
+
// each entry to `<name>@<marketplace>: latest`. A plugin rename moves under it silently,
|
|
151
|
+
// which is how 13.0.0's code-analysis -> code-search rename left six profiles installing
|
|
152
|
+
// a plugin the marketplace no longer lists. The agent checks above never read this array.
|
|
153
|
+
test("(d) every predefined profile names plugins the marketplace still lists", () => {
|
|
154
|
+
const marketplace = JSON.parse(readFileSync(MARKETPLACE, "utf8")) as {
|
|
155
|
+
plugins: { name: string }[];
|
|
156
|
+
};
|
|
157
|
+
const known = new Set(marketplace.plugins.map((p) => p.name));
|
|
158
|
+
const dead: string[] = [];
|
|
159
|
+
for (const profile of PREDEFINED_PROFILES) {
|
|
160
|
+
for (const entry of profile.magusPlugins ?? []) {
|
|
161
|
+
const name = entry.split("@")[0];
|
|
162
|
+
if (!known.has(name)) dead.push(`${profile.id}: ${entry}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
expect(dead).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
);
|
|
@@ -24,6 +24,11 @@ function snapshot(over: Partial<ModelsSnapshot> = {}): ModelsSnapshot {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Drop `//` line comments and block comments, so a source guard matches code only. */
|
|
28
|
+
function stripComments(source: string): string {
|
|
29
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
/** Navigating away and back is what unmounts the screen. */
|
|
28
33
|
const roundTrip: AppAction[] = [
|
|
29
34
|
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
@@ -36,6 +41,9 @@ describe("models screen initial state", () => {
|
|
|
36
41
|
expect(initialState.models.searchQuery).toBe("");
|
|
37
42
|
expect(initialState.models.status).toBeNull();
|
|
38
43
|
expect(initialState.models.isApplying).toBe(false);
|
|
44
|
+
// The mate slots are not drawn until something says they can be served. Starting
|
|
45
|
+
// true would put three rows on screen before anything had been asked.
|
|
46
|
+
expect(initialState.models.matesAvailable).toBe(false);
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
test("the models route is reachable and NAVIGATE keeps the screen's state", () => {
|
|
@@ -194,6 +202,44 @@ describe("apply-in-flight state", () => {
|
|
|
194
202
|
});
|
|
195
203
|
});
|
|
196
204
|
|
|
205
|
+
describe("the mate slots' availability", () => {
|
|
206
|
+
test("survives the screen unmounting, so the lookup happens once a session", () => {
|
|
207
|
+
// Unlike the two file reads this screen does on mount, this answer costs a
|
|
208
|
+
// marketplace resolution with network timeouts behind it. Held locally it would die
|
|
209
|
+
// on every tab switch and be paid for again on every return — for something that
|
|
210
|
+
// cannot change while the TUI is open.
|
|
211
|
+
const available = run([
|
|
212
|
+
{ type: "MODELS_MATES_AVAILABLE", available: true },
|
|
213
|
+
]);
|
|
214
|
+
expect(available.models.matesAvailable).toBe(true);
|
|
215
|
+
expect(run(roundTrip, available).models.matesAvailable).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a false answer is recorded, not treated as no answer", () => {
|
|
219
|
+
// "Not installed" and "never asked" render the same, and that is deliberate — but
|
|
220
|
+
// the reducer still has to hold the difference, or a later dispatch could not undo it.
|
|
221
|
+
const state = run([
|
|
222
|
+
{ type: "MODELS_MATES_AVAILABLE", available: true },
|
|
223
|
+
{ type: "MODELS_MATES_AVAILABLE", available: false },
|
|
224
|
+
]);
|
|
225
|
+
expect(state.models.matesAvailable).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("it touches no other part of the models state", () => {
|
|
229
|
+
const before = run([
|
|
230
|
+
{ type: "MODELS_SELECT", index: 2 },
|
|
231
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
232
|
+
]);
|
|
233
|
+
const after = run(
|
|
234
|
+
[{ type: "MODELS_MATES_AVAILABLE", available: true }],
|
|
235
|
+
before,
|
|
236
|
+
);
|
|
237
|
+
expect(after.models.selectedIndex).toBe(2);
|
|
238
|
+
expect(after.models.data).toBe(before.models.data);
|
|
239
|
+
expect(after.styles).toBe(before.styles);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
197
243
|
describe("selection state", () => {
|
|
198
244
|
test("select records the index verbatim", () => {
|
|
199
245
|
expect(
|
|
@@ -253,7 +299,17 @@ describe("ModelsScreen keybindings", () => {
|
|
|
253
299
|
test("screen state is held in the reducer, never in useState", async () => {
|
|
254
300
|
// The rule the two "survives unmounting" tests above enforce at runtime,
|
|
255
301
|
// pinned at the source so a new piece of state cannot quietly opt out.
|
|
256
|
-
|
|
302
|
+
//
|
|
303
|
+
// Comments are stripped first, the way hook-import-policy.test.ts does it. The rule
|
|
304
|
+
// is about CODE, and this screen documents why each piece of its state lives in the
|
|
305
|
+
// reducer — naming the hook it is deliberately not using is the clearest way to say
|
|
306
|
+
// that, and a guard reading the prose would forbid the explanation along with the
|
|
307
|
+
// thing explained.
|
|
308
|
+
const src = stripComments(await fs.readFile(SCREEN, "utf8"));
|
|
257
309
|
expect(src).not.toContain("useState");
|
|
310
|
+
// Negative control: stripping must not have eaten the code being guarded. A guard
|
|
311
|
+
// over an empty string passes forever.
|
|
312
|
+
expect(src).toContain("useEffect");
|
|
313
|
+
expect(src).toContain("useCallback");
|
|
258
314
|
});
|
|
259
315
|
});
|
|
@@ -83,7 +83,7 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
83
83
|
"magus",
|
|
84
84
|
buildLocalMarketplace("magus", [
|
|
85
85
|
{
|
|
86
|
-
name: "code-
|
|
86
|
+
name: "code-search",
|
|
87
87
|
version: "5.3.0",
|
|
88
88
|
description: "Codebase investigation tools",
|
|
89
89
|
category: "development",
|
|
@@ -108,12 +108,10 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
108
108
|
|
|
109
109
|
expect(resolved.plugins).toHaveLength(2);
|
|
110
110
|
expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
|
|
111
|
-
"code-
|
|
111
|
+
"code-search",
|
|
112
112
|
"dev",
|
|
113
113
|
]);
|
|
114
|
-
const codeAnalysis = resolved.plugins.find(
|
|
115
|
-
(p) => p.name === "code-analysis",
|
|
116
|
-
);
|
|
114
|
+
const codeAnalysis = resolved.plugins.find((p) => p.name === "code-search");
|
|
117
115
|
expect(codeAnalysis?.version).toBe("5.3.0");
|
|
118
116
|
expect(codeAnalysis?.description).toBe("Codebase investigation tools");
|
|
119
117
|
expect(codeAnalysis?.category).toBe("development");
|
|
@@ -91,9 +91,9 @@ describe("resolveExtends", () => {
|
|
|
91
91
|
extends: "growth-marketer",
|
|
92
92
|
};
|
|
93
93
|
const merged = resolveExtends(entry);
|
|
94
|
-
// image
|
|
94
|
+
// image/video-editing ship on the magus-marketing channel and
|
|
95
95
|
// pin it explicitly — they must NOT be rewritten to @magus.
|
|
96
|
-
expect(merged.plugins!["image
|
|
96
|
+
expect(merged.plugins!["image@magus-marketing"]).toBe("latest");
|
|
97
97
|
expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
|
|
98
98
|
expect(merged.plugins!["image-generate@magus"]).toBeUndefined();
|
|
99
99
|
// Bare names still default to @magus.
|
|
@@ -302,7 +302,7 @@ describe("marketplaces derived from plugin ids", () => {
|
|
|
302
302
|
name: "Mixed",
|
|
303
303
|
plugins: {
|
|
304
304
|
"dev@magus": "latest",
|
|
305
|
-
"image
|
|
305
|
+
"image@magus-marketing": "latest",
|
|
306
306
|
"feature-dev@claude-plugins-official": "latest",
|
|
307
307
|
},
|
|
308
308
|
},
|
package/src/cli/doctor.ts
CHANGED
|
@@ -21,7 +21,6 @@ import { readManifest } from "../services/manifest.js";
|
|
|
21
21
|
import {
|
|
22
22
|
readModelsConfig,
|
|
23
23
|
readModelsStatus,
|
|
24
|
-
reapplyModels,
|
|
25
24
|
} from "../services/models-manager.js";
|
|
26
25
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
27
26
|
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
@@ -172,19 +171,15 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
|
|
|
172
171
|
);
|
|
173
172
|
}
|
|
174
173
|
|
|
174
|
+
// No drift check. Settings differing from the config is what a CUSTOM config is, and
|
|
175
|
+
// `doctor` reports problems — a project running models no shipped preset names has none.
|
|
176
|
+
// So `doctor --fix` no longer reapplies anything: `invalid` returns early above (those
|
|
177
|
+
// are choices, not damage) and `unhooked` is repaired by registering the hook. Writing
|
|
178
|
+
// settings back from the config is `claudeup models use <preset>`, which is a decision
|
|
179
|
+
// the user makes, not a repair.
|
|
175
180
|
const status = await readModelsStatus(projectPath);
|
|
176
|
-
if (status.state === "
|
|
177
|
-
console.log("
|
|
178
|
-
for (const line of status.drift) console.log(` ${line}`);
|
|
179
|
-
if (fix) {
|
|
180
|
-
await reapplyModels(projectPath);
|
|
181
|
-
console.log(" Re-applied the config (fixed).");
|
|
182
|
-
} else {
|
|
183
|
-
console.log(" Fix: claudeup doctor --fix");
|
|
184
|
-
problems++;
|
|
185
|
-
}
|
|
186
|
-
} else if (status.state === "on") {
|
|
187
|
-
console.log(` ✓ preset "${status.preset}" applied, no drift.`);
|
|
181
|
+
if (status.state === "on") {
|
|
182
|
+
console.log(" ✓ routing is applied.");
|
|
188
183
|
}
|
|
189
184
|
|
|
190
185
|
for (const warning of status.warnings) console.log(` ⚠ ${warning}`);
|
package/src/cli/models.ts
CHANGED
|
@@ -18,10 +18,20 @@ import {
|
|
|
18
18
|
presetLabel,
|
|
19
19
|
presetNames,
|
|
20
20
|
} from "../data/models-presets.js";
|
|
21
|
+
import {
|
|
22
|
+
loadMateCatalog,
|
|
23
|
+
stalenessNote,
|
|
24
|
+
unknownBindings,
|
|
25
|
+
} from "../services/mate-catalog.js";
|
|
21
26
|
import {
|
|
22
27
|
GRADES,
|
|
23
28
|
type ModelsConfig,
|
|
24
29
|
type ModelsState,
|
|
30
|
+
boundModel,
|
|
31
|
+
isMate,
|
|
32
|
+
mateEffort,
|
|
33
|
+
matesInUse,
|
|
34
|
+
sameRouting,
|
|
25
35
|
} from "../services/models-core.js";
|
|
26
36
|
import {
|
|
27
37
|
applyModelPreset,
|
|
@@ -35,13 +45,34 @@ import { ensureManifest } from "./bootstrap.js";
|
|
|
35
45
|
const HEADLINE: Record<ModelsState, string> = {
|
|
36
46
|
off: "off — no .claude/models.json, subagents inherit the session model",
|
|
37
47
|
on: "on",
|
|
38
|
-
stale: "stale — settings no longer match the config",
|
|
39
48
|
invalid: "invalid — nothing is routed",
|
|
40
49
|
unhooked: "unhooked — the config is there, but nothing runs it",
|
|
41
50
|
};
|
|
42
51
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
52
|
+
/**
|
|
53
|
+
* `opus (medium)`, or `opus` when no effort is set.
|
|
54
|
+
*
|
|
55
|
+
* A mate prints the SLOT and what it is bound to — `mate1 → grok-4.6` — because both are
|
|
56
|
+
* answers to different questions the reader has: which role this tier plays, and which model
|
|
57
|
+
* plays it. The slot alone was all that could be printed before the `mates` block existed,
|
|
58
|
+
* and a bare `kangaroo` in a column of `opus (xhigh)` read as a model id nobody recognises.
|
|
59
|
+
*
|
|
60
|
+
* An effort on a mate is qualified `(declared)`, for the reason spelled out at
|
|
61
|
+
* `DECLARED_SUFFIX`: claudeup writes it for claudish and nothing applies it yet, so printing
|
|
62
|
+
* a bare `(high)` beside a tier where `(high)` IS in force would claim they do the same
|
|
63
|
+
* thing.
|
|
64
|
+
*/
|
|
65
|
+
function describeSpec(
|
|
66
|
+
config: ModelsConfig,
|
|
67
|
+
spec: { model: string; effort?: string },
|
|
68
|
+
): string {
|
|
69
|
+
if (isMate(spec.model)) {
|
|
70
|
+
const bound = boundModel(config, spec.model);
|
|
71
|
+
const effort = spec.effort ?? mateEffort(config, spec.model);
|
|
72
|
+
const target = bound ?? "unbound";
|
|
73
|
+
const note = effort ? `${effort} declared, via claudish` : "via claudish";
|
|
74
|
+
return `${spec.model} → ${target} (${note})`;
|
|
75
|
+
}
|
|
45
76
|
return spec.effort ? `${spec.model} (${spec.effort})` : spec.model;
|
|
46
77
|
}
|
|
47
78
|
|
|
@@ -58,24 +89,82 @@ function printPreset(preset: ModelsConfig, active: boolean): void {
|
|
|
58
89
|
console.log(
|
|
59
90
|
`${mark} ${presetLabel(preset.preset)} — ${preset.preset}${isDefault}`,
|
|
60
91
|
);
|
|
61
|
-
console.log(` main ${describeSpec(preset.main)}`);
|
|
92
|
+
console.log(` main ${describeSpec(preset, preset.main)}`);
|
|
62
93
|
for (const grade of GRADES) {
|
|
63
94
|
console.log(
|
|
64
|
-
` ${grade.padEnd(10)} ${describeSpec(preset.grades[grade])}`,
|
|
95
|
+
` ${grade.padEnd(10)} ${describeSpec(preset, preset.grades[grade])}`,
|
|
65
96
|
);
|
|
66
97
|
}
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
async function runList(projectPath: string): Promise<number> {
|
|
70
101
|
const { config } = await readModelsConfig(projectPath);
|
|
102
|
+
|
|
103
|
+
// Has the project diverged from the built-in it NAMES?
|
|
104
|
+
//
|
|
105
|
+
// The test used to be "does it name a preset the built-ins do not", which misses the
|
|
106
|
+
// ordinary case: `claudeup models use opus-lead` writes that name, and every hand edit
|
|
107
|
+
// afterwards leaves it in place. So a config with a `mates` block was reported as the
|
|
108
|
+
// built-in `opus-lead`, and the `●` sat on a preset that is not what runs. Compared by
|
|
109
|
+
// CONTENT, shared with the TUI through `sameRouting` so the two cannot disagree about
|
|
110
|
+
// which routing is in force.
|
|
111
|
+
const builtInNamed = BUILT_IN_PRESETS.find(
|
|
112
|
+
(preset) => preset.preset === config?.preset,
|
|
113
|
+
);
|
|
114
|
+
const diverged =
|
|
115
|
+
config !== null && (!builtInNamed || !sameRouting(config, builtInNamed));
|
|
116
|
+
|
|
71
117
|
console.log("\nModel tiers:\n");
|
|
72
118
|
for (const preset of BUILT_IN_PRESETS) {
|
|
73
|
-
printPreset(preset, config?.preset === preset.preset);
|
|
119
|
+
printPreset(preset, !diverged && config?.preset === preset.preset);
|
|
74
120
|
console.log();
|
|
75
121
|
}
|
|
76
|
-
if (config &&
|
|
77
|
-
|
|
122
|
+
if (config && diverged) {
|
|
123
|
+
// The project's own config gets its tiers PRINTED, not just its name.
|
|
124
|
+
//
|
|
125
|
+
// It is the routing actually in force, and it is the only config here that can name a
|
|
126
|
+
// mate — no built-in does — so printing the name alone hid the one line a reader came
|
|
127
|
+
// for. The built-ins above are shown in full; showing the active one in less detail
|
|
128
|
+
// than the ones it replaced was backwards.
|
|
129
|
+
printPreset(config, true);
|
|
130
|
+
console.log(" (this project's own, not a built-in)\n");
|
|
78
131
|
}
|
|
132
|
+
|
|
133
|
+
// Which agents leave Claude Code entirely.
|
|
134
|
+
//
|
|
135
|
+
// `models list` prints tiers, never agents, so a mate assigned per-agent — the ordinary
|
|
136
|
+
// way to use one — appears nowhere else in this command's output. It is also the one
|
|
137
|
+
// routing decision here that claudeup does not carry out itself, which makes it exactly
|
|
138
|
+
// the thing worth naming rather than leaving to be discovered in the file.
|
|
139
|
+
const mated = Object.entries(config?.agents ?? {}).filter(
|
|
140
|
+
([, assignment]) =>
|
|
141
|
+
typeof assignment !== "string" && isMate(assignment.model),
|
|
142
|
+
);
|
|
143
|
+
if (config && mated.length > 0) {
|
|
144
|
+
console.log("Routed outside Claude Code, through claudish:\n");
|
|
145
|
+
for (const [agent, assignment] of mated) {
|
|
146
|
+
const slot =
|
|
147
|
+
typeof assignment === "string" ? assignment : assignment.model;
|
|
148
|
+
// The SLOT and the model it names. The slot is what the file says and what the
|
|
149
|
+
// reader greps for; the bound id is what actually runs. Printing only the slot was
|
|
150
|
+
// the CLI's version of the screen's `mate1 mate1 —` — a role where a model belongs.
|
|
151
|
+
const bound = isMate(slot) ? boundModel(config, slot) : null;
|
|
152
|
+
const target = bound ?? "unbound — add it to the mates block";
|
|
153
|
+
console.log(` ${agent.padEnd(28)} ${slot.padEnd(10)} ${target}`);
|
|
154
|
+
}
|
|
155
|
+
console.log();
|
|
156
|
+
|
|
157
|
+
// The staleness advisory, on the same terms as everywhere else: ADVISORY, and silent
|
|
158
|
+
// when the catalogue could not be read. `unknownBindings` returns nothing for an empty
|
|
159
|
+
// catalogue, so a machine without claudish prints no note rather than flagging every
|
|
160
|
+
// binding it cannot check.
|
|
161
|
+
const bound = matesInUse(config)
|
|
162
|
+
.map((mate) => boundModel(config, mate))
|
|
163
|
+
.filter((id): id is string => id !== null);
|
|
164
|
+
const note = stalenessNote(unknownBindings(bound, await loadMateCatalog()));
|
|
165
|
+
if (note) console.log(`⚠ ${note}\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
79
168
|
console.log(
|
|
80
169
|
"Tiers name what a subagent is FOR: `smart` takes the judgement calls, `cheap`",
|
|
81
170
|
);
|
|
@@ -97,11 +186,6 @@ async function runStatus(projectPath: string): Promise<number> {
|
|
|
97
186
|
if (status.drift.length > 0) {
|
|
98
187
|
console.log(status.state === "invalid" ? "\nErrors:" : "\nDrift:");
|
|
99
188
|
for (const line of status.drift) console.log(` ✗ ${line}`);
|
|
100
|
-
if (status.state === "stale") {
|
|
101
|
-
console.log(
|
|
102
|
-
"\n Fix: claudeup models use <preset> (or claudeup doctor --fix)",
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
189
|
if (status.state === "unhooked")
|
|
106
190
|
console.log("\n Fix: claudeup doctor --fix");
|
|
107
191
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `medium`) rather than a harder-working one. What holds everywhere is the other half: the
|
|
14
14
|
* thread dispatching the work is never strictly the biggest spend in the preset.
|
|
15
15
|
*/
|
|
16
|
-
import type { Grade, ModelsConfig } from "../services/models-core.js";
|
|
16
|
+
import type { Effort, Grade, ModelsConfig } from "../services/models-core.js";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Which tier each subagent gets, shared by every preset.
|
|
@@ -40,19 +40,18 @@ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
|
|
|
40
40
|
// what is true; both were on `smart` because "writes an important document" got confused
|
|
41
41
|
// with "makes an expensive judgement".
|
|
42
42
|
"dev:spec-writer": "normal",
|
|
43
|
-
"dev:
|
|
43
|
+
"dev:aggregator": "normal",
|
|
44
44
|
|
|
45
45
|
// Reading a lot is not analysing.
|
|
46
46
|
//
|
|
47
|
-
// `
|
|
47
|
+
// `analyze` is read-only: it locates implementations, traces a path and reports
|
|
48
48
|
// file:line. That is high-volume retrieval with light inference — the same shape as
|
|
49
49
|
// `Explore`, with more structure in the output. It sat on `smart` because "investigates"
|
|
50
50
|
// sounds like thinking; what it actually does is look.
|
|
51
|
-
"code-
|
|
51
|
+
"code-search:analyze": "cheap",
|
|
52
52
|
|
|
53
|
-
// Search
|
|
53
|
+
// Search and bookkeeping: bounded, mechanical, high-volume.
|
|
54
54
|
Explore: "cheap",
|
|
55
|
-
"dev:scribe": "cheap",
|
|
56
55
|
"dev:stack-detector": "cheap",
|
|
57
56
|
"dev:docs": "cheap",
|
|
58
57
|
"terminal:tui-navigator": "cheap",
|
|
@@ -69,32 +68,73 @@ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
|
|
|
69
68
|
* answers to "what will this preset cost me", and the lump sum shows neither.
|
|
70
69
|
*
|
|
71
70
|
* Read out of `plugins/dev/commands/{dev,debug,investigate}.md` — agents only. Those files
|
|
72
|
-
* also name skills (`dev:context-detection`, `code-
|
|
71
|
+
* also name skills (`dev:context-detection`, `code-search:investigate`) which are not
|
|
73
72
|
* dispatched through the Agent tool and so are not routed at all.
|
|
73
|
+
*
|
|
74
|
+
* ## `external` — the steps that leave Claude Code, whatever the preset says
|
|
75
|
+
*
|
|
76
|
+
* A workflow's use of an outside model is a property of the WORKFLOW, not of the routing
|
|
77
|
+
* config. `/dev:dev` runs a multi-model plan review at Phase 3 and a multi-model code review
|
|
78
|
+
* at Phase 5 because its command file says so, and switching from `Sonnet` to `Opus main`
|
|
79
|
+
* does not change that. So these steps are declared here beside the agents and are drawn
|
|
80
|
+
* under every preset, rather than being read out of `config.agents` — where they would
|
|
81
|
+
* appear only for a project that happened to have hand-bound a slot.
|
|
82
|
+
*
|
|
83
|
+
* Read out of the same command files:
|
|
84
|
+
* `plugins/dev/commands/dev.md:138` Plan review (external models via claudish) → dev:architect
|
|
85
|
+
* `plugins/dev/commands/dev.md:140` Code review (external models via claudish) → dev:architect
|
|
86
|
+
*
|
|
87
|
+
* `debug.md` and `investigate.md` contain no claudish reference at all, so both are empty
|
|
88
|
+
* here — and that absence is worth drawing, because "this flow stays inside Claude Code" is
|
|
89
|
+
* exactly as useful to know as the opposite.
|
|
90
|
+
*
|
|
91
|
+
* A STEP, not an agent: `dev:architect` appears in `agents` above as well, because the same
|
|
92
|
+
* agent does the Phase 3 planning on a Claude model. Marking the agent external would be
|
|
93
|
+
* wrong in both directions — it would move the planning outside and merge the two reviews
|
|
94
|
+
* into it.
|
|
74
95
|
*/
|
|
75
|
-
export
|
|
96
|
+
export interface WorkflowStep {
|
|
97
|
+
/** What the phase is called in the command file, for the reader who goes looking. */
|
|
98
|
+
step: string;
|
|
99
|
+
/** The agent the step dispatches. Recorded for provenance; routing is claudish's. */
|
|
100
|
+
agent: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Workflow {
|
|
104
|
+
name: string;
|
|
105
|
+
agents: string[];
|
|
106
|
+
external: WorkflowStep[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const WORKFLOWS: Workflow[] = [
|
|
76
110
|
{
|
|
77
111
|
name: "dev",
|
|
78
112
|
agents: [
|
|
79
113
|
"dev:stack-detector",
|
|
80
114
|
"dev:architect",
|
|
81
115
|
"dev:developer",
|
|
82
|
-
"dev:
|
|
116
|
+
"dev:qa-engineer",
|
|
83
117
|
"dev:reviewer",
|
|
84
118
|
],
|
|
119
|
+
external: [
|
|
120
|
+
{ step: "plan review", agent: "dev:architect" },
|
|
121
|
+
{ step: "code review", agent: "dev:architect" },
|
|
122
|
+
],
|
|
85
123
|
},
|
|
86
124
|
{
|
|
87
125
|
name: "debug",
|
|
88
126
|
agents: [
|
|
89
127
|
"dev:stack-detector",
|
|
90
|
-
"code-
|
|
128
|
+
"code-search:analyze",
|
|
91
129
|
"dev:debugger",
|
|
92
130
|
"dev:developer",
|
|
93
131
|
],
|
|
132
|
+
external: [],
|
|
94
133
|
},
|
|
95
134
|
{
|
|
96
135
|
name: "investigate",
|
|
97
|
-
agents: ["code-
|
|
136
|
+
agents: ["code-search:analyze", "dev:researcher"],
|
|
137
|
+
external: [],
|
|
98
138
|
},
|
|
99
139
|
];
|
|
100
140
|
|
|
@@ -102,7 +142,7 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
102
142
|
* Agents whose model is a CAPABILITY choice, not a spend choice.
|
|
103
143
|
*
|
|
104
144
|
* The three tiers answer "how much model", and that is the wrong question for an agent that
|
|
105
|
-
* reads images. `designer:
|
|
145
|
+
* reads images. `designer:review` compares a rendered screen against a reference and
|
|
106
146
|
* `designer:ui` audits a screenshot for usability: what they need is the model that SEES
|
|
107
147
|
* best, which is not a point on a cheap-to-smart line and does not move when the preset
|
|
108
148
|
* changes.
|
|
@@ -112,12 +152,27 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
112
152
|
* preset's `fallback`, which is a defensible default and an honest one; pinning them to a
|
|
113
153
|
* model id guessed here is neither.
|
|
114
154
|
*/
|
|
115
|
-
export const VISION_AGENTS = ["designer:
|
|
155
|
+
export const VISION_AGENTS = ["designer:review", "designer:ui"] as const;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The effort the external slots run at, per preset.
|
|
159
|
+
*
|
|
160
|
+
* A preset cannot say WHICH model serves a mate — a catalogue id shipped in this repo goes
|
|
161
|
+
* stale, which is the whole reason bindings live in a project's own `mates` block. It can say
|
|
162
|
+
* how hard that work should try, and that half does not depend on which model answers.
|
|
163
|
+
*
|
|
164
|
+
* `xhigh` almost everywhere: a slot is reached for the judgement calls — plan review, code
|
|
165
|
+
* review — and those are the calls worth spending on whichever model takes them. `Sonnet` is
|
|
166
|
+
* the exception because it is the economy preset, and a preset whose whole point is to spend
|
|
167
|
+
* less would be lying if the work it sent outside ignored that.
|
|
168
|
+
*/
|
|
169
|
+
const MATE_EFFORT_DEFAULT: Effort = "xhigh";
|
|
116
170
|
|
|
117
171
|
function preset(
|
|
118
172
|
name: string,
|
|
119
173
|
main: ModelsConfig["main"],
|
|
120
174
|
grades: ModelsConfig["grades"],
|
|
175
|
+
mateEffort: Effort = MATE_EFFORT_DEFAULT,
|
|
121
176
|
): ModelsConfig {
|
|
122
177
|
return {
|
|
123
178
|
version: 1,
|
|
@@ -126,6 +181,7 @@ function preset(
|
|
|
126
181
|
grades,
|
|
127
182
|
agents: { ...DEFAULT_AGENT_GRADES },
|
|
128
183
|
fallback: "normal",
|
|
184
|
+
mateEffort,
|
|
129
185
|
};
|
|
130
186
|
}
|
|
131
187
|
|
|
@@ -248,6 +304,8 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
|
|
|
248
304
|
// effort is not throttled on top of it. Every routed agent runs sonnet at `xhigh`,
|
|
249
305
|
// including `cheap` — so the hook still pins the model (a session started on opus does
|
|
250
306
|
// not leak into its subagents) while the tier table itself makes no distinction.
|
|
307
|
+
// The one preset whose mates run at `medium` rather than `xhigh`: an economy preset that
|
|
308
|
+
// spent freely the moment work left Claude Code would not be one.
|
|
251
309
|
preset(
|
|
252
310
|
"sonnet-economy",
|
|
253
311
|
{ model: "sonnet", effort: "xhigh" },
|
|
@@ -256,6 +314,7 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
|
|
|
256
314
|
normal: { model: "sonnet", effort: "xhigh" },
|
|
257
315
|
cheap: { model: "sonnet", effort: "xhigh" },
|
|
258
316
|
},
|
|
317
|
+
"medium",
|
|
259
318
|
),
|
|
260
319
|
];
|
|
261
320
|
|