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

@@ -26,31 +26,32 @@ const plugin = {
properties: { properties: {
models: { models: {
type: "object", type: "object",
description: "Model mapping per role and tier", description: "Model mapping per role and level",
properties: { properties: {
dev: { dev: {
type: "object", type: "object",
description: "Developer tier models", description: "Developer level models",
properties: { properties: {
junior: { type: "string" }, junior: { type: "string" },
medior: { type: "string" }, mid: { type: "string" },
senior: { type: "string" }, senior: { type: "string" },
}, },
}, },
qa: { qa: {
type: "object", type: "object",
description: "QA tier models", description: "QA level models",
properties: { properties: {
reviewer: { type: "string" }, junior: { type: "string" },
tester: { type: "string" }, mid: { type: "string" },
senior: { type: "string" },
}, },
}, },
architect: { architect: {
type: "object", type: "object",
description: "Architect tier models", description: "Architect level models",
properties: { properties: {
opus: { type: "string" }, junior: { type: "string" },
sonnet: { type: "string" }, senior: { type: "string" },
}, },
}, },
}, },

View File

@@ -11,12 +11,12 @@ import os from "node:os";
describe("parseDevClawSessionKey", () => { describe("parseDevClawSessionKey", () => {
it("should parse a standard dev session key", () => { it("should parse a standard dev session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-medior"); const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-mid");
assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" }); assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" });
}); });
it("should parse a qa session key", () => { it("should parse a qa session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-reviewer"); const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-mid");
assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" }); assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" });
}); });
@@ -26,7 +26,7 @@ describe("parseDevClawSessionKey", () => {
}); });
it("should handle project names with multiple hyphens and qa role", () => { it("should handle project names with multiple hyphens and qa role", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-tester"); const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-junior");
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" }); assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" });
}); });

View File

@@ -17,8 +17,8 @@ import { getSessionKeyRolePattern } from "./roles/index.js";
* *
* Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}` * Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
* Examples: * Examples:
* - `agent:devclaw:subagent:my-project-dev-medior` → { projectName: "my-project", role: "dev" } * - `agent:devclaw:subagent:my-project-dev-mid` → { projectName: "my-project", role: "dev" }
* - `agent:devclaw:subagent:webapp-qa-reviewer` → { projectName: "webapp", role: "qa" } * - `agent:devclaw:subagent:webapp-qa-mid` → { projectName: "webapp", role: "qa" }
* *
* Note: projectName may contain hyphens, so we match role from the end. * Note: projectName may contain hyphens, so we match role from the end.
*/ */

View File

@@ -6,7 +6,8 @@
import type { Command } from "commander"; import type { Command } from "commander";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { runSetup } from "./setup/index.js"; import { runSetup } from "./setup/index.js";
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "./tiers.js"; import { DEFAULT_MODELS } from "./tiers.js";
import { getLevelsForRole } from "./roles/index.js";
/** /**
* Register the `devclaw` CLI command group on a Commander program. * Register the `devclaw` CLI command group on a Commander program.
@@ -23,18 +24,20 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
.option("--agent <id>", "Use an existing agent by ID") .option("--agent <id>", "Use an existing agent by ID")
.option("--workspace <path>", "Direct workspace path") .option("--workspace <path>", "Direct workspace path")
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`) .option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`)
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`) .option("--mid <model>", `Mid dev model (default: ${DEFAULT_MODELS.dev.mid})`)
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`) .option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`)
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`) .option("--qa-junior <model>", `QA junior model (default: ${DEFAULT_MODELS.qa.junior})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`) .option("--qa-mid <model>", `QA mid model (default: ${DEFAULT_MODELS.qa.mid})`)
.option("--qa-senior <model>", `QA senior model (default: ${DEFAULT_MODELS.qa.senior})`)
.action(async (opts) => { .action(async (opts) => {
const dev: Record<string, string> = {}; const dev: Record<string, string> = {};
const qa: Record<string, string> = {}; const qa: Record<string, string> = {};
if (opts.junior) dev.junior = opts.junior; if (opts.junior) dev.junior = opts.junior;
if (opts.medior) dev.medior = opts.medior; if (opts.mid) dev.mid = opts.mid;
if (opts.senior) dev.senior = opts.senior; if (opts.senior) dev.senior = opts.senior;
if (opts.reviewer) qa.reviewer = opts.reviewer; if (opts.qaJunior) qa.junior = opts.qaJunior;
if (opts.tester) qa.tester = opts.tester; if (opts.qaMid) qa.mid = opts.qaMid;
if (opts.qaSenior) qa.senior = opts.qaSenior;
const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0; const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0;
const models = hasOverrides const models = hasOverrides
@@ -54,8 +57,9 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
} }
console.log("Models configured:"); console.log("Models configured:");
for (const t of DEV_LEVELS) console.log(` dev.${t}: ${result.models.dev[t]}`); for (const t of getLevelsForRole("dev")) console.log(` dev.${t}: ${result.models.dev[t]}`);
for (const t of QA_LEVELS) console.log(` qa.${t}: ${result.models.qa[t]}`); for (const t of getLevelsForRole("qa")) console.log(` qa.${t}: ${result.models.qa[t]}`);
for (const t of getLevelsForRole("architect")) console.log(` architect.${t}: ${result.models.architect[t]}`);
console.log("Files written:"); console.log("Files written:");
for (const file of result.filesWritten) { for (const file of result.filesWritten) {

View File

@@ -26,7 +26,7 @@ export type DispatchOpts = {
issueDescription: string; issueDescription: string;
issueUrl: string; issueUrl: string;
role: "dev" | "qa" | "architect"; role: "dev" | "qa" | "architect";
/** Developer level (junior, medior, senior, reviewer, opus, sonnet) or raw model ID */ /** Developer level (junior, mid, senior) or raw model ID */
level: string; level: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
fromLabel: string; fromLabel: string;

View File

@@ -1,7 +1,7 @@
/** /**
* Model selection for dev/qa tasks. * Model selection for dev/qa tasks.
* Keyword heuristic fallback — used when the orchestrator doesn't specify a level. * Keyword heuristic fallback — used when the orchestrator doesn't specify a level.
* Returns plain level names (junior, medior, senior, reviewer, tester). * Returns plain level names (junior, mid, senior).
*/ */
export type LevelSelection = { export type LevelSelection = {
@@ -41,12 +41,10 @@ const COMPLEX_KEYWORDS = [
/** /**
* Select appropriate developer level based on task description. * Select appropriate developer level based on task description.
* *
* Developer levels: * All roles use consistent levels:
* - junior: very simple (typos, single-file fixes, CSS tweaks) * - junior: simple tasks (typos, single-file fixes, CSS tweaks)
* - medior: standard DEV (features, bug fixes, multi-file changes) * - mid: standard work (features, bug fixes, multi-file changes)
* - senior: deep/architectural (system-wide refactoring, novel design) * - senior: deep/architectural (system-wide refactoring, novel design)
* - reviewer: QA code inspection and validation
* - tester: QA manual testing
*/ */
export function selectLevel( export function selectLevel(
issueTitle: string, issueTitle: string,
@@ -55,7 +53,7 @@ export function selectLevel(
): LevelSelection { ): LevelSelection {
if (role === "qa") { if (role === "qa") {
return { return {
level: "reviewer", level: "mid",
reason: "Default QA level for code inspection and validation", reason: "Default QA level for code inspection and validation",
}; };
} }
@@ -64,10 +62,10 @@ export function selectLevel(
const text = `${issueTitle} ${issueDescription}`.toLowerCase(); const text = `${issueTitle} ${issueDescription}`.toLowerCase();
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
return { return {
level: isComplex ? "opus" : "sonnet", level: isComplex ? "senior" : "junior",
reason: isComplex reason: isComplex
? "Complex design task — using opus for depth" ? "Complex design task — using senior for depth"
: "Standard design task — using sonnet", : "Standard design task — using junior",
}; };
} }
@@ -92,9 +90,9 @@ export function selectLevel(
}; };
} }
// Default: medior for standard dev work // Default: mid for standard dev work
return { return {
level: "medior", level: "mid",
reason: "Standard dev task — multi-file changes, features, bug fixes", reason: "Standard dev task — multi-file changes, features, bug fixes",
}; };
} }

View File

@@ -79,11 +79,9 @@ export function buildOnboardToolContext(): string {
// Build the model table dynamically from DEFAULT_MODELS // Build the model table dynamically from DEFAULT_MODELS
const rows: string[] = []; const rows: string[] = [];
const purposes: Record<string, string> = { const purposes: Record<string, string> = {
junior: "Typos, single-file fixes", junior: "Simple tasks, single-file fixes",
medior: "Features, bug fixes", mid: "Features, bug fixes, code review",
senior: "Architecture, refactoring", senior: "Architecture, refactoring, complex tasks",
reviewer: "Code review",
tester: "Testing",
}; };
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
for (const [level, model] of Object.entries(levels)) { for (const [level, model] of Object.entries(levels)) {
@@ -97,7 +95,7 @@ export function buildOnboardToolContext(): string {
## What is DevClaw? ## What is DevClaw?
DevClaw turns each Telegram group into an autonomous development team: DevClaw turns each Telegram group into an autonomous development team:
- An **orchestrator** that manages backlogs and delegates work - An **orchestrator** that manages backlogs and delegates work
- **DEV workers** (junior/medior/senior levels) that write code in isolated sessions - **DEV workers** (junior/mid/senior levels) that write code in isolated sessions
- **QA workers** that review code and run tests - **QA workers** that review code and run tests
- Atomic tools for label transitions, session dispatch, state management, and audit logging - Atomic tools for label transitions, session dispatch, state management, and audit logging

View File

@@ -37,13 +37,44 @@ export type ProjectsData = {
projects: Record<string, Project>; projects: Record<string, Project>;
}; };
function parseWorkerState(worker: Record<string, unknown>): WorkerState { /**
* Level migration aliases: old name → new canonical name, keyed by role.
*/
const LEVEL_MIGRATION: Record<string, Record<string, string>> = {
dev: { medior: "mid" },
qa: { reviewer: "mid", tester: "junior" },
architect: { opus: "senior", sonnet: "junior" },
};
function migrateLevel(level: string | null, role: string): string | null {
if (!level) return null;
return LEVEL_MIGRATION[role]?.[level] ?? level;
}
function migrateSessions(
sessions: Record<string, string | null>,
role: string,
): Record<string, string | null> {
const aliases = LEVEL_MIGRATION[role];
if (!aliases) return sessions;
const migrated: Record<string, string | null> = {};
for (const [key, value] of Object.entries(sessions)) {
const newKey = aliases[key] ?? key;
migrated[newKey] = value;
}
return migrated;
}
function parseWorkerState(worker: Record<string, unknown>, role: string): WorkerState {
const level = (worker.level ?? worker.tier ?? null) as string | null;
const sessions = (worker.sessions as Record<string, string | null>) ?? {};
return { return {
active: worker.active as boolean, active: worker.active as boolean,
issueId: worker.issueId as string | null, issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null, startTime: worker.startTime as string | null,
level: (worker.level ?? worker.tier ?? null) as string | null, level: migrateLevel(level, role),
sessions: (worker.sessions as Record<string, string | null>) ?? {}, sessions: migrateSessions(sessions, role),
}; };
} }
@@ -84,13 +115,13 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
for (const project of Object.values(data.projects)) { for (const project of Object.values(data.projects)) {
project.dev = project.dev project.dev = project.dev
? parseWorkerState(project.dev as unknown as Record<string, unknown>) ? parseWorkerState(project.dev as unknown as Record<string, unknown>, "dev")
: emptyWorkerState([]); : emptyWorkerState([]);
project.qa = project.qa project.qa = project.qa
? parseWorkerState(project.qa as unknown as Record<string, unknown>) ? parseWorkerState(project.qa as unknown as Record<string, unknown>, "qa")
: emptyWorkerState([]); : emptyWorkerState([]);
project.architect = project.architect project.architect = project.architect
? parseWorkerState(project.architect as unknown as Record<string, unknown>) ? parseWorkerState(project.architect as unknown as Record<string, unknown>, "architect")
: emptyWorkerState([]); : emptyWorkerState([]);
if (!project.channel) { if (!project.channel) {
project.channel = "telegram"; project.channel = "telegram";

View File

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

View File

@@ -18,6 +18,7 @@ import {
getDefaultModel, getDefaultModel,
getAllDefaultModels, getAllDefaultModels,
resolveModel, resolveModel,
canonicalLevel,
getEmoji, getEmoji,
getFallbackEmoji, getFallbackEmoji,
getCompletionResults, getCompletionResults,
@@ -54,9 +55,9 @@ describe("role registry", () => {
describe("levels", () => { describe("levels", () => {
it("should return levels for each role", () => { it("should return levels for each role", () => {
assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "mid", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("qa")], ["reviewer", "tester"]); assert.deepStrictEqual([...getLevelsForRole("qa")], ["junior", "mid", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("architect")], ["opus", "sonnet"]); assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]);
}); });
it("should return empty for unknown role", () => { it("should return empty for unknown role", () => {
@@ -66,35 +67,62 @@ describe("levels", () => {
it("should return all levels", () => { it("should return all levels", () => {
const all = getAllLevels(); const all = getAllLevels();
assert.ok(all.includes("junior")); assert.ok(all.includes("junior"));
assert.ok(all.includes("reviewer")); assert.ok(all.includes("mid"));
assert.ok(all.includes("opus")); assert.ok(all.includes("senior"));
}); });
it("should check level membership", () => { it("should check level membership", () => {
assert.strictEqual(isLevelForRole("junior", "dev"), true); assert.strictEqual(isLevelForRole("junior", "dev"), true);
assert.strictEqual(isLevelForRole("junior", "qa"), false); assert.strictEqual(isLevelForRole("junior", "qa"), true);
assert.strictEqual(isLevelForRole("opus", "architect"), 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", () => { it("should find role for level", () => {
// "junior" appears in dev first (registry order)
assert.strictEqual(roleForLevel("junior"), "dev"); assert.strictEqual(roleForLevel("junior"), "dev");
assert.strictEqual(roleForLevel("reviewer"), "qa"); assert.strictEqual(roleForLevel("mid"), "dev");
assert.strictEqual(roleForLevel("opus"), "architect"); assert.strictEqual(roleForLevel("senior"), "dev");
assert.strictEqual(roleForLevel("nonexistent"), undefined); assert.strictEqual(roleForLevel("nonexistent"), undefined);
}); });
it("should return default level", () => { it("should return default level", () => {
assert.strictEqual(getDefaultLevel("dev"), "medior"); assert.strictEqual(getDefaultLevel("dev"), "mid");
assert.strictEqual(getDefaultLevel("qa"), "reviewer"); assert.strictEqual(getDefaultLevel("qa"), "mid");
assert.strictEqual(getDefaultLevel("architect"), "sonnet"); 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", () => { describe("models", () => {
it("should return default models", () => { it("should return default models", () => {
assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5"); assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5");
assert.strictEqual(getDefaultModel("qa", "reviewer"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("dev", "mid"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("architect", "opus"), "anthropic/claude-opus-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", () => { it("should return all default models", () => {
@@ -117,12 +145,25 @@ describe("models", () => {
it("should pass through unknown level as model ID", () => { it("should pass through unknown level as model ID", () => {
assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); 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", () => { describe("emoji", () => {
it("should return level emoji", () => { it("should return level emoji", () => {
assert.strictEqual(getEmoji("dev", "junior"), "⚡"); assert.strictEqual(getEmoji("dev", "junior"), "⚡");
assert.strictEqual(getEmoji("architect", "opus"), "🏗️"); assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
}); });
it("should return fallback emoji", () => { it("should return fallback emoji", () => {

View File

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

View File

@@ -36,6 +36,21 @@ export function requireRole(role: string): RoleConfig {
return config; 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 // Levels
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -90,7 +105,7 @@ export function getAllDefaultModels(): Record<string, Record<string, string>> {
* Resolve a level to a full model ID. * Resolve a level to a full model ID.
* *
* Resolution order: * Resolution order:
* 1. Plugin config `models.<role>.<level>` * 1. Plugin config `models.<role>.<level>` (tries canonical name, then original)
* 2. Registry default model * 2. Registry default model
* 3. Passthrough (treat level as raw model ID) * 3. Passthrough (treat level as raw model ID)
*/ */
@@ -99,12 +114,15 @@ export function resolveModel(
level: string, level: string,
pluginConfig?: Record<string, unknown>, pluginConfig?: Record<string, unknown>,
): string { ): string {
const canonical = canonicalLevel(role, level);
const models = (pluginConfig as { models?: Record<string, unknown> })?.models; const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
if (models && typeof models === "object") { if (models && typeof models === "object") {
const roleModels = models[role] as Record<string, string> | undefined; 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]; if (roleModels?.[level]) return roleModels[level];
} }
return getDefaultModel(role, level) ?? level; return getDefaultModel(role, canonical) ?? canonical;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -48,7 +48,7 @@ export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW);
export function detectLevelFromLabels(labels: string[]): string | null { export function detectLevelFromLabels(labels: string[]): string | null {
const lower = labels.map((l) => l.toLowerCase()); const lower = labels.map((l) => l.toLowerCase());
// Match role.level labels (e.g., "dev.senior", "qa.reviewer", "architect.opus") // Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior")
for (const l of lower) { for (const l of lower) {
const dot = l.indexOf("."); const dot = l.indexOf(".");
if (dot === -1) continue; if (dot === -1) continue;

View File

@@ -6,7 +6,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> }; type ModelConfig = { dev: Record<string, string>; qa: Record<string, string>; architect: Record<string, string> };
/** /**
* Write DevClaw model level config to openclaw.json plugins section. * Write DevClaw model level config to openclaw.json plugins section.

View File

@@ -11,7 +11,7 @@ import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js"; import { writePluginConfig } from "./config.js";
import { scaffoldWorkspace } from "./workspace.js"; import { scaffoldWorkspace } from "./workspace.js";
export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> }; export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string>; architect: Record<string, string> };
export type SetupOpts = { export type SetupOpts = {
/** OpenClaw plugin API for config access. */ /** OpenClaw plugin API for config access. */
@@ -27,7 +27,7 @@ export type SetupOpts = {
/** Override workspace path (auto-detected from agent if not given). */ /** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string; workspacePath?: string;
/** Model overrides per role.level. Missing levels use defaults. */ /** Model overrides per role.level. Missing levels use defaults. */
models?: { dev?: Partial<Record<string, string>>; qa?: Partial<Record<string, string>> }; models?: { dev?: Partial<Record<string, string>>; qa?: Partial<Record<string, string>>; architect?: Partial<Record<string, string>> };
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential"; projectExecution?: "parallel" | "sequential";
}; };
@@ -115,6 +115,7 @@ async function tryMigrateBinding(
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
const dev: Record<string, string> = { ...DEFAULT_MODELS.dev }; const dev: Record<string, string> = { ...DEFAULT_MODELS.dev };
const qa: Record<string, string> = { ...DEFAULT_MODELS.qa }; const qa: Record<string, string> = { ...DEFAULT_MODELS.qa };
const architect: Record<string, string> = { ...DEFAULT_MODELS.architect };
if (overrides?.dev) { if (overrides?.dev) {
for (const [level, model] of Object.entries(overrides.dev)) { for (const [level, model] of Object.entries(overrides.dev)) {
@@ -126,6 +127,11 @@ function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
if (model) qa[level] = model; if (model) qa[level] = model;
} }
} }
if (overrides?.architect) {
return { dev, qa }; for (const [level, model] of Object.entries(overrides.architect)) {
if (model) architect[level] = model;
}
}
return { dev, qa, architect };
} }

View File

@@ -8,16 +8,17 @@ import { runCommand } from "../run-command.js";
export type ModelAssignment = { export type ModelAssignment = {
dev: { dev: {
junior: string; junior: string;
medior: string; mid: string;
senior: string; senior: string;
}; };
qa: { qa: {
reviewer: string; junior: string;
tester: string; mid: string;
senior: string;
}; };
architect: { architect: {
opus: string; junior: string;
sonnet: string; senior: string;
}; };
}; };
@@ -36,9 +37,9 @@ export async function selectModelsWithLLM(
if (availableModels.length === 1) { if (availableModels.length === 1) {
const model = availableModels[0].model; const model = availableModels[0].model;
return { return {
dev: { junior: model, medior: model, senior: model }, dev: { junior: model, mid: model, senior: model },
qa: { reviewer: model, tester: model }, qa: { junior: model, mid: model, senior: model },
architect: { opus: model, sonnet: model }, architect: { junior: model, senior: model },
}; };
} }
@@ -50,18 +51,16 @@ export async function selectModelsWithLLM(
Available models: Available models:
${modelList} ${modelList}
Assign models to these roles based on capability: All roles use the same level scheme based on task complexity:
- **senior** (most capable): Complex architecture, refactoring, critical decisions - **senior** (most capable): Complex architecture, refactoring, critical decisions
- **medior** (balanced): Features, bug fixes, code review - **mid** (balanced): Features, bug fixes, code review, standard tasks
- **junior** (fast/efficient): Simple fixes, testing, routine tasks - **junior** (fast/efficient): Simple fixes, routine tasks
- **reviewer** (same as medior): Code review
- **tester** (same as junior): Testing
Rules: Rules:
1. Prefer same provider for consistency 1. Prefer same provider for consistency
2. Assign most capable model to senior 2. Assign most capable model to senior
3. Assign mid-tier model to medior/reviewer 3. Assign mid-tier model to mid
4. Assign fastest/cheapest model to junior/tester 4. Assign fastest/cheapest model to junior
5. Consider model version numbers (higher = newer/better) 5. Consider model version numbers (higher = newer/better)
6. Stable versions (no date) > snapshot versions (with date like 20250514) 6. Stable versions (no date) > snapshot versions (with date like 20250514)
@@ -69,16 +68,17 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
{ {
"dev": { "dev": {
"junior": "provider/model-name", "junior": "provider/model-name",
"medior": "provider/model-name", "mid": "provider/model-name",
"senior": "provider/model-name" "senior": "provider/model-name"
}, },
"qa": { "qa": {
"reviewer": "provider/model-name", "junior": "provider/model-name",
"tester": "provider/model-name" "mid": "provider/model-name",
"senior": "provider/model-name"
}, },
"architect": { "architect": {
"opus": "provider/model-name", "junior": "provider/model-name",
"sonnet": "provider/model-name" "senior": "provider/model-name"
} }
}`; }`;
@@ -131,17 +131,18 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
// Backfill architect if LLM didn't return it (graceful upgrade) // Backfill architect if LLM didn't return it (graceful upgrade)
if (!assignment.architect) { if (!assignment.architect) {
assignment.architect = { assignment.architect = {
opus: assignment.dev?.senior ?? availableModels[0].model, senior: assignment.dev?.senior ?? availableModels[0].model,
sonnet: assignment.dev?.medior ?? availableModels[0].model, junior: assignment.dev?.mid ?? availableModels[0].model,
}; };
} }
if ( if (
!assignment.dev?.junior || !assignment.dev?.junior ||
!assignment.dev?.medior || !assignment.dev?.mid ||
!assignment.dev?.senior || !assignment.dev?.senior ||
!assignment.qa?.reviewer || !assignment.qa?.junior ||
!assignment.qa?.tester !assignment.qa?.mid ||
!assignment.qa?.senior
) { ) {
console.error("Invalid assignment structure. Got:", assignment); console.error("Invalid assignment structure. Got:", assignment);
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`); throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);

View File

@@ -7,16 +7,17 @@
export type ModelAssignment = { export type ModelAssignment = {
dev: { dev: {
junior: string; junior: string;
medior: string; mid: string;
senior: string; senior: string;
}; };
qa: { qa: {
reviewer: string; junior: string;
tester: string; mid: string;
senior: string;
}; };
architect: { architect: {
opus: string; junior: string;
sonnet: string; senior: string;
}; };
}; };
@@ -43,9 +44,9 @@ export async function assignModels(
if (authenticated.length === 1) { if (authenticated.length === 1) {
const model = authenticated[0].model; const model = authenticated[0].model;
return { return {
dev: { junior: model, medior: model, senior: model }, dev: { junior: model, mid: model, senior: model },
qa: { reviewer: model, tester: model }, qa: { junior: model, mid: model, senior: model },
architect: { opus: model, sonnet: model }, architect: { junior: model, senior: model },
}; };
} }
@@ -68,12 +69,13 @@ export function formatAssignment(assignment: ModelAssignment): string {
"| Role | Level | Model |", "| Role | Level | Model |",
"|------|----------|--------------------------|", "|------|----------|--------------------------|",
`| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`, `| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`,
`| DEV | medior | ${assignment.dev.medior.padEnd(24)} |`, `| DEV | mid | ${assignment.dev.mid.padEnd(24)} |`,
`| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`, `| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`,
`| QA | reviewer | ${assignment.qa.reviewer.padEnd(24)} |`, `| QA | senior | ${assignment.qa.senior.padEnd(24)} |`,
`| QA | tester | ${assignment.qa.tester.padEnd(24)} |`, `| QA | mid | ${assignment.qa.mid.padEnd(24)} |`,
`| ARCH | opus | ${assignment.architect.opus.padEnd(24)} |`, `| QA | junior | ${assignment.qa.junior.padEnd(24)} |`,
`| ARCH | sonnet | ${assignment.architect.sonnet.padEnd(24)} |`, `| ARCH | senior | ${assignment.architect.senior.padEnd(24)} |`,
`| ARCH | junior | ${assignment.architect.junior.padEnd(24)} |`,
]; ];
return lines.join("\n"); return lines.join("\n");
} }

View File

@@ -172,7 +172,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
**Why this matters:** **Why this matters:**
- **Audit trail** — Every code change is tracked to an issue - **Audit trail** — Every code change is tracked to an issue
- **Tier selection** — Junior/medior/senior models match task complexity - **Level selection** — Junior/mid/senior models match task complexity
- **Parallelization** — Workers run in parallel, you stay free to plan - **Parallelization** — Workers run in parallel, you stay free to plan
- **QA pipeline** — Code goes through review before closing - **QA pipeline** — Code goes through review before closing
@@ -195,7 +195,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
Examples: Examples:
- ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42" - ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42"
- ✅ "Picked up #42 for DEV (medior) 🔗 https://github.com/org/repo/issues/42" - ✅ "Picked up #42 for DEV (mid) 🔗 https://github.com/org/repo/issues/42"
- ❌ "Created issue #42 about the login bug" (missing URL) - ❌ "Created issue #42 about the login bug" (missing URL)
### DevClaw Tools ### DevClaw Tools
@@ -232,11 +232,10 @@ Issue labels are the single source of truth for task state.
Evaluate each task and pass the appropriate developer level to \`work_start\`: Evaluate each task and pass the appropriate developer level to \`work_start\`:
- **junior** — trivial: typos, single-file fix, quick change - **junior** — trivial: typos, single-file fix, quick change
- **medior** — standard: features, bug fixes, multi-file changes - **mid** — standard: features, bug fixes, multi-file changes
- **senior** — complex: architecture, system-wide refactoring, 5+ services - **senior** — complex: architecture, system-wide refactoring, 5+ services
- **reviewer** — QA: code inspection, validation, test runs
- **opus** — Architect: complex, high-impact design investigations All roles (DEV, QA, Architect) use the same level scheme. Levels describe task complexity, not the model.
- **sonnet** — Architect: standard feature design investigations
### Picking Up Work ### Picking Up Work

View File

@@ -4,7 +4,7 @@
* This module now delegates to the centralized role registry (lib/roles/). * This module now delegates to the centralized role registry (lib/roles/).
* Kept for backward compatibility — new code should import from lib/roles/ directly. * Kept for backward compatibility — new code should import from lib/roles/ directly.
* *
* Level names are plain: "junior", "senior", "reviewer", etc. * Level names are plain: "junior", "mid", "senior".
* Role context (dev/qa/architect) is always provided by the caller. * Role context (dev/qa/architect) is always provided by the caller.
*/ */
import { import {

View File

@@ -102,7 +102,7 @@ export function createAutoConfigureModelsTool(api: OpenClawPluginApi) {
if (modelCount === 1) { if (modelCount === 1) {
message += " Only one authenticated model found — assigned to all roles."; message += " Only one authenticated model found — assigned to all roles.";
} else { } else {
message += " Models assigned by capability tier (Tier 1 → senior, Tier 2 → medior/reviewer, Tier 3 → junior/tester)."; message += " Models assigned by capability tier (Tier 1 → senior, Tier 2 → mid, Tier 3 → junior).";
} }
if (preferProvider) { if (preferProvider) {

View File

@@ -14,29 +14,31 @@ import {
describe("architect tiers", () => { describe("architect tiers", () => {
it("should recognize architect levels", () => { it("should recognize architect levels", () => {
assert.strictEqual(isArchitectLevel("opus"), true); assert.strictEqual(isArchitectLevel("junior"), true);
assert.strictEqual(isArchitectLevel("sonnet"), true); assert.strictEqual(isArchitectLevel("senior"), true);
assert.strictEqual(isArchitectLevel("medior"), false); assert.strictEqual(isArchitectLevel("mid"), false);
}); });
it("should map architect levels to role", () => { it("should map architect levels to role", () => {
assert.strictEqual(levelRole("opus"), "architect"); // "junior" and "senior" appear in dev first (registry order), so roleForLevel returns "dev"
assert.strictEqual(levelRole("sonnet"), "architect"); // This is expected — use isArchitectLevel for architect-specific checks
assert.strictEqual(levelRole("junior"), "dev");
assert.strictEqual(levelRole("senior"), "dev");
}); });
it("should resolve default architect models", () => { it("should resolve default architect models", () => {
assert.strictEqual(defaultModel("architect", "opus"), "anthropic/claude-opus-4-5"); assert.strictEqual(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(defaultModel("architect", "sonnet"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
}); });
it("should resolve architect model from config", () => { it("should resolve architect model from config", () => {
const config = { models: { architect: { opus: "custom/model" } } }; const config = { models: { architect: { senior: "custom/model" } } };
assert.strictEqual(resolveModel("architect", "opus", config), "custom/model"); assert.strictEqual(resolveModel("architect", "senior", config), "custom/model");
}); });
it("should have architect emoji", () => { it("should have architect emoji", () => {
assert.strictEqual(levelEmoji("architect", "opus"), "🏗️"); assert.strictEqual(levelEmoji("architect", "senior"), "🏗️");
assert.strictEqual(levelEmoji("architect", "sonnet"), "📐"); assert.strictEqual(levelEmoji("architect", "junior"), "📐");
}); });
}); });
@@ -81,25 +83,25 @@ describe("architect workflow states", () => {
}); });
describe("architect model selection", () => { describe("architect model selection", () => {
it("should select sonnet for standard design tasks", () => { it("should select junior for standard design tasks", () => {
const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect"); const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect");
assert.strictEqual(result.level, "sonnet"); assert.strictEqual(result.level, "junior");
}); });
it("should select opus for complex design tasks", () => { it("should select senior for complex design tasks", () => {
const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect"); const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect");
assert.strictEqual(result.level, "opus"); assert.strictEqual(result.level, "senior");
}); });
}); });
describe("architect session key parsing", () => { describe("architect session key parsing", () => {
it("should parse architect session key", () => { it("should parse architect session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-opus"); const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-senior");
assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" }); assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" });
}); });
it("should parse architect sonnet session key", () => { it("should parse architect junior session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-sonnet"); const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-junior");
assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" }); assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" });
}); });
}); });

View File

@@ -58,7 +58,7 @@ Example:
complexity: { complexity: {
type: "string", type: "string",
enum: ["simple", "medium", "complex"], enum: ["simple", "medium", "complex"],
description: "Suggests architect level: simple/medium → sonnet, complex → opus. Defaults to medium.", description: "Suggests architect level: simple/medium → junior, complex → senior. Defaults to medium.",
}, },
dryRun: { dryRun: {
type: "boolean", type: "boolean",
@@ -110,7 +110,7 @@ Example:
}); });
// Select level based on complexity // Select level based on complexity
const level = complexity === "complex" ? "opus" : "sonnet"; const level = complexity === "complex" ? "senior" : "junior";
if (dryRun) { if (dryRun) {
return jsonResult({ return jsonResult({

View File

@@ -8,7 +8,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { runSetup, type SetupOpts } from "../setup/index.js"; import { runSetup, type SetupOpts } from "../setup/index.js";
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js"; import { DEFAULT_MODELS } from "../tiers.js";
import { getLevelsForRole } from "../roles/index.js";
export function createSetupTool(api: OpenClawPluginApi) { export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -45,9 +46,9 @@ export function createSetupTool(api: OpenClawPluginApi) {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.dev.junior}`, description: `Default: ${DEFAULT_MODELS.dev.junior}`,
}, },
medior: { mid: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.dev.medior}`, description: `Default: ${DEFAULT_MODELS.dev.mid}`,
}, },
senior: { senior: {
type: "string", type: "string",
@@ -59,13 +60,17 @@ export function createSetupTool(api: OpenClawPluginApi) {
type: "object", type: "object",
description: "QA level models", description: "QA level models",
properties: { properties: {
reviewer: { junior: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.qa.reviewer}`, description: `Default: ${DEFAULT_MODELS.qa.junior}`,
}, },
tester: { mid: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.qa.tester}`, description: `Default: ${DEFAULT_MODELS.qa.mid}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.qa.senior}`,
}, },
}, },
}, },
@@ -109,8 +114,9 @@ export function createSetupTool(api: OpenClawPluginApi) {
} }
lines.push( lines.push(
"Models:", "Models:",
...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`), ...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`),
...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`), ...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`),
...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[t]}`),
"", "",
); );

View File

@@ -30,7 +30,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
projectGroupId: { type: "string", description: "Project group ID." }, projectGroupId: { type: "string", description: "Project group ID." },
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." }, issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
role: { type: "string", enum: getAllRoleIds(), description: "Worker role. Auto-detected from label if omitted." }, role: { type: "string", enum: getAllRoleIds(), description: "Worker role. Auto-detected from label if omitted." },
level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." }, level: { type: "string", description: "Worker level (junior/mid/senior). Auto-detected if omitted." },
}, },
}, },