feat: enhance role-tier structure for models and update related configurations

This commit is contained in:
Lauren ten Hoor
2026-02-11 01:49:14 +08:00
parent b249217bc1
commit 2450181482
8 changed files with 174 additions and 127 deletions

View File

@@ -38,8 +38,8 @@ const plugin = {
type: "object", type: "object",
description: "QA tier models", description: "QA tier models",
properties: { properties: {
"qa-engineer": { type: "string" }, reviewer: { type: "string" },
"manual-tester": { type: "string" }, tester: { type: "string" },
}, },
}, },
}, },
@@ -47,7 +47,8 @@ const plugin = {
projectExecution: { projectExecution: {
type: "string", type: "string",
enum: ["parallel", "sequential"], enum: ["parallel", "sequential"],
description: "Plugin-level: parallel (each project independent) or sequential (one project at a time)", description:
"Plugin-level: parallel (each project independent) or sequential (one project at a time)",
default: "parallel", default: "parallel",
}, },
notifications: { notifications: {
@@ -61,11 +62,24 @@ const plugin = {
}, },
work_heartbeat: { work_heartbeat: {
type: "object", type: "object",
description: "Token-free interval-based heartbeat service. Runs health checks + queue dispatch automatically.", description:
"Token-free interval-based heartbeat service. Runs health checks + queue dispatch automatically.",
properties: { properties: {
enabled: { type: "boolean", default: true, description: "Enable the heartbeat service." }, enabled: {
intervalSeconds: { type: "number", default: 60, description: "Seconds between ticks." }, type: "boolean",
maxPickupsPerTick: { type: "number", default: 4, description: "Max worker dispatches per tick." }, default: true,
description: "Enable the heartbeat service.",
},
intervalSeconds: {
type: "number",
default: 60,
description: "Seconds between ticks.",
},
maxPickupsPerTick: {
type: "number",
default: 4,
description: "Max worker dispatches per tick.",
},
}, },
}, },
}, },
@@ -84,10 +98,14 @@ const plugin = {
// Operations // Operations
api.registerTool(createStatusTool(api), { names: ["status"] }); api.registerTool(createStatusTool(api), { names: ["status"] });
api.registerTool(createHealthTool(api), { names: ["health"] }); api.registerTool(createHealthTool(api), { names: ["health"] });
api.registerTool(createWorkHeartbeatTool(api), { names: ["work_heartbeat"] }); api.registerTool(createWorkHeartbeatTool(api), {
names: ["work_heartbeat"],
});
// Setup & config // Setup & config
api.registerTool(createProjectRegisterTool(api), { names: ["project_register"] }); api.registerTool(createProjectRegisterTool(api), {
names: ["project_register"],
});
api.registerTool(createSetupTool(api), { names: ["setup"] }); api.registerTool(createSetupTool(api), { names: ["setup"] });
api.registerTool(createOnboardTool(api), { names: ["onboard"] }); api.registerTool(createOnboardTool(api), { names: ["onboard"] });
@@ -99,7 +117,9 @@ const plugin = {
// Services // Services
registerHeartbeatService(api); registerHeartbeatService(api);
api.logger.info("DevClaw plugin registered (11 tools, 1 service, 1 CLI command)"); api.logger.info(
"DevClaw plugin registered (11 tools, 1 service, 1 CLI command)",
);
}, },
}; };

View File

@@ -24,13 +24,15 @@ export function registerCli(program: Command): void {
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.junior})`) .option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.junior})`)
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.medior})`) .option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.medior})`)
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.senior})`) .option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.senior})`)
.option("--qa <model>", `QA engineer model (default: ${DEFAULT_MODELS.qa})`) .option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.reviewer})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.tester})`)
.action(async (opts) => { .action(async (opts) => {
const models: Partial<Record<Tier, string>> = {}; const models: Partial<Record<Tier, string>> = {};
if (opts.junior) models.junior = opts.junior; if (opts.junior) models.junior = opts.junior;
if (opts.medior) models.medior = opts.medior; if (opts.medior) models.medior = opts.medior;
if (opts.senior) models.senior = opts.senior; if (opts.senior) models.senior = opts.senior;
if (opts.qa) models.qa = opts.qa; if (opts.reviewer) models.reviewer = opts.reviewer;
if (opts.tester) models.tester = opts.tester;
const result = await runSetup({ const result = await runSetup({
newAgentName: opts.newAgent, newAgentName: opts.newAgent,

View File

@@ -5,8 +5,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { TIER_MIGRATION } from "./tiers.js";
export type WorkerState = { export type WorkerState = {
active: boolean; active: boolean;
issueId: string | null; issueId: string | null;
@@ -36,63 +34,13 @@ export type ProjectsData = {
projects: Record<string, Project>; projects: Record<string, Project>;
}; };
/** function parseWorkerState(worker: Record<string, unknown>): WorkerState {
* Migrate old WorkerState schema to current format.
*
* Handles three migrations:
* 1. Old sessionId field → sessions map (pre-sessions era)
* 2. Model-alias session keys → tier-name keys (haiku→junior, sonnet→medior, etc.)
* 3. Old "model" field name → "tier" field name
*/
function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
// Read tier from either "tier" (new) or "model" (old) field
const rawTier = (worker.tier ?? worker.model) as string | null;
// Migration 1: old sessionId field → sessions map
if (!worker.sessions || typeof worker.sessions !== "object") {
const sessionId = worker.sessionId as string | null;
const sessions: Record<string, string | null> = {};
if (sessionId && rawTier) {
const tierKey = TIER_MIGRATION[rawTier] ?? rawTier;
sessions[tierKey] = sessionId;
}
return { return {
active: worker.active as boolean, active: worker.active as boolean,
issueId: worker.issueId as string | null, issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null, startTime: worker.startTime as string | null,
tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null, tier: worker.tier as string | null,
sessions, sessions: (worker.sessions as Record<string, string | null>) ?? {},
};
}
// Migration 2: model-alias session keys → tier-name keys
const oldSessions = worker.sessions as Record<string, string | null>;
const needsMigration = Object.keys(oldSessions).some((key) => key in TIER_MIGRATION);
if (needsMigration) {
const newSessions: Record<string, string | null> = {};
for (const [key, value] of Object.entries(oldSessions)) {
const newKey = TIER_MIGRATION[key] ?? key;
newSessions[newKey] = value;
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions: newSessions,
};
}
// Migration 3: "model" field → "tier" field (already handled by rawTier above)
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions: oldSessions,
}; };
} }
@@ -133,10 +81,10 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
for (const project of Object.values(data.projects)) { for (const project of Object.values(data.projects)) {
project.dev = project.dev project.dev = project.dev
? migrateWorkerState(project.dev as unknown as Record<string, unknown>) ? parseWorkerState(project.dev as unknown as Record<string, unknown>)
: emptyWorkerState([]); : emptyWorkerState([]);
project.qa = project.qa project.qa = project.qa
? migrateWorkerState(project.qa as unknown as Record<string, unknown>) ? parseWorkerState(project.qa as unknown as Record<string, unknown>)
: emptyWorkerState([]); : emptyWorkerState([]);
if (!project.channel) { if (!project.channel) {
project.channel = "telegram"; project.channel = "telegram";

View File

@@ -10,7 +10,7 @@ import { createProvider } from "../providers/index.js";
import { selectTier } from "../model-selector.js"; import { selectTier } from "../model-selector.js";
import { getWorker, getSessionForTier, readProjects } from "../projects.js"; import { getWorker, getSessionForTier, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { ALL_TIERS, type Tier } from "../tiers.js"; import { ALL_TIERS, isDevTier, type Tier } from "../tiers.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared constants + helpers (used by tick, work-start, auto-pickup) // Shared constants + helpers (used by tick, work-start, auto-pickup)
@@ -189,8 +189,10 @@ export async function projectTick(opts: {
function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string { function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string {
const labelTier = detectTierFromLabels(issue.labels); const labelTier = detectTierFromLabels(issue.labels);
if (labelTier) { if (labelTier) {
if (role === "qa" && labelTier !== "qa") return "qa"; // QA role but label specifies a dev tier → heuristic picks the right QA tier
if (role === "dev" && labelTier === "qa") return selectTier(issue.title, issue.description ?? "", role).tier; if (role === "qa" && isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier;
// DEV role but label specifies a QA tier → heuristic picks the right dev tier
if (role === "dev" && !isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier;
return labelTier; return labelTier;
} }
return selectTier(issue.title, issue.description ?? "", role).tier; return selectTier(issue.title, issue.description ?? "", role).tier;

View File

@@ -6,7 +6,7 @@
*/ */
export const DEV_TIERS = ["junior", "medior", "senior"] as const; export const DEV_TIERS = ["junior", "medior", "senior"] as const;
export const QA_TIERS = ["qa-engineer", "manual-tester"] as const; export const QA_TIERS = ["reviewer", "tester"] as const;
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const; export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
export type DevTier = (typeof DEV_TIERS)[number]; export type DevTier = (typeof DEV_TIERS)[number];
@@ -17,8 +17,8 @@ export const DEFAULT_MODELS: Record<Tier, string> = {
junior: "anthropic/claude-haiku-4-5", junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5", medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5", senior: "anthropic/claude-opus-4-5",
"qa-engineer": "anthropic/claude-sonnet-4-5", reviewer: "anthropic/claude-sonnet-4-5",
"manual-tester": "anthropic/claude-haiku-4-5", tester: "anthropic/claude-haiku-4-5",
}; };
/** Default models by role-tier structure. */ /** Default models by role-tier structure. */
@@ -29,8 +29,8 @@ export const DEFAULT_MODELS_BY_ROLE = {
senior: "anthropic/claude-opus-4-5", senior: "anthropic/claude-opus-4-5",
}, },
qa: { qa: {
"qa-engineer": "anthropic/claude-sonnet-4-5", reviewer: "anthropic/claude-sonnet-4-5",
"manual-tester": "anthropic/claude-haiku-4-5", tester: "anthropic/claude-haiku-4-5",
}, },
}; };
@@ -39,8 +39,8 @@ export const TIER_EMOJI: Record<Tier, string> = {
junior: "⚡", junior: "⚡",
medior: "🔧", medior: "🔧",
senior: "🧠", senior: "🧠",
"qa-engineer": "🔍", reviewer: "🔍",
"manual-tester": "👀", tester: "👀",
}; };
/** Check if a string is a valid tier name. */ /** Check if a string is a valid tier name. */
@@ -83,15 +83,3 @@ export function resolveTierToModel(
return DEFAULT_MODELS[tier as Tier] ?? tier; return DEFAULT_MODELS[tier as Tier] ?? tier;
} }
/**
* Migration map from old model-alias session keys to tier names.
* Used by migrateWorkerState() in projects.ts.
*/
export const TIER_MIGRATION: Record<string, string> = {
haiku: "junior",
sonnet: "medior",
opus: "senior",
grok: "qa-engineer",
qa: "qa-engineer",
};

View File

@@ -18,56 +18,113 @@ export function createSetupTool(api: OpenClawPluginApi) {
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
newAgentName: { type: "string", description: "Create a new agent. Omit to configure current workspace." }, newAgentName: {
channelBinding: { type: "string", enum: ["telegram", "whatsapp"], description: "Channel to bind (optional, with newAgentName only)." }, type: "string",
migrateFrom: { type: "string", description: "Agent ID to migrate channel binding from. Check openclaw.json bindings first." }, description:
"Create a new agent. Omit to configure current workspace.",
},
channelBinding: {
type: "string",
enum: ["telegram", "whatsapp"],
description: "Channel to bind (optional, with newAgentName only).",
},
migrateFrom: {
type: "string",
description:
"Agent ID to migrate channel binding from. Check openclaw.json bindings first.",
},
models: { models: {
type: "object", description: "Model overrides per role and tier.", type: "object",
description: "Model overrides per role and tier.",
properties: { properties: {
dev: { dev: {
type: "object", description: "Developer tier models", type: "object",
description: "Developer tier models",
properties: { properties: {
junior: { type: "string", description: `Default: ${DEFAULT_MODELS.junior}` }, junior: {
medior: { type: "string", description: `Default: ${DEFAULT_MODELS.medior}` }, type: "string",
senior: { type: "string", description: `Default: ${DEFAULT_MODELS.senior}` }, description: `Default: ${DEFAULT_MODELS.junior}`,
},
medior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.medior}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.senior}`,
},
}, },
}, },
qa: { qa: {
type: "object", description: "QA tier models", type: "object",
description: "QA tier models",
properties: { properties: {
"qa-engineer": { type: "string", description: `Default: ${DEFAULT_MODELS["qa-engineer"]}` }, reviewer: {
"manual-tester": { type: "string", description: `Default: ${DEFAULT_MODELS["manual-tester"]}` }, type: "string",
description: `Default: ${DEFAULT_MODELS.reviewer}`,
},
tester: {
type: "string",
description: `Default: ${DEFAULT_MODELS.tester}`,
}, },
}, },
}, },
}, },
projectExecution: { type: "string", enum: ["parallel", "sequential"], description: "Project execution mode. Default: parallel." }, },
projectExecution: {
type: "string",
enum: ["parallel", "sequential"],
description: "Project execution mode. Default: parallel.",
},
}, },
}, },
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const result = await runSetup({ const result = await runSetup({
newAgentName: params.newAgentName as string | undefined, newAgentName: params.newAgentName as string | undefined,
channelBinding: (params.channelBinding as "telegram" | "whatsapp") ?? null, channelBinding:
(params.channelBinding as "telegram" | "whatsapp") ?? null,
migrateFrom: params.migrateFrom as string | undefined, migrateFrom: params.migrateFrom as string | undefined,
agentId: params.newAgentName ? undefined : ctx.agentId, agentId: params.newAgentName ? undefined : ctx.agentId,
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir, workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
models: params.models as Partial<Record<Tier, string>> | undefined, models: params.models as Partial<Record<Tier, string>> | undefined,
projectExecution: params.projectExecution as "parallel" | "sequential" | undefined, projectExecution: params.projectExecution as
| "parallel"
| "sequential"
| undefined,
}); });
const lines = [ const lines = [
result.agentCreated ? `Agent "${result.agentId}" created` : `Configured "${result.agentId}"`, result.agentCreated
? `Agent "${result.agentId}" created`
: `Configured "${result.agentId}"`,
"", "",
]; ];
if (result.bindingMigrated) { if (result.bindingMigrated) {
lines.push(`✅ Binding migrated: ${result.bindingMigrated.channel} (${result.bindingMigrated.from}${result.agentId})`, ""); lines.push(
`✅ Binding migrated: ${result.bindingMigrated.channel} (${result.bindingMigrated.from}${result.agentId})`,
"",
);
} }
lines.push("Models:", ...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`), "", "Files:", ...result.filesWritten.map((f) => ` ${f}`)); lines.push(
if (result.warnings.length > 0) lines.push("", "Warnings:", ...result.warnings.map((w) => ` ${w}`)); "Models:",
lines.push("", "Next: register a project, then create issues and pick them up."); ...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`),
"",
"Files:",
...result.filesWritten.map((f) => ` ${f}`),
);
if (result.warnings.length > 0)
lines.push("", "Warnings:", ...result.warnings.map((w) => ` ${w}`));
lines.push(
"",
"Next: register a project, then create issues and pick them up.",
);
return jsonResult({ success: true, ...result, summary: lines.join("\n") }); return jsonResult({
success: true,
...result,
summary: lines.join("\n"),
});
}, },
}); });
} }

View File

@@ -14,6 +14,7 @@ import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { notify, getNotificationConfig } from "../notify.js"; import { notify, getNotificationConfig } from "../notify.js";
import { findNextIssue, detectRoleFromLabel, detectTierFromLabels } from "../services/tick.js"; import { findNextIssue, detectRoleFromLabel, detectTierFromLabels } from "../services/tick.js";
import { isDevTier } from "../tiers.js";
import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js"; import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js";
export function createWorkStartTool(api: OpenClawPluginApi) { export function createWorkStartTool(api: OpenClawPluginApi) {
@@ -83,8 +84,8 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
} else { } else {
const labelTier = detectTierFromLabels(issue.labels); const labelTier = detectTierFromLabels(issue.labels);
if (labelTier) { if (labelTier) {
if (role === "qa" && labelTier !== "qa") { selectedTier = "qa"; tierReason = `QA overrides "${labelTier}"`; tierSource = "role-override"; } if (role === "qa" && isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = `QA overrides dev tier "${labelTier}"`; tierSource = "role-override"; }
else if (role === "dev" && labelTier === "qa") { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; } else if (role === "dev" && !isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; }
else { selectedTier = labelTier; tierReason = `Label: "${labelTier}"`; tierSource = "label"; } else { selectedTier = labelTier; tierReason = `Label: "${labelTier}"`; tierSource = "label"; }
} else { } else {
const s = selectTier(issue.title, issue.description ?? "", role); const s = selectTier(issue.title, issue.description ?? "", role);

View File

@@ -5,26 +5,55 @@
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"devClawAgentIds": {
"type": "array",
"items": { "type": "string" },
"description": "Agent IDs recognized as DevClaw orchestrators"
},
"models": { "models": {
"type": "object", "type": "object",
"description": "Model mapping per developer tier (junior, medior, senior, qa)", "description": "Model mapping per role and tier",
"properties": { "properties": {
"junior": { "dev": {
"type": "string", "type": "object",
"description": "Junior dev model (default: anthropic/claude-haiku-4-5)" "description": "Developer tier models",
}, "properties": {
"medior": { "junior": { "type": "string" },
"type": "string", "medior": { "type": "string" },
"description": "Medior dev model (default: anthropic/claude-sonnet-4-5)" "senior": { "type": "string" }
}, }
"senior": {
"type": "string",
"description": "Senior dev model (default: anthropic/claude-opus-4-5)"
}, },
"qa": { "qa": {
"type": "object",
"description": "QA tier models",
"properties": {
"reviewer": { "type": "string" },
"tester": { "type": "string" }
}
}
}
},
"projectExecution": {
"type": "string", "type": "string",
"description": "QA engineer model (default: anthropic/claude-sonnet-4-5)" "enum": ["parallel", "sequential"],
} "description": "Project execution mode. Default: parallel."
},
"notifications": {
"type": "object",
"description": "Notification settings",
"properties": {
"heartbeatDm": { "type": "boolean" },
"workerStart": { "type": "boolean" },
"workerComplete": { "type": "boolean" }
}
},
"work_heartbeat": {
"type": "object",
"description": "Token-free interval-based heartbeat service.",
"properties": {
"enabled": { "type": "boolean" },
"intervalSeconds": { "type": "number" },
"maxPickupsPerTick": { "type": "number" }
} }
} }
} }