diff --git a/index.ts b/index.ts index 5d09ac9..f278475 100644 --- a/index.ts +++ b/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)", + ); }, }; diff --git a/lib/cli.ts b/lib/cli.ts index f7f0c5b..5ab49e4 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -24,13 +24,15 @@ export function registerCli(program: Command): void { .option("--junior ", `Junior dev model (default: ${DEFAULT_MODELS.junior})`) .option("--medior ", `Medior dev model (default: ${DEFAULT_MODELS.medior})`) .option("--senior ", `Senior dev model (default: ${DEFAULT_MODELS.senior})`) - .option("--qa ", `QA engineer model (default: ${DEFAULT_MODELS.qa})`) + .option("--reviewer ", `Reviewer model (default: ${DEFAULT_MODELS.reviewer})`) + .option("--tester ", `Tester model (default: ${DEFAULT_MODELS.tester})`) .action(async (opts) => { const models: Partial> = {}; 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, diff --git a/lib/projects.ts b/lib/projects.ts index 4215e40..ff77f5c 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -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; }; -/** - * 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): 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 = {}; - - if (sessionId && rawTier) { - const tierKey = TIER_MIGRATION[rawTier] ?? rawTier; - sessions[tierKey] = sessionId; - } - - 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; - const needsMigration = Object.keys(oldSessions).some((key) => key in TIER_MIGRATION); - - if (needsMigration) { - const newSessions: Record = {}; - 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) +function parseWorkerState(worker: Record): 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: oldSessions, + tier: worker.tier as string | null, + sessions: (worker.sessions as Record) ?? {}, }; } @@ -133,10 +81,10 @@ export async function readProjects(workspaceDir: string): Promise for (const project of Object.values(data.projects)) { project.dev = project.dev - ? migrateWorkerState(project.dev as unknown as Record) + ? parseWorkerState(project.dev as unknown as Record) : emptyWorkerState([]); project.qa = project.qa - ? migrateWorkerState(project.qa as unknown as Record) + ? parseWorkerState(project.qa as unknown as Record) : emptyWorkerState([]); if (!project.channel) { project.channel = "telegram"; diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 373d96b..68a36d2 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -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; diff --git a/lib/tiers.ts b/lib/tiers.ts index 1ad7642..32613b7 100644 --- a/lib/tiers.ts +++ b/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 = { 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 = { 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 = { - haiku: "junior", - sonnet: "medior", - opus: "senior", - grok: "qa-engineer", - qa: "qa-engineer", -}; diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 08c0f3e..8dba277 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -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) { 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> | 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"), + }); }, }); } diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 7237a29..1f73b6d 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -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); diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 78b5a0e..5f9e07d 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -5,27 +5,56 @@ "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": "string", - "description": "QA engineer model (default: anthropic/claude-sonnet-4-5)" + "type": "object", + "description": "QA tier models", + "properties": { + "reviewer": { "type": "string" }, + "tester": { "type": "string" } + } } } + }, + "projectExecution": { + "type": "string", + "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" } + } } } }