refactor: implement dynamic role and level handling with migration support

This commit is contained in:
Lauren ten Hoor
2026-02-15 18:46:00 +08:00
parent 0e24a68882
commit a85f4fd33e
10 changed files with 278 additions and 227 deletions

View File

@@ -15,6 +15,28 @@ import { registerCli } from "./lib/cli.js";
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
import { initRunCommand } from "./lib/run-command.js";
import { ROLE_REGISTRY } from "./lib/roles/index.js";
/** Build the models config schema dynamically from the role registry. */
function buildModelsSchema(): Record<string, unknown> {
const properties: Record<string, unknown> = {};
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
const levelProps: Record<string, unknown> = {};
for (const level of config.levels) {
levelProps[level] = { type: "string" };
}
properties[roleId] = {
type: "object",
description: `${config.displayName} level models`,
properties: levelProps,
};
}
return {
type: "object",
description: "Model mapping per role and level",
properties,
};
}
const plugin = {
id: "devclaw",
@@ -24,38 +46,7 @@ const plugin = {
configSchema: {
type: "object",
properties: {
models: {
type: "object",
description: "Model mapping per role and level",
properties: {
dev: {
type: "object",
description: "Developer level models",
properties: {
junior: { type: "string" },
mid: { type: "string" },
senior: { type: "string" },
},
},
qa: {
type: "object",
description: "QA level models",
properties: {
junior: { type: "string" },
mid: { type: "string" },
senior: { type: "string" },
},
},
architect: {
type: "object",
description: "Architect level models",
properties: {
junior: { type: "string" },
senior: { type: "string" },
},
},
},
},
models: buildModelsSchema(),
projectExecution: {
type: "string",
enum: ["parallel", "sequential"],

141
lib/migrations.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* migrations.ts — Backward-compatibility aliases and migration logic.
*
* Contains all role/level renaming aliases and projects.json format migration.
* This file can be removed once all users have migrated to the new format.
*
* Migrations handled:
* - Role renames: dev → developer, qa → tester
* - Level renames: mid → medior, reviewer → medior, tester → junior, opus → senior, sonnet → junior
* - projects.json format: old hardcoded dev/qa/architect fields → workers map
* - projects.json format: old role keys in workers map → canonical role keys
*/
import type { WorkerState, Project } from "./projects.js";
// ---------------------------------------------------------------------------
// Role aliases — old role IDs → canonical IDs
// ---------------------------------------------------------------------------
/** Maps old role IDs to canonical IDs. */
export const ROLE_ALIASES: Record<string, string> = {
dev: "developer",
qa: "tester",
};
/** Resolve a role ID, applying aliases for backward compatibility. */
export function canonicalRole(role: string): string {
return ROLE_ALIASES[role] ?? role;
}
// ---------------------------------------------------------------------------
// Level aliases — old level names → canonical names, per role
// ---------------------------------------------------------------------------
/** Maps old level names to canonical names, per role. */
export const LEVEL_ALIASES: Record<string, Record<string, string>> = {
developer: { mid: "medior", medior: "medior" },
dev: { mid: "medior", medior: "medior" },
tester: { mid: "medior", reviewer: "medior", tester: "junior" },
qa: { mid: "medior", reviewer: "medior", 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;
}
// ---------------------------------------------------------------------------
// projects.json migration helpers
// ---------------------------------------------------------------------------
function migrateLevel(level: string | null, role: string): string | null {
if (!level) return null;
return LEVEL_ALIASES[role]?.[level] ?? level;
}
function migrateSessions(
sessions: Record<string, string | null>,
role: string,
): Record<string, string | null> {
const aliases = LEVEL_ALIASES[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: migrateLevel(level, role),
sessions: migrateSessions(sessions, role),
};
}
/** Empty worker state with null sessions for given levels. */
function emptyWorkerState(levels: string[]): WorkerState {
const sessions: Record<string, string | null> = {};
for (const l of levels) {
sessions[l] = null;
}
return {
active: false,
issueId: null,
startTime: null,
level: null,
sessions,
};
}
/**
* Migrate a raw project object from old format to current format.
*
* Handles:
* 1. Old format: hardcoded dev/qa/architect fields → workers map
* 2. Old role keys in workers map (dev → developer, qa → tester)
* 3. Old level names in worker state
* 4. Missing channel field defaults to "telegram"
*/
export function migrateProject(project: Project): void {
const raw = project as unknown as Record<string, unknown>;
if (!raw.workers && (raw.dev || raw.qa || raw.architect)) {
// Old format: hardcoded dev/qa/architect fields → workers map
project.workers = {};
for (const role of ["dev", "qa", "architect"]) {
const canonical = ROLE_ALIASES[role] ?? role;
project.workers[canonical] = raw[role]
? parseWorkerState(raw[role] as Record<string, unknown>, role)
: emptyWorkerState([]);
}
// Clean up old fields from the in-memory object
delete raw.dev;
delete raw.qa;
delete raw.architect;
} else if (raw.workers) {
// New format: parse each worker with role-aware migration
const workers = raw.workers as Record<string, Record<string, unknown>>;
project.workers = {};
for (const [role, worker] of Object.entries(workers)) {
// Migrate old role keys (dev→developer, qa→tester)
const canonical = ROLE_ALIASES[role] ?? role;
project.workers[canonical] = parseWorkerState(worker, role);
}
} else {
project.workers = {};
}
if (!project.channel) {
project.channel = "telegram";
}
}

View File

@@ -5,7 +5,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { homedir } from "node:os";
import { LEVEL_ALIASES, ROLE_ALIASES } from "./roles/index.js";
import { migrateProject } from "./migrations.js";
export type WorkerState = {
active: boolean;
issueId: string | null;
@@ -37,38 +38,6 @@ export type ProjectsData = {
projects: Record<string, Project>;
};
function migrateLevel(level: string | null, role: string): string | null {
if (!level) return null;
return LEVEL_ALIASES[role]?.[level] ?? level;
}
function migrateSessions(
sessions: Record<string, string | null>,
role: string,
): Record<string, string | null> {
const aliases = LEVEL_ALIASES[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: migrateLevel(level, role),
sessions: migrateSessions(sessions, role),
};
}
/**
* Create a blank WorkerState with null sessions for given level names.
*/
@@ -105,36 +74,7 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
const data = JSON.parse(raw) as ProjectsData;
for (const project of Object.values(data.projects)) {
// Migrate old format: hardcoded dev/qa/architect fields → workers map
const raw = project as unknown as Record<string, unknown>;
if (!raw.workers && (raw.dev || raw.qa || raw.architect)) {
project.workers = {};
for (const role of ["dev", "qa", "architect"]) {
const canonical = ROLE_ALIASES[role] ?? role;
project.workers[canonical] = raw[role]
? parseWorkerState(raw[role] as Record<string, unknown>, role)
: emptyWorkerState([]);
}
// Clean up old fields from the in-memory object
delete raw.dev;
delete raw.qa;
delete raw.architect;
} else if (raw.workers) {
// New format: parse each worker with role-aware migration
const workers = raw.workers as Record<string, Record<string, unknown>>;
project.workers = {};
for (const [role, worker] of Object.entries(workers)) {
// Migrate old role keys (dev→developer, qa→tester)
const canonical = ROLE_ALIASES[role] ?? role;
project.workers[canonical] = parseWorkerState(worker, role);
}
} else {
project.workers = {};
}
if (!project.channel) {
project.channel = "telegram";
}
migrateProject(project);
}
return data;

View File

@@ -129,7 +129,7 @@ describe("models", () => {
assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5");
assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
});
it("should return all default models", () => {
@@ -150,7 +150,7 @@ describe("models", () => {
});
it("should pass through unknown level as model ID", () => {
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5");
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6");
});
it("should resolve old config keys via aliases", () => {

View File

@@ -22,7 +22,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
models: {
junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
senior: "anthropic/claude-opus-4-6",
},
emoji: {
junior: "⚡",
@@ -43,7 +43,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
models: {
junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
senior: "anthropic/claude-opus-4-6",
},
emoji: {
junior: "⚡",
@@ -63,7 +63,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
defaultLevel: "junior",
models: {
junior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
senior: "anthropic/claude-opus-4-6",
},
emoji: {
junior: "📐",

View File

@@ -7,6 +7,7 @@
import { ROLE_REGISTRY } from "./registry.js";
import type { RoleConfig } from "./types.js";
import type { ResolvedRoleConfig } from "../config/types.js";
import { ROLE_ALIASES as _ROLE_ALIASES, canonicalLevel as _canonicalLevel } from "../migrations.js";
// ---------------------------------------------------------------------------
// Role IDs
@@ -38,37 +39,10 @@ export function requireRole(role: string): RoleConfig {
}
// ---------------------------------------------------------------------------
// Role aliases — maps old role IDs to new canonical IDs
// Migration aliases — re-exported from lib/migrations.ts for backward compat
// ---------------------------------------------------------------------------
/** Maps old role IDs to canonical IDs. Used for backward compatibility. */
export const ROLE_ALIASES: Record<string, string> = {
dev: "developer",
qa: "tester",
};
/** Resolve a role ID, applying aliases for backward compatibility. */
export function canonicalRole(role: string): string {
return ROLE_ALIASES[role] ?? role;
}
// ---------------------------------------------------------------------------
// Level aliases — maps old level names to new canonical names
// ---------------------------------------------------------------------------
/** Maps old level names to canonical names, per role. Used for backward compatibility. */
export const LEVEL_ALIASES: Record<string, Record<string, string>> = {
developer: { mid: "medior", medior: "medior" },
dev: { mid: "medior", medior: "medior" },
tester: { mid: "medior", reviewer: "medior", tester: "junior" },
qa: { mid: "medior", reviewer: "medior", 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;
}
export { ROLE_ALIASES, canonicalRole, LEVEL_ALIASES, canonicalLevel } from "../migrations.js";
// ---------------------------------------------------------------------------
// Levels
@@ -135,13 +109,13 @@ export function resolveModel(
pluginConfig?: Record<string, unknown>,
resolvedRole?: ResolvedRoleConfig,
): string {
const canonical = canonicalLevel(role, level);
const canonical = _canonicalLevel(role, level);
// 1. Plugin config override (openclaw.json) — check canonical role + old aliases
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
if (models && typeof models === "object") {
// Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer")
const roleModels = (models[role] ?? models[Object.entries(ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record<string, string> | undefined;
const roleModels = (models[role] ?? models[Object.entries(_ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record<string, string> | undefined;
if (roleModels?.[canonical]) return roleModels[canonical];
if (roleModels?.[level]) return roleModels[level];
}

View File

@@ -4,23 +4,63 @@
* Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles.
*/
import { runCommand } from "../run-command.js";
import { ROLE_REGISTRY } from "../roles/index.js";
import type { ModelAssignment } from "./smart-model-selector.js";
export type ModelAssignment = {
developer: {
junior: string;
medior: string;
senior: string;
};
tester: {
junior: string;
medior: string;
senior: string;
};
architect: {
junior: string;
senior: string;
};
};
/**
* Build a ModelAssignment where every role/level maps to the same model.
*/
function singleModelAssignment(model: string): ModelAssignment {
const result: ModelAssignment = {};
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
result[roleId] = {};
for (const level of config.levels) {
result[roleId][level] = model;
}
}
return result;
}
/**
* Build the JSON format example for the LLM prompt, derived from registry.
*/
function buildJsonExample(): string {
const obj: Record<string, Record<string, string>> = {};
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
obj[roleId] = {};
for (const level of config.levels) {
obj[roleId][level] = "provider/model-name";
}
}
return JSON.stringify(obj, null, 2);
}
/**
* Validate that a parsed assignment has all required roles and levels.
*/
function validateAssignment(assignment: Record<string, unknown>, fallbackModel: string): ModelAssignment | null {
const result: ModelAssignment = {};
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
const roleData = assignment[roleId] as Record<string, string> | undefined;
if (!roleData) {
// Backfill missing roles from the first available role or fallback
result[roleId] = {};
for (const level of config.levels) {
result[roleId][level] = fallbackModel;
}
continue;
}
result[roleId] = {};
for (const level of config.levels) {
if (!roleData[level]) {
console.error(`Missing ${roleId}.${level} in LLM assignment`);
return null;
}
result[roleId][level] = roleData[level];
}
}
return result;
}
/**
* Use an LLM to intelligently select and assign models to DevClaw roles.
@@ -35,16 +75,12 @@ export async function selectModelsWithLLM(
// If only one model, assign it to all roles
if (availableModels.length === 1) {
const model = availableModels[0].model;
return {
developer: { junior: model, medior: model, senior: model },
tester: { junior: model, medior: model, senior: model },
architect: { junior: model, senior: model },
};
return singleModelAssignment(availableModels[0].model);
}
// Create a prompt for the LLM
const modelList = availableModels.map((m) => m.model).join("\n");
const jsonExample = buildJsonExample();
const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities.
@@ -65,22 +101,7 @@ Rules:
6. Stable versions (no date) > snapshot versions (with date like 20250514)
Return ONLY a JSON object in this exact format (no markdown, no explanation):
{
"developer": {
"junior": "provider/model-name",
"medior": "provider/model-name",
"senior": "provider/model-name"
},
"tester": {
"junior": "provider/model-name",
"medior": "provider/model-name",
"senior": "provider/model-name"
},
"architect": {
"junior": "provider/model-name",
"senior": "provider/model-name"
}
}`;
${jsonExample}`;
try {
const sessionId = sessionKey ?? "devclaw-model-selection";
@@ -127,28 +148,14 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
// Log what we got for debugging
console.log("LLM returned:", JSON.stringify(assignment, null, 2));
// Validate the structure
// Backfill architect if LLM didn't return it (graceful upgrade)
if (!assignment.architect) {
assignment.architect = {
senior: assignment.developer?.senior ?? availableModels[0].model,
junior: assignment.developer?.medior ?? availableModels[0].model,
};
}
if (
!assignment.developer?.junior ||
!assignment.developer?.medior ||
!assignment.developer?.senior ||
!assignment.tester?.junior ||
!assignment.tester?.medior ||
!assignment.tester?.senior
) {
// Validate and backfill
const validated = validateAssignment(assignment, availableModels[0].model);
if (!validated) {
console.error("Invalid assignment structure. Got:", assignment);
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);
}
return assignment as ModelAssignment;
return validated;
} catch (err) {
console.error("LLM model selection failed:", (err as Error).message);
return null;

View File

@@ -3,23 +3,25 @@
*
* Uses an LLM to intelligently analyze and assign models to DevClaw roles.
*/
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
import { ROLE_REGISTRY } from "../roles/index.js";
export type ModelAssignment = {
developer: {
junior: string;
medior: string;
senior: string;
};
tester: {
junior: string;
medior: string;
senior: string;
};
architect: {
junior: string;
senior: string;
};
};
/** Model assignment: role → level → model ID. Derived from registry structure. */
export type ModelAssignment = Record<string, Record<string, string>>;
/**
* Build a ModelAssignment where every role/level maps to the same model.
*/
function singleModelAssignment(model: string): ModelAssignment {
const result: ModelAssignment = {};
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
result[roleId] = {};
for (const level of config.levels) {
result[roleId][level] = model;
}
}
return result;
}
/**
* Intelligently assign available models to DevClaw roles using an LLM.
@@ -42,12 +44,7 @@ export async function assignModels(
// If only one model, use it for everything
if (authenticated.length === 1) {
const model = authenticated[0].model;
return {
developer: { junior: model, medior: model, senior: model },
tester: { junior: model, medior: model, senior: model },
architect: { junior: model, senior: model },
};
return singleModelAssignment(authenticated[0].model);
}
// Multiple models: use LLM-based selection
@@ -68,15 +65,16 @@ export function formatAssignment(assignment: ModelAssignment): string {
const lines = [
"| Role | Level | Model |",
"|-----------|----------|--------------------------|",
`| DEVELOPER | senior | ${assignment.developer.senior.padEnd(24)} |`,
`| DEVELOPER | medior | ${assignment.developer.medior.padEnd(24)} |`,
`| DEVELOPER | junior | ${assignment.developer.junior.padEnd(24)} |`,
`| TESTER | senior | ${assignment.tester.senior.padEnd(24)} |`,
`| TESTER | medior | ${assignment.tester.medior.padEnd(24)} |`,
`| TESTER | junior | ${assignment.tester.junior.padEnd(24)} |`,
`| ARCHITECT | senior | ${assignment.architect.senior.padEnd(24)} |`,
`| ARCHITECT | junior | ${assignment.architect.junior.padEnd(24)} |`,
];
for (const roleId of getAllRoleIds()) {
const roleModels = assignment[roleId];
if (!roleModels) continue;
const displayName = ROLE_REGISTRY[roleId]?.displayName ?? roleId.toUpperCase();
for (const level of getLevelsForRole(roleId)) {
const model = roleModels[level] ?? "";
lines.push(`| ${displayName.padEnd(9)} | ${level.padEnd(8)} | ${model.padEnd(24)} |`);
}
}
return lines.join("\n");
}

View File

@@ -27,7 +27,7 @@ describe("architect tiers", () => {
});
it("should resolve default architect models", () => {
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
});

View File

@@ -11,10 +11,11 @@ import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
import { getAllRoleIds, getFallbackEmoji } from "../roles/index.js";
/** Valid author roles for attribution */
const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const;
type AuthorRole = (typeof AUTHOR_ROLES)[number];
/** Valid author roles for attribution — all registry roles + orchestrator */
const AUTHOR_ROLES = [...getAllRoleIds(), "orchestrator"];
type AuthorRole = string;
export function createTaskCommentTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -73,7 +74,7 @@ Examples:
const issue = await provider.getIssue(issueId);
const commentBody = authorRole
? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}`
? `${getRoleEmoji(authorRole)} **${authorRole.toUpperCase()}**: ${body}`
: body;
await provider.addComment(issueId, commentBody);
@@ -99,8 +100,7 @@ Examples:
// Private helpers
// ---------------------------------------------------------------------------
const ROLE_EMOJI: Record<AuthorRole, string> = {
developer: "👨‍💻",
tester: "🔍",
orchestrator: "🎛️",
};
function getRoleEmoji(role: string): string {
if (role === "orchestrator") return "🎛️";
return getFallbackEmoji(role);
}