refactor: standardize level names across all roles (#195 phase 1)

Rename levels to use consistent industry-standard terminology:
- dev: medior → mid
- qa: reviewer → mid, tester → junior, add senior level
- architect: opus → senior, sonnet → junior

Add backward-compatible migration for projects.json and openclaw.json
config via level aliases in selectors and projects parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lauren ten Hoor
2026-02-15 17:01:16 +08:00
parent 01ad5d326c
commit 6a99752e5f
24 changed files with 273 additions and 162 deletions

View File

@@ -13,6 +13,8 @@ export {
isValidRole,
getRole,
requireRole,
// Level aliases
canonicalLevel,
// Levels
getLevelsForRole,
getAllLevels,

View File

@@ -18,6 +18,7 @@ import {
getDefaultModel,
getAllDefaultModels,
resolveModel,
canonicalLevel,
getEmoji,
getFallbackEmoji,
getCompletionResults,
@@ -54,9 +55,9 @@ describe("role registry", () => {
describe("levels", () => {
it("should return levels for each role", () => {
assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "medior", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("qa")], ["reviewer", "tester"]);
assert.deepStrictEqual([...getLevelsForRole("architect")], ["opus", "sonnet"]);
assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "mid", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("qa")], ["junior", "mid", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]);
});
it("should return empty for unknown role", () => {
@@ -66,35 +67,62 @@ describe("levels", () => {
it("should return all levels", () => {
const all = getAllLevels();
assert.ok(all.includes("junior"));
assert.ok(all.includes("reviewer"));
assert.ok(all.includes("opus"));
assert.ok(all.includes("mid"));
assert.ok(all.includes("senior"));
});
it("should check level membership", () => {
assert.strictEqual(isLevelForRole("junior", "dev"), true);
assert.strictEqual(isLevelForRole("junior", "qa"), false);
assert.strictEqual(isLevelForRole("opus", "architect"), true);
assert.strictEqual(isLevelForRole("junior", "qa"), true);
assert.strictEqual(isLevelForRole("junior", "architect"), true);
assert.strictEqual(isLevelForRole("mid", "dev"), true);
assert.strictEqual(isLevelForRole("mid", "architect"), false);
});
it("should find role for level", () => {
// "junior" appears in dev first (registry order)
assert.strictEqual(roleForLevel("junior"), "dev");
assert.strictEqual(roleForLevel("reviewer"), "qa");
assert.strictEqual(roleForLevel("opus"), "architect");
assert.strictEqual(roleForLevel("mid"), "dev");
assert.strictEqual(roleForLevel("senior"), "dev");
assert.strictEqual(roleForLevel("nonexistent"), undefined);
});
it("should return default level", () => {
assert.strictEqual(getDefaultLevel("dev"), "medior");
assert.strictEqual(getDefaultLevel("qa"), "reviewer");
assert.strictEqual(getDefaultLevel("architect"), "sonnet");
assert.strictEqual(getDefaultLevel("dev"), "mid");
assert.strictEqual(getDefaultLevel("qa"), "mid");
assert.strictEqual(getDefaultLevel("architect"), "junior");
});
});
describe("level aliases", () => {
it("should map old dev level names", () => {
assert.strictEqual(canonicalLevel("dev", "medior"), "mid");
assert.strictEqual(canonicalLevel("dev", "junior"), "junior");
assert.strictEqual(canonicalLevel("dev", "senior"), "senior");
});
it("should map old qa level names", () => {
assert.strictEqual(canonicalLevel("qa", "reviewer"), "mid");
assert.strictEqual(canonicalLevel("qa", "tester"), "junior");
});
it("should map old architect level names", () => {
assert.strictEqual(canonicalLevel("architect", "opus"), "senior");
assert.strictEqual(canonicalLevel("architect", "sonnet"), "junior");
});
it("should pass through unknown levels", () => {
assert.strictEqual(canonicalLevel("dev", "custom"), "custom");
assert.strictEqual(canonicalLevel("unknown", "whatever"), "whatever");
});
});
describe("models", () => {
it("should return default models", () => {
assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5");
assert.strictEqual(getDefaultModel("qa", "reviewer"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("architect", "opus"), "anthropic/claude-opus-4-5");
assert.strictEqual(getDefaultModel("dev", "mid"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("qa", "mid"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
});
it("should return all default models", () => {
@@ -117,12 +145,25 @@ describe("models", () => {
it("should pass through unknown level as model ID", () => {
assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5");
});
it("should resolve old config keys via aliases", () => {
// Old config uses "medior" key — should still resolve
const config = { models: { dev: { medior: "custom/old-config-model" } } };
assert.strictEqual(resolveModel("dev", "medior", config), "custom/old-config-model");
// Also works when requesting the canonical name
assert.strictEqual(resolveModel("dev", "mid", {}), "anthropic/claude-sonnet-4-5");
});
it("should resolve old qa config keys", () => {
const config = { models: { qa: { reviewer: "custom/qa-model" } } };
assert.strictEqual(resolveModel("qa", "reviewer", config), "custom/qa-model");
});
});
describe("emoji", () => {
it("should return level emoji", () => {
assert.strictEqual(getEmoji("dev", "junior"), "⚡");
assert.strictEqual(getEmoji("architect", "opus"), "🏗️");
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
});
it("should return fallback emoji", () => {

View File

@@ -17,16 +17,16 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
dev: {
id: "dev",
displayName: "DEV",
levels: ["junior", "medior", "senior"],
defaultLevel: "medior",
levels: ["junior", "mid", "senior"],
defaultLevel: "mid",
models: {
junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5",
mid: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
},
emoji: {
junior: "⚡",
medior: "🔧",
mid: "🔧",
senior: "🧠",
},
fallbackEmoji: "🔧",
@@ -38,15 +38,17 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
qa: {
id: "qa",
displayName: "QA",
levels: ["reviewer", "tester"],
defaultLevel: "reviewer",
levels: ["junior", "mid", "senior"],
defaultLevel: "mid",
models: {
reviewer: "anthropic/claude-sonnet-4-5",
tester: "anthropic/claude-haiku-4-5",
junior: "anthropic/claude-haiku-4-5",
mid: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
},
emoji: {
reviewer: "🔍",
tester: "👀",
junior: "",
mid: "🔍",
senior: "🧠",
},
fallbackEmoji: "🔍",
completionResults: ["pass", "fail", "refine", "blocked"],
@@ -57,15 +59,15 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
architect: {
id: "architect",
displayName: "ARCHITECT",
levels: ["opus", "sonnet"],
defaultLevel: "sonnet",
levels: ["junior", "senior"],
defaultLevel: "junior",
models: {
opus: "anthropic/claude-opus-4-5",
sonnet: "anthropic/claude-sonnet-4-5",
junior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
},
emoji: {
opus: "🏗️",
sonnet: "📐",
junior: "📐",
senior: "🏗️",
},
fallbackEmoji: "🏗️",
completionResults: ["done", "blocked"],

View File

@@ -36,6 +36,21 @@ export function requireRole(role: string): RoleConfig {
return config;
}
// ---------------------------------------------------------------------------
// Level aliases — maps old level names to new canonical names
// ---------------------------------------------------------------------------
const LEVEL_ALIASES: Record<string, Record<string, string>> = {
dev: { medior: "mid" },
qa: { reviewer: "mid", tester: "junior" },
architect: { opus: "senior", sonnet: "junior" },
};
/** Resolve a level name, applying aliases for backward compatibility. */
export function canonicalLevel(role: string, level: string): string {
return LEVEL_ALIASES[role]?.[level] ?? level;
}
// ---------------------------------------------------------------------------
// Levels
// ---------------------------------------------------------------------------
@@ -90,7 +105,7 @@ export function getAllDefaultModels(): Record<string, Record<string, string>> {
* Resolve a level to a full model ID.
*
* Resolution order:
* 1. Plugin config `models.<role>.<level>`
* 1. Plugin config `models.<role>.<level>` (tries canonical name, then original)
* 2. Registry default model
* 3. Passthrough (treat level as raw model ID)
*/
@@ -99,12 +114,15 @@ export function resolveModel(
level: string,
pluginConfig?: Record<string, unknown>,
): string {
const canonical = canonicalLevel(role, level);
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
if (models && typeof models === "object") {
const roleModels = models[role] as Record<string, string> | undefined;
// Try canonical name first, then original (for old configs)
if (roleModels?.[canonical]) return roleModels[canonical];
if (roleModels?.[level]) return roleModels[level];
}
return getDefaultModel(role, level) ?? level;
return getDefaultModel(role, canonical) ?? canonical;
}
// ---------------------------------------------------------------------------