claudeup 6.7.1 → 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__/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 +29 -2
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/data/models-presets.ts +62 -2
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The agent tier table
|
|
2
|
+
* The agent tier table and the workflow list are keyed by agent id, and nothing else
|
|
3
|
+
* checks those ids.
|
|
3
4
|
*
|
|
4
5
|
* `DEFAULT_AGENT_GRADES` in `data/models-presets.ts` names
|
|
5
6
|
* agents as `plugin:name`. A rename in the marketplace (`code-search:analyze` →
|
|
@@ -17,7 +18,11 @@
|
|
|
17
18
|
import { describe, expect, test } from "bun:test";
|
|
18
19
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
20
|
import { join, resolve } from "node:path";
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_AGENT_GRADES,
|
|
23
|
+
VISION_AGENTS,
|
|
24
|
+
WORKFLOWS,
|
|
25
|
+
} from "../data/models-presets.js";
|
|
21
26
|
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
22
27
|
|
|
23
28
|
const REPO = resolve(import.meta.dir, "..", "..", "..", "..");
|
|
@@ -100,6 +105,28 @@ describe.skipIf(!inMagusSrc)(
|
|
|
100
105
|
expect(dead).toEqual([]);
|
|
101
106
|
});
|
|
102
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
|
+
|
|
103
130
|
test("(b) every marketplace agent is classified somewhere", () => {
|
|
104
131
|
const classified = new Set<string>([
|
|
105
132
|
...presetKeys,
|
|
@@ -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
|
});
|
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.
|
|
@@ -70,8 +70,43 @@ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
|
|
|
70
70
|
* Read out of `plugins/dev/commands/{dev,debug,investigate}.md` — agents only. Those files
|
|
71
71
|
* also name skills (`dev:context-detection`, `code-search:investigate`) which are not
|
|
72
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.
|
|
73
95
|
*/
|
|
74
|
-
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[] = [
|
|
75
110
|
{
|
|
76
111
|
name: "dev",
|
|
77
112
|
agents: [
|
|
@@ -81,6 +116,10 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
81
116
|
"dev:qa-engineer",
|
|
82
117
|
"dev:reviewer",
|
|
83
118
|
],
|
|
119
|
+
external: [
|
|
120
|
+
{ step: "plan review", agent: "dev:architect" },
|
|
121
|
+
{ step: "code review", agent: "dev:architect" },
|
|
122
|
+
],
|
|
84
123
|
},
|
|
85
124
|
{
|
|
86
125
|
name: "debug",
|
|
@@ -90,10 +129,12 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
90
129
|
"dev:debugger",
|
|
91
130
|
"dev:developer",
|
|
92
131
|
],
|
|
132
|
+
external: [],
|
|
93
133
|
},
|
|
94
134
|
{
|
|
95
135
|
name: "investigate",
|
|
96
136
|
agents: ["code-search:analyze", "dev:researcher"],
|
|
137
|
+
external: [],
|
|
97
138
|
},
|
|
98
139
|
];
|
|
99
140
|
|
|
@@ -113,10 +154,25 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
113
154
|
*/
|
|
114
155
|
export const VISION_AGENTS = ["designer:review", "designer:ui"] as const;
|
|
115
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";
|
|
170
|
+
|
|
116
171
|
function preset(
|
|
117
172
|
name: string,
|
|
118
173
|
main: ModelsConfig["main"],
|
|
119
174
|
grades: ModelsConfig["grades"],
|
|
175
|
+
mateEffort: Effort = MATE_EFFORT_DEFAULT,
|
|
120
176
|
): ModelsConfig {
|
|
121
177
|
return {
|
|
122
178
|
version: 1,
|
|
@@ -125,6 +181,7 @@ function preset(
|
|
|
125
181
|
grades,
|
|
126
182
|
agents: { ...DEFAULT_AGENT_GRADES },
|
|
127
183
|
fallback: "normal",
|
|
184
|
+
mateEffort,
|
|
128
185
|
};
|
|
129
186
|
}
|
|
130
187
|
|
|
@@ -247,6 +304,8 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
|
|
|
247
304
|
// effort is not throttled on top of it. Every routed agent runs sonnet at `xhigh`,
|
|
248
305
|
// including `cheap` — so the hook still pins the model (a session started on opus does
|
|
249
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.
|
|
250
309
|
preset(
|
|
251
310
|
"sonnet-economy",
|
|
252
311
|
{ model: "sonnet", effort: "xhigh" },
|
|
@@ -255,6 +314,7 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
|
|
|
255
314
|
normal: { model: "sonnet", effort: "xhigh" },
|
|
256
315
|
cheap: { model: "sonnet", effort: "xhigh" },
|
|
257
316
|
},
|
|
317
|
+
"medium",
|
|
258
318
|
),
|
|
259
319
|
];
|
|
260
320
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is there anything on this machine that could serve a mate?
|
|
3
|
+
*
|
|
4
|
+
* A mate (`mate1`, `mate2`, `kangaroo`) names a third-party model reached through claudish,
|
|
5
|
+
* and claudish is reached through the `multimodel@magus` plugin. Without it, drawing the
|
|
6
|
+
* three slots on the Models screen advertises routing that cannot happen — so the renderers
|
|
7
|
+
* take this as a boolean and draw them only when it is true.
|
|
8
|
+
*
|
|
9
|
+
* ## Three things this module deliberately is NOT
|
|
10
|
+
*
|
|
11
|
+
* It is not in `models-core.ts`. That module is pure — no fs, no process, no network — and
|
|
12
|
+
* has to stay that way: Claude Code runs the hook on every single Agent call, and the hook
|
|
13
|
+
* imports the core.
|
|
14
|
+
*
|
|
15
|
+
* It is not in the renderers either. Answering the question needs the plugin registry, which
|
|
16
|
+
* is the adapter layer's to read, and a renderer that reached for it could not be tested
|
|
17
|
+
* without a machine to read.
|
|
18
|
+
*
|
|
19
|
+
* And it is not part of VALIDATION. A committed `models.json` naming `mate1` is valid
|
|
20
|
+
* everywhere, whether or not the plugin is installed — see `validateModelsConfig`. Gating
|
|
21
|
+
* validity on a local install would make one file valid on one teammate's laptop and invalid
|
|
22
|
+
* on the next one's.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the registry is read through `plugin-manager`
|
|
25
|
+
*
|
|
26
|
+
* `installed_plugins.json`, `enabledPlugins` and `installedPluginVersions` are Claude Code's
|
|
27
|
+
* to own, and the last of those is maintained by nothing but claudeup, so it goes stale
|
|
28
|
+
* silently. `getAvailablePlugins` resolves all three scopes and hands back both facts per
|
|
29
|
+
* scope, which is all this module takes from it.
|
|
30
|
+
*
|
|
31
|
+
* The test on those facts is `servesAMate`, below, and it is deliberately NOT
|
|
32
|
+
* `plugin-manager`'s `isInstalledInScope` — that one answers a narrower question. See the
|
|
33
|
+
* comment on the predicate for why the two must not share a helper.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
type PluginInfo,
|
|
38
|
+
type ScopeStatus,
|
|
39
|
+
getAvailablePlugins,
|
|
40
|
+
} from "./plugin-manager.js";
|
|
41
|
+
|
|
42
|
+
/** The plugin that puts claudish's model tools in reach. */
|
|
43
|
+
export const MATE_PLUGIN_ID = "multimodel@magus";
|
|
44
|
+
|
|
45
|
+
/** How the plugins are listed. A seam, for the reason below. */
|
|
46
|
+
export type PluginLister = (projectPath?: string) => Promise<PluginInfo[]>;
|
|
47
|
+
|
|
48
|
+
/** The env var that forces the slots on, and the values it accepts. */
|
|
49
|
+
export const MATE_FORCE_ENV = "CLAUDEUP_MATES";
|
|
50
|
+
const FORCE_ON = new Set(["1", "true", "on", "yes"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Draw the slots regardless of what is installed: `CLAUDEUP_MATES=1 claudeup`.
|
|
54
|
+
*
|
|
55
|
+
* The three rows ARE the feature, and on a machine without `multimodel@magus` there is no way
|
|
56
|
+
* to put them on screen — which leaves the one part of this layout that cannot be checked by
|
|
57
|
+
* eye being the one most likely to be wrong, since `kangaroo` is eight cells and every column
|
|
58
|
+
* here was sized for six.
|
|
59
|
+
*
|
|
60
|
+
* Read at CALL time, never captured at module load. Bun loads a cwd `.env` before user code
|
|
61
|
+
* runs, so a value read at import is a snapshot of whichever directory the process started in
|
|
62
|
+
* — and this process is a TUI a user starts from anywhere.
|
|
63
|
+
*
|
|
64
|
+
* It forces the ANSWER and nothing else. No plugin appears because a flag was set, so a mate
|
|
65
|
+
* still passes straight through the hook and the agent still runs on whatever it would have
|
|
66
|
+
* run on. The flag moves pixels, not routing.
|
|
67
|
+
*/
|
|
68
|
+
export function matesForced(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
69
|
+
const raw = env[MATE_FORCE_ENV];
|
|
70
|
+
return raw !== undefined && FORCE_ON.has(raw.trim().toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when `multimodel@magus` is installed in ANY scope.
|
|
75
|
+
*
|
|
76
|
+
* Any scope, because a mate is served by whatever Claude Code has loaded when the agent is
|
|
77
|
+
* spawned, and that is the union of the three — a user-scope install serves a project that
|
|
78
|
+
* enables nothing of its own.
|
|
79
|
+
*
|
|
80
|
+
* FAILS CLOSED. This reaches the marketplace resolution path, which does network work and can
|
|
81
|
+
* time out; a failure means "we do not know", and the honest rendering of not knowing is the
|
|
82
|
+
* screen exactly as it was before mates existed. Returning true on a failure would put three
|
|
83
|
+
* rows on screen on the strength of an error.
|
|
84
|
+
*
|
|
85
|
+
* `listPlugins` is injected the same way `buildSettingsPatch` takes its `resolveFullId`: the
|
|
86
|
+
* DECISION here — which scopes count, what an enabled-but-not-installed plugin means, what a
|
|
87
|
+
* thrown error means — is the part worth testing, and it cannot be tested through a function
|
|
88
|
+
* that reaches the network and a real machine's registry. The default is the real one, so no
|
|
89
|
+
* caller has to know the seam exists.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Can this scope actually serve a mate? Both facts, not either one.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately NOT `isInstalledInScope`, which asks a narrower question: that one means the
|
|
95
|
+
* files are on disk, and says so — it dropped `enabled` from its test on purpose, so the
|
|
96
|
+
* plugin list would stop offering to install what was already there.
|
|
97
|
+
*
|
|
98
|
+
* Presence on disk is not what this module needs. A plugin the user has switched off is not
|
|
99
|
+
* loaded, so claudish is not reachable through it, so the routing a mate row advertises
|
|
100
|
+
* cannot happen — which is the one thing these rows must never claim. The other half matters
|
|
101
|
+
* for the mirror-image state: a settings flag with no registry version is the broken
|
|
102
|
+
* enabled-but-not-installed case, and a row drawn on the strength of it promises a plugin
|
|
103
|
+
* that never loaded.
|
|
104
|
+
*
|
|
105
|
+
* So both, and locally, rather than reaching for a shared helper whose meaning is owned by a
|
|
106
|
+
* different question.
|
|
107
|
+
*/
|
|
108
|
+
function servesAMate(scope: ScopeStatus | undefined): boolean {
|
|
109
|
+
return !!scope?.enabled && !!scope.version;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function areMatesAvailable(
|
|
113
|
+
projectPath?: string,
|
|
114
|
+
listPlugins: PluginLister = getAvailablePlugins,
|
|
115
|
+
): Promise<boolean> {
|
|
116
|
+
// Before the registry, so the flag also skips the network work behind it. Someone who
|
|
117
|
+
// set it has already said what answer they want; making them wait for a lookup whose
|
|
118
|
+
// result is discarded would be the slowest possible way to agree with them.
|
|
119
|
+
if (matesForced()) return true;
|
|
120
|
+
try {
|
|
121
|
+
const plugin = (await listPlugins(projectPath)).find(
|
|
122
|
+
(candidate) => candidate.id === MATE_PLUGIN_ID,
|
|
123
|
+
);
|
|
124
|
+
if (!plugin) return false;
|
|
125
|
+
return (
|
|
126
|
+
servesAMate(plugin.userScope) ||
|
|
127
|
+
servesAMate(plugin.projectScope) ||
|
|
128
|
+
servesAMate(plugin.localScope)
|
|
129
|
+
);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|