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: {
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" },
},
},
},

View File

@@ -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" });
});

View File

@@ -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.
*/

View File

@@ -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 <id>", "Use an existing agent by ID")
.option("--workspace <path>", "Direct workspace path")
.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("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`)
.option("--qa-junior <model>", `QA junior model (default: ${DEFAULT_MODELS.qa.junior})`)
.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) => {
const dev: Record<string, string> = {};
const qa: Record<string, string> = {};
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) {

View File

@@ -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;

View File

@@ -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",
};
}

View File

@@ -79,11 +79,9 @@ export function buildOnboardToolContext(): string {
// Build the model table dynamically from DEFAULT_MODELS
const rows: string[] = [];
const purposes: Record<string, string> = {
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

View File

@@ -37,13 +37,44 @@ export type ProjectsData = {
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 {
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<string, string | null>) ?? {},
level: migrateLevel(level, role),
sessions: migrateSessions(sessions, role),
};
}
@@ -84,13 +115,13 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
for (const project of Object.values(data.projects)) {
project.dev = project.dev
? parseWorkerState(project.dev as unknown as Record<string, unknown>)
? parseWorkerState(project.dev as unknown as Record<string, unknown>, "dev")
: emptyWorkerState([]);
project.qa = project.qa
? parseWorkerState(project.qa as unknown as Record<string, unknown>)
? parseWorkerState(project.qa as unknown as Record<string, unknown>, "qa")
: emptyWorkerState([]);
project.architect = project.architect
? parseWorkerState(project.architect as unknown as Record<string, unknown>)
? parseWorkerState(project.architect as unknown as Record<string, unknown>, "architect")
: emptyWorkerState([]);
if (!project.channel) {
project.channel = "telegram";

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;
}
// ---------------------------------------------------------------------------

View File

@@ -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;

View File

@@ -6,7 +6,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
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.

View File

@@ -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<string, string>; qa: Record<string, string> };
export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string>; architect: Record<string, string> };
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<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. */
projectExecution?: "parallel" | "sequential";
};
@@ -115,6 +115,7 @@ async function tryMigrateBinding(
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
const dev: Record<string, string> = { ...DEFAULT_MODELS.dev };
const qa: Record<string, string> = { ...DEFAULT_MODELS.qa };
const architect: Record<string, string> = { ...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 };
}

View File

@@ -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))}`);

View File

@@ -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");
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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" });
});
});

View File

@@ -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({

View File

@@ -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]}`),
"",
);

View File

@@ -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." },
},
},