From 6a99752e5f9941f3ce236dd1f5aec2c67d7dd663 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 17:01:16 +0800 Subject: [PATCH] refactor: standardize level names across all roles (#195 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.ts | 19 +++++---- lib/bootstrap-hook.test.ts | 6 +-- lib/bootstrap-hook.ts | 4 +- lib/cli.ts | 22 ++++++---- lib/dispatch.ts | 2 +- lib/model-selector.ts | 22 +++++----- lib/onboarding.ts | 10 ++--- lib/projects.ts | 43 ++++++++++++++++--- lib/roles/index.ts | 2 + lib/roles/registry.test.ts | 71 ++++++++++++++++++++++++------- lib/roles/registry.ts | 34 ++++++++------- lib/roles/selectors.ts | 22 +++++++++- lib/services/tick.ts | 2 +- lib/setup/config.ts | 2 +- lib/setup/index.ts | 12 ++++-- lib/setup/llm-model-selector.ts | 51 +++++++++++----------- lib/setup/smart-model-selector.ts | 28 ++++++------ lib/templates.ts | 11 +++-- lib/tiers.ts | 2 +- lib/tools/autoconfigure-models.ts | 2 +- lib/tools/design-task.test.ts | 38 +++++++++-------- lib/tools/design-task.ts | 4 +- lib/tools/setup.ts | 24 +++++++---- lib/tools/work-start.ts | 2 +- 24 files changed, 273 insertions(+), 162 deletions(-) diff --git a/index.ts b/index.ts index d00cc61..32f405e 100644 --- a/index.ts +++ b/index.ts @@ -26,31 +26,32 @@ const plugin = { properties: { models: { type: "object", - description: "Model mapping per role and tier", + description: "Model mapping per role and level", properties: { dev: { type: "object", - description: "Developer tier models", + description: "Developer level models", properties: { junior: { type: "string" }, - medior: { type: "string" }, + mid: { type: "string" }, senior: { type: "string" }, }, }, qa: { type: "object", - description: "QA tier models", + description: "QA level models", properties: { - reviewer: { type: "string" }, - tester: { type: "string" }, + junior: { type: "string" }, + mid: { type: "string" }, + senior: { type: "string" }, }, }, architect: { type: "object", - description: "Architect tier models", + description: "Architect level models", properties: { - opus: { type: "string" }, - sonnet: { type: "string" }, + junior: { type: "string" }, + senior: { type: "string" }, }, }, }, diff --git a/lib/bootstrap-hook.test.ts b/lib/bootstrap-hook.test.ts index 008dd99..844841b 100644 --- a/lib/bootstrap-hook.test.ts +++ b/lib/bootstrap-hook.test.ts @@ -11,12 +11,12 @@ import os from "node:os"; describe("parseDevClawSessionKey", () => { 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" }); }); 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" }); }); @@ -26,7 +26,7 @@ describe("parseDevClawSessionKey", () => { }); 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" }); }); diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index b82acd0..8de14c2 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -17,8 +17,8 @@ import { getSessionKeyRolePattern } from "./roles/index.js"; * * Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}` * Examples: - * - `agent:devclaw:subagent:my-project-dev-medior` → { projectName: "my-project", role: "dev" } - * - `agent:devclaw:subagent:webapp-qa-reviewer` → { projectName: "webapp", role: "qa" } + * - `agent:devclaw:subagent:my-project-dev-mid` → { projectName: "my-project", role: "dev" } + * - `agent:devclaw:subagent:webapp-qa-mid` → { projectName: "webapp", role: "qa" } * * Note: projectName may contain hyphens, so we match role from the end. */ diff --git a/lib/cli.ts b/lib/cli.ts index 82553cb..c31473d 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -6,7 +6,8 @@ import type { Command } from "commander"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; 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. @@ -23,18 +24,20 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { .option("--agent ", "Use an existing agent by ID") .option("--workspace ", "Direct workspace path") .option("--junior ", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`) - .option("--medior ", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`) + .option("--mid ", `Mid dev model (default: ${DEFAULT_MODELS.dev.mid})`) .option("--senior ", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`) - .option("--reviewer ", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`) - .option("--tester ", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`) + .option("--qa-junior ", `QA junior model (default: ${DEFAULT_MODELS.qa.junior})`) + .option("--qa-mid ", `QA mid model (default: ${DEFAULT_MODELS.qa.mid})`) + .option("--qa-senior ", `QA senior model (default: ${DEFAULT_MODELS.qa.senior})`) .action(async (opts) => { const dev: Record = {}; const qa: Record = {}; 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.reviewer) qa.reviewer = opts.reviewer; - if (opts.tester) qa.tester = opts.tester; + if (opts.qaJunior) qa.junior = opts.qaJunior; + 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 models = hasOverrides @@ -54,8 +57,9 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { } console.log("Models configured:"); - for (const t of DEV_LEVELS) 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("dev")) console.log(` dev.${t}: ${result.models.dev[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:"); for (const file of result.filesWritten) { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 3409787..8f9b14a 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -26,7 +26,7 @@ export type DispatchOpts = { issueDescription: string; issueUrl: string; 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; /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ fromLabel: string; diff --git a/lib/model-selector.ts b/lib/model-selector.ts index cb24b5f..d99f5ab 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -1,7 +1,7 @@ /** * Model selection for dev/qa tasks. * 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 = { @@ -41,12 +41,10 @@ const COMPLEX_KEYWORDS = [ /** * Select appropriate developer level based on task description. * - * Developer levels: - * - junior: very simple (typos, single-file fixes, CSS tweaks) - * - medior: standard DEV (features, bug fixes, multi-file changes) + * All roles use consistent levels: + * - junior: simple tasks (typos, single-file fixes, CSS tweaks) + * - mid: standard work (features, bug fixes, multi-file changes) * - senior: deep/architectural (system-wide refactoring, novel design) - * - reviewer: QA code inspection and validation - * - tester: QA manual testing */ export function selectLevel( issueTitle: string, @@ -55,7 +53,7 @@ export function selectLevel( ): LevelSelection { if (role === "qa") { return { - level: "reviewer", + level: "mid", reason: "Default QA level for code inspection and validation", }; } @@ -64,10 +62,10 @@ export function selectLevel( const text = `${issueTitle} ${issueDescription}`.toLowerCase(); const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); return { - level: isComplex ? "opus" : "sonnet", + level: isComplex ? "senior" : "junior", reason: isComplex - ? "Complex design task — using opus for depth" - : "Standard design task — using sonnet", + ? "Complex design task — using senior for depth" + : "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 { - level: "medior", + level: "mid", reason: "Standard dev task — multi-file changes, features, bug fixes", }; } diff --git a/lib/onboarding.ts b/lib/onboarding.ts index bf8b204..b570c4d 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -79,11 +79,9 @@ export function buildOnboardToolContext(): string { // Build the model table dynamically from DEFAULT_MODELS const rows: string[] = []; const purposes: Record = { - junior: "Typos, single-file fixes", - medior: "Features, bug fixes", - senior: "Architecture, refactoring", - reviewer: "Code review", - tester: "Testing", + junior: "Simple tasks, single-file fixes", + mid: "Features, bug fixes, code review", + senior: "Architecture, refactoring, complex tasks", }; for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { for (const [level, model] of Object.entries(levels)) { @@ -97,7 +95,7 @@ export function buildOnboardToolContext(): string { ## What is DevClaw? DevClaw turns each Telegram group into an autonomous development team: - 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 - Atomic tools for label transitions, session dispatch, state management, and audit logging diff --git a/lib/projects.ts b/lib/projects.ts index 1158147..6191384 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -37,13 +37,44 @@ export type ProjectsData = { projects: Record; }; -function parseWorkerState(worker: Record): WorkerState { +/** + * Level migration aliases: old name → new canonical name, keyed by role. + */ +const LEVEL_MIGRATION: Record> = { + 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, + role: string, +): Record { + const aliases = LEVEL_MIGRATION[role]; + if (!aliases) return sessions; + + const migrated: Record = {}; + for (const [key, value] of Object.entries(sessions)) { + const newKey = aliases[key] ?? key; + migrated[newKey] = value; + } + return migrated; +} + +function parseWorkerState(worker: Record, role: string): WorkerState { + const level = (worker.level ?? worker.tier ?? null) as string | null; + const sessions = (worker.sessions as Record) ?? {}; return { active: worker.active as boolean, issueId: worker.issueId as string | null, startTime: worker.startTime as string | null, - level: (worker.level ?? worker.tier ?? null) as string | null, - sessions: (worker.sessions as Record) ?? {}, + level: migrateLevel(level, role), + sessions: migrateSessions(sessions, role), }; } @@ -84,13 +115,13 @@ export async function readProjects(workspaceDir: string): Promise for (const project of Object.values(data.projects)) { project.dev = project.dev - ? parseWorkerState(project.dev as unknown as Record) + ? parseWorkerState(project.dev as unknown as Record, "dev") : emptyWorkerState([]); project.qa = project.qa - ? parseWorkerState(project.qa as unknown as Record) + ? parseWorkerState(project.qa as unknown as Record, "qa") : emptyWorkerState([]); project.architect = project.architect - ? parseWorkerState(project.architect as unknown as Record) + ? parseWorkerState(project.architect as unknown as Record, "architect") : emptyWorkerState([]); if (!project.channel) { project.channel = "telegram"; diff --git a/lib/roles/index.ts b/lib/roles/index.ts index 3db1f9d..1bd4dab 100644 --- a/lib/roles/index.ts +++ b/lib/roles/index.ts @@ -13,6 +13,8 @@ export { isValidRole, getRole, requireRole, + // Level aliases + canonicalLevel, // Levels getLevelsForRole, getAllLevels, diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index fa5bdb7..ac234d5 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -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", () => { diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index 1243428..7ef6d7a 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -17,16 +17,16 @@ export const ROLE_REGISTRY: Record = { 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 = { 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 = { 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"], diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index 6fd88c1..ba7e5ad 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -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> = { + 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> { * Resolve a level to a full model ID. * * Resolution order: - * 1. Plugin config `models..` + * 1. Plugin config `models..` (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 { + const canonical = canonicalLevel(role, level); const models = (pluginConfig as { models?: Record })?.models; if (models && typeof models === "object") { const roleModels = models[role] as Record | 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; } // --------------------------------------------------------------------------- diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 97a54e3..d4c10cf 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -48,7 +48,7 @@ export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW); export function detectLevelFromLabels(labels: string[]): string | null { 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) { const dot = l.indexOf("."); if (dot === -1) continue; diff --git a/lib/setup/config.ts b/lib/setup/config.ts index 2384b6b..9c0a783 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; -type ModelConfig = { dev: Record; qa: Record }; +type ModelConfig = { dev: Record; qa: Record; architect: Record }; /** * Write DevClaw model level config to openclaw.json plugins section. diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 307a6cf..9d4c8e4 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -11,7 +11,7 @@ import { createAgent, resolveWorkspacePath } from "./agent.js"; import { writePluginConfig } from "./config.js"; import { scaffoldWorkspace } from "./workspace.js"; -export type ModelConfig = { dev: Record; qa: Record }; +export type ModelConfig = { dev: Record; qa: Record; architect: Record }; export type SetupOpts = { /** OpenClaw plugin API for config access. */ @@ -27,7 +27,7 @@ export type SetupOpts = { /** Override workspace path (auto-detected from agent if not given). */ workspacePath?: string; /** Model overrides per role.level. Missing levels use defaults. */ - models?: { dev?: Partial>; qa?: Partial> }; + models?: { dev?: Partial>; qa?: Partial>; architect?: Partial> }; /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ projectExecution?: "parallel" | "sequential"; }; @@ -115,6 +115,7 @@ async function tryMigrateBinding( function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { const dev: Record = { ...DEFAULT_MODELS.dev }; const qa: Record = { ...DEFAULT_MODELS.qa }; + const architect: Record = { ...DEFAULT_MODELS.architect }; if (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 (overrides?.architect) { + for (const [level, model] of Object.entries(overrides.architect)) { + if (model) architect[level] = model; + } + } - return { dev, qa }; + return { dev, qa, architect }; } diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index a3668b4..9cbb2fe 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -8,16 +8,17 @@ import { runCommand } from "../run-command.js"; export type ModelAssignment = { dev: { junior: string; - medior: string; + mid: string; senior: string; }; qa: { - reviewer: string; - tester: string; + junior: string; + mid: string; + senior: string; }; architect: { - opus: string; - sonnet: string; + junior: string; + senior: string; }; }; @@ -36,9 +37,9 @@ export async function selectModelsWithLLM( if (availableModels.length === 1) { const model = availableModels[0].model; return { - dev: { junior: model, medior: model, senior: model }, - qa: { reviewer: model, tester: model }, - architect: { opus: model, sonnet: model }, + dev: { junior: model, mid: model, senior: model }, + qa: { junior: model, mid: model, senior: model }, + architect: { junior: model, senior: model }, }; } @@ -50,18 +51,16 @@ export async function selectModelsWithLLM( Available models: ${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 -- **medior** (balanced): Features, bug fixes, code review -- **junior** (fast/efficient): Simple fixes, testing, routine tasks -- **reviewer** (same as medior): Code review -- **tester** (same as junior): Testing +- **mid** (balanced): Features, bug fixes, code review, standard tasks +- **junior** (fast/efficient): Simple fixes, routine tasks Rules: 1. Prefer same provider for consistency 2. Assign most capable model to senior -3. Assign mid-tier model to medior/reviewer -4. Assign fastest/cheapest model to junior/tester +3. Assign mid-tier model to mid +4. Assign fastest/cheapest model to junior 5. Consider model version numbers (higher = newer/better) 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": { "junior": "provider/model-name", - "medior": "provider/model-name", + "mid": "provider/model-name", "senior": "provider/model-name" }, "qa": { - "reviewer": "provider/model-name", - "tester": "provider/model-name" + "junior": "provider/model-name", + "mid": "provider/model-name", + "senior": "provider/model-name" }, "architect": { - "opus": "provider/model-name", - "sonnet": "provider/model-name" + "junior": "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) if (!assignment.architect) { assignment.architect = { - opus: assignment.dev?.senior ?? availableModels[0].model, - sonnet: assignment.dev?.medior ?? availableModels[0].model, + senior: assignment.dev?.senior ?? availableModels[0].model, + junior: assignment.dev?.mid ?? availableModels[0].model, }; } if ( !assignment.dev?.junior || - !assignment.dev?.medior || + !assignment.dev?.mid || !assignment.dev?.senior || - !assignment.qa?.reviewer || - !assignment.qa?.tester + !assignment.qa?.junior || + !assignment.qa?.mid || + !assignment.qa?.senior ) { console.error("Invalid assignment structure. Got:", assignment); throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`); diff --git a/lib/setup/smart-model-selector.ts b/lib/setup/smart-model-selector.ts index eb78eb5..1c2fdf6 100644 --- a/lib/setup/smart-model-selector.ts +++ b/lib/setup/smart-model-selector.ts @@ -7,16 +7,17 @@ export type ModelAssignment = { dev: { junior: string; - medior: string; + mid: string; senior: string; }; qa: { - reviewer: string; - tester: string; + junior: string; + mid: string; + senior: string; }; architect: { - opus: string; - sonnet: string; + junior: string; + senior: string; }; }; @@ -43,9 +44,9 @@ export async function assignModels( if (authenticated.length === 1) { const model = authenticated[0].model; return { - dev: { junior: model, medior: model, senior: model }, - qa: { reviewer: model, tester: model }, - architect: { opus: model, sonnet: model }, + dev: { junior: model, mid: model, senior: model }, + qa: { junior: model, mid: model, senior: model }, + architect: { junior: model, senior: model }, }; } @@ -68,12 +69,13 @@ export function formatAssignment(assignment: ModelAssignment): string { "| Role | Level | Model |", "|------|----------|--------------------------|", `| 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)} |`, - `| QA | reviewer | ${assignment.qa.reviewer.padEnd(24)} |`, - `| QA | tester | ${assignment.qa.tester.padEnd(24)} |`, - `| ARCH | opus | ${assignment.architect.opus.padEnd(24)} |`, - `| ARCH | sonnet | ${assignment.architect.sonnet.padEnd(24)} |`, + `| QA | senior | ${assignment.qa.senior.padEnd(24)} |`, + `| QA | mid | ${assignment.qa.mid.padEnd(24)} |`, + `| QA | junior | ${assignment.qa.junior.padEnd(24)} |`, + `| ARCH | senior | ${assignment.architect.senior.padEnd(24)} |`, + `| ARCH | junior | ${assignment.architect.junior.padEnd(24)} |`, ]; return lines.join("\n"); } diff --git a/lib/templates.ts b/lib/templates.ts index 7fa7d23..cc8f322 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -172,7 +172,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder **Why this matters:** - **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 - **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: - ✅ "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) ### 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\`: - **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 -- **reviewer** — QA: code inspection, validation, test runs -- **opus** — Architect: complex, high-impact design investigations -- **sonnet** — Architect: standard feature design investigations + +All roles (DEV, QA, Architect) use the same level scheme. Levels describe task complexity, not the model. ### Picking Up Work diff --git a/lib/tiers.ts b/lib/tiers.ts index ae73b90..2f08915 100644 --- a/lib/tiers.ts +++ b/lib/tiers.ts @@ -4,7 +4,7 @@ * This module now delegates to the centralized role registry (lib/roles/). * 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. */ import { diff --git a/lib/tools/autoconfigure-models.ts b/lib/tools/autoconfigure-models.ts index cd6f62a..7c610d4 100644 --- a/lib/tools/autoconfigure-models.ts +++ b/lib/tools/autoconfigure-models.ts @@ -102,7 +102,7 @@ export function createAutoConfigureModelsTool(api: OpenClawPluginApi) { if (modelCount === 1) { message += "ℹ️ Only one authenticated model found — assigned to all roles."; } 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) { diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index ecd7536..0581a39 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -14,29 +14,31 @@ import { describe("architect tiers", () => { it("should recognize architect levels", () => { - assert.strictEqual(isArchitectLevel("opus"), true); - assert.strictEqual(isArchitectLevel("sonnet"), true); - assert.strictEqual(isArchitectLevel("medior"), false); + assert.strictEqual(isArchitectLevel("junior"), true); + assert.strictEqual(isArchitectLevel("senior"), true); + assert.strictEqual(isArchitectLevel("mid"), false); }); it("should map architect levels to role", () => { - assert.strictEqual(levelRole("opus"), "architect"); - assert.strictEqual(levelRole("sonnet"), "architect"); + // "junior" and "senior" appear in dev first (registry order), so roleForLevel returns "dev" + // 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", () => { - assert.strictEqual(defaultModel("architect", "opus"), "anthropic/claude-opus-4-5"); - assert.strictEqual(defaultModel("architect", "sonnet"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); + assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); it("should resolve architect model from config", () => { - const config = { models: { architect: { opus: "custom/model" } } }; - assert.strictEqual(resolveModel("architect", "opus", config), "custom/model"); + const config = { models: { architect: { senior: "custom/model" } } }; + assert.strictEqual(resolveModel("architect", "senior", config), "custom/model"); }); it("should have architect emoji", () => { - assert.strictEqual(levelEmoji("architect", "opus"), "🏗️"); - assert.strictEqual(levelEmoji("architect", "sonnet"), "📐"); + assert.strictEqual(levelEmoji("architect", "senior"), "🏗️"); + assert.strictEqual(levelEmoji("architect", "junior"), "📐"); }); }); @@ -81,25 +83,25 @@ describe("architect workflow states", () => { }); 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"); - 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"); - assert.strictEqual(result.level, "opus"); + assert.strictEqual(result.level, "senior"); }); }); describe("architect session key parsing", () => { 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" }); }); - it("should parse architect sonnet session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-sonnet"); + it("should parse architect junior session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-junior"); assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" }); }); }); diff --git a/lib/tools/design-task.ts b/lib/tools/design-task.ts index 2c9a1ea..12e379d 100644 --- a/lib/tools/design-task.ts +++ b/lib/tools/design-task.ts @@ -58,7 +58,7 @@ Example: complexity: { type: "string", 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: { type: "boolean", @@ -110,7 +110,7 @@ Example: }); // Select level based on complexity - const level = complexity === "complex" ? "opus" : "sonnet"; + const level = complexity === "complex" ? "senior" : "junior"; if (dryRun) { return jsonResult({ diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 5091d54..40fbbe2 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -8,7 +8,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.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) { return (ctx: ToolContext) => ({ @@ -45,9 +46,9 @@ export function createSetupTool(api: OpenClawPluginApi) { type: "string", description: `Default: ${DEFAULT_MODELS.dev.junior}`, }, - medior: { + mid: { type: "string", - description: `Default: ${DEFAULT_MODELS.dev.medior}`, + description: `Default: ${DEFAULT_MODELS.dev.mid}`, }, senior: { type: "string", @@ -59,13 +60,17 @@ export function createSetupTool(api: OpenClawPluginApi) { type: "object", description: "QA level models", properties: { - reviewer: { + junior: { type: "string", - description: `Default: ${DEFAULT_MODELS.qa.reviewer}`, + description: `Default: ${DEFAULT_MODELS.qa.junior}`, }, - tester: { + mid: { 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( "Models:", - ...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`), - ...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`), + ...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`), + ...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`), + ...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[t]}`), "", ); diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 8c2e207..e0e9aaa 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -30,7 +30,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { projectGroupId: { type: "string", description: "Project group ID." }, 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." }, - 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." }, }, },