feat: enhance role-tier structure for models and update related configurations
This commit is contained in:
40
index.ts
40
index.ts
@@ -38,8 +38,8 @@ const plugin = {
|
||||
type: "object",
|
||||
description: "QA tier models",
|
||||
properties: {
|
||||
"qa-engineer": { type: "string" },
|
||||
"manual-tester": { type: "string" },
|
||||
reviewer: { type: "string" },
|
||||
tester: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -47,7 +47,8 @@ const plugin = {
|
||||
projectExecution: {
|
||||
type: "string",
|
||||
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",
|
||||
},
|
||||
notifications: {
|
||||
@@ -61,11 +62,24 @@ const plugin = {
|
||||
},
|
||||
work_heartbeat: {
|
||||
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: {
|
||||
enabled: { type: "boolean", 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." },
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
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
|
||||
api.registerTool(createStatusTool(api), { names: ["status"] });
|
||||
api.registerTool(createHealthTool(api), { names: ["health"] });
|
||||
api.registerTool(createWorkHeartbeatTool(api), { names: ["work_heartbeat"] });
|
||||
api.registerTool(createWorkHeartbeatTool(api), {
|
||||
names: ["work_heartbeat"],
|
||||
});
|
||||
|
||||
// Setup & config
|
||||
api.registerTool(createProjectRegisterTool(api), { names: ["project_register"] });
|
||||
api.registerTool(createProjectRegisterTool(api), {
|
||||
names: ["project_register"],
|
||||
});
|
||||
api.registerTool(createSetupTool(api), { names: ["setup"] });
|
||||
api.registerTool(createOnboardTool(api), { names: ["onboard"] });
|
||||
|
||||
@@ -99,7 +117,9 @@ const plugin = {
|
||||
// Services
|
||||
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)",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -24,13 +24,15 @@ export function registerCli(program: Command): void {
|
||||
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.junior})`)
|
||||
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.medior})`)
|
||||
.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) => {
|
||||
const models: Partial<Record<Tier, string>> = {};
|
||||
if (opts.junior) models.junior = opts.junior;
|
||||
if (opts.medior) models.medior = opts.medior;
|
||||
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({
|
||||
newAgentName: opts.newAgent,
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { TIER_MIGRATION } from "./tiers.js";
|
||||
|
||||
export type WorkerState = {
|
||||
active: boolean;
|
||||
issueId: string | null;
|
||||
@@ -36,63 +34,13 @@ export type ProjectsData = {
|
||||
projects: Record<string, Project>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
function parseWorkerState(worker: Record<string, unknown>): WorkerState {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
tier: worker.tier as string | null,
|
||||
sessions: (worker.sessions as Record<string, string | null>) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,10 +81,10 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
|
||||
|
||||
for (const project of Object.values(data.projects)) {
|
||||
project.dev = project.dev
|
||||
? migrateWorkerState(project.dev as unknown as Record<string, unknown>)
|
||||
? parseWorkerState(project.dev as unknown as Record<string, unknown>)
|
||||
: emptyWorkerState([]);
|
||||
project.qa = project.qa
|
||||
? migrateWorkerState(project.qa as unknown as Record<string, unknown>)
|
||||
? parseWorkerState(project.qa as unknown as Record<string, unknown>)
|
||||
: emptyWorkerState([]);
|
||||
if (!project.channel) {
|
||||
project.channel = "telegram";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createProvider } from "../providers/index.js";
|
||||
import { selectTier } from "../model-selector.js";
|
||||
import { getWorker, getSessionForTier, readProjects } from "../projects.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)
|
||||
@@ -189,8 +189,10 @@ export async function projectTick(opts: {
|
||||
function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string {
|
||||
const labelTier = detectTierFromLabels(issue.labels);
|
||||
if (labelTier) {
|
||||
if (role === "qa" && labelTier !== "qa") return "qa";
|
||||
if (role === "dev" && labelTier === "qa") return selectTier(issue.title, issue.description ?? "", role).tier;
|
||||
// QA role but label specifies a dev tier → heuristic picks the right QA 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 selectTier(issue.title, issue.description ?? "", role).tier;
|
||||
|
||||
26
lib/tiers.ts
26
lib/tiers.ts
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
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 type DevTier = (typeof DEV_TIERS)[number];
|
||||
@@ -17,8 +17,8 @@ export const DEFAULT_MODELS: Record<Tier, string> = {
|
||||
junior: "anthropic/claude-haiku-4-5",
|
||||
medior: "anthropic/claude-sonnet-4-5",
|
||||
senior: "anthropic/claude-opus-4-5",
|
||||
"qa-engineer": "anthropic/claude-sonnet-4-5",
|
||||
"manual-tester": "anthropic/claude-haiku-4-5",
|
||||
reviewer: "anthropic/claude-sonnet-4-5",
|
||||
tester: "anthropic/claude-haiku-4-5",
|
||||
};
|
||||
|
||||
/** Default models by role-tier structure. */
|
||||
@@ -29,8 +29,8 @@ export const DEFAULT_MODELS_BY_ROLE = {
|
||||
senior: "anthropic/claude-opus-4-5",
|
||||
},
|
||||
qa: {
|
||||
"qa-engineer": "anthropic/claude-sonnet-4-5",
|
||||
"manual-tester": "anthropic/claude-haiku-4-5",
|
||||
reviewer: "anthropic/claude-sonnet-4-5",
|
||||
tester: "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,8 +39,8 @@ export const TIER_EMOJI: Record<Tier, string> = {
|
||||
junior: "⚡",
|
||||
medior: "🔧",
|
||||
senior: "🧠",
|
||||
"qa-engineer": "🔍",
|
||||
"manual-tester": "👀",
|
||||
reviewer: "🔍",
|
||||
tester: "👀",
|
||||
};
|
||||
|
||||
/** Check if a string is a valid tier name. */
|
||||
@@ -83,15 +83,3 @@ export function resolveTierToModel(
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -18,56 +18,113 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
newAgentName: { type: "string", 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." },
|
||||
newAgentName: {
|
||||
type: "string",
|
||||
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: {
|
||||
type: "object", description: "Model overrides per role and tier.",
|
||||
type: "object",
|
||||
description: "Model overrides per role and tier.",
|
||||
properties: {
|
||||
dev: {
|
||||
type: "object", description: "Developer tier models",
|
||||
type: "object",
|
||||
description: "Developer tier models",
|
||||
properties: {
|
||||
junior: { type: "string", description: `Default: ${DEFAULT_MODELS.junior}` },
|
||||
medior: { type: "string", description: `Default: ${DEFAULT_MODELS.medior}` },
|
||||
senior: { type: "string", description: `Default: ${DEFAULT_MODELS.senior}` },
|
||||
junior: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.junior}`,
|
||||
},
|
||||
medior: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.medior}`,
|
||||
},
|
||||
senior: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.senior}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
qa: {
|
||||
type: "object", description: "QA tier models",
|
||||
type: "object",
|
||||
description: "QA tier models",
|
||||
properties: {
|
||||
"qa-engineer": { type: "string", description: `Default: ${DEFAULT_MODELS["qa-engineer"]}` },
|
||||
"manual-tester": { type: "string", description: `Default: ${DEFAULT_MODELS["manual-tester"]}` },
|
||||
reviewer: {
|
||||
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>) {
|
||||
const result = await runSetup({
|
||||
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,
|
||||
agentId: params.newAgentName ? undefined : ctx.agentId,
|
||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||
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 = [
|
||||
result.agentCreated ? `Agent "${result.agentId}" created` : `Configured "${result.agentId}"`,
|
||||
result.agentCreated
|
||||
? `Agent "${result.agentId}" created`
|
||||
: `Configured "${result.agentId}"`,
|
||||
"",
|
||||
];
|
||||
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}`));
|
||||
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.");
|
||||
lines.push(
|
||||
"Models:",
|
||||
...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"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getWorker } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { notify, getNotificationConfig } from "../notify.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";
|
||||
|
||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
@@ -83,8 +84,8 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
} else {
|
||||
const labelTier = detectTierFromLabels(issue.labels);
|
||||
if (labelTier) {
|
||||
if (role === "qa" && labelTier !== "qa") { selectedTier = "qa"; tierReason = `QA overrides "${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"; }
|
||||
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" && !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 {
|
||||
const s = selectTier(issue.title, issue.description ?? "", role);
|
||||
|
||||
@@ -5,26 +5,55 @@
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"devClawAgentIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Agent IDs recognized as DevClaw orchestrators"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"description": "Model mapping per developer tier (junior, medior, senior, qa)",
|
||||
"description": "Model mapping per role and tier",
|
||||
"properties": {
|
||||
"junior": {
|
||||
"type": "string",
|
||||
"description": "Junior dev model (default: anthropic/claude-haiku-4-5)"
|
||||
},
|
||||
"medior": {
|
||||
"type": "string",
|
||||
"description": "Medior dev model (default: anthropic/claude-sonnet-4-5)"
|
||||
},
|
||||
"senior": {
|
||||
"type": "string",
|
||||
"description": "Senior dev model (default: anthropic/claude-opus-4-5)"
|
||||
"dev": {
|
||||
"type": "object",
|
||||
"description": "Developer tier models",
|
||||
"properties": {
|
||||
"junior": { "type": "string" },
|
||||
"medior": { "type": "string" },
|
||||
"senior": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"qa": {
|
||||
"type": "object",
|
||||
"description": "QA tier models",
|
||||
"properties": {
|
||||
"reviewer": { "type": "string" },
|
||||
"tester": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"projectExecution": {
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user