diff --git a/lib/cli.ts b/lib/cli.ts index d9c4746..30c1dcd 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -5,7 +5,7 @@ */ import type { Command } from "commander"; import { runSetup } from "./setup/index.js"; -import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js"; +import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "./tiers.js"; /** * Register the `devclaw` CLI command group on a Commander program. @@ -27,18 +27,24 @@ export function registerCli(program: Command): void { .option("--reviewer ", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`) .option("--tester ", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`) .action(async (opts) => { - const models: Partial> = {}; - if (opts.junior) models["dev.junior"] = opts.junior; - if (opts.medior) models["dev.medior"] = opts.medior; - if (opts.senior) models["dev.senior"] = opts.senior; - if (opts.reviewer) models["qa.reviewer"] = opts.reviewer; - if (opts.tester) models["qa.tester"] = opts.tester; + const dev: Record = {}; + const qa: Record = {}; + if (opts.junior) dev.junior = opts.junior; + if (opts.medior) dev.medior = opts.medior; + if (opts.senior) dev.senior = opts.senior; + if (opts.reviewer) qa.reviewer = opts.reviewer; + if (opts.tester) qa.tester = opts.tester; + + const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0; + const models = hasOverrides + ? { ...(Object.keys(dev).length > 0 && { dev }), ...(Object.keys(qa).length > 0 && { qa }) } + : undefined; const result = await runSetup({ newAgentName: opts.newAgent, agentId: opts.agent, workspacePath: opts.workspace, - models: Object.keys(models).length > 0 ? models : undefined, + models, }); if (result.agentCreated) { @@ -46,9 +52,8 @@ export function registerCli(program: Command): void { } console.log("Models configured:"); - for (const tier of ALL_TIERS) { - console.log(` ${tier}: ${result.models[tier]}`); - } + 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]}`); console.log("Files written:"); for (const file of result.filesWritten) { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 6d2d2b1..a83ffc9 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -12,10 +12,10 @@ import { log as auditLog } from "./audit.js"; import { type Project, activateWorker, - getSessionForTier, + getSessionForLevel, getWorker, } from "./projects.js"; -import { tierEmoji, resolveTierToModel } from "./tiers.js"; +import { resolveModel, levelEmoji } from "./tiers.js"; const execFileAsync = promisify(execFile); @@ -29,8 +29,8 @@ export type DispatchOpts = { issueDescription: string; issueUrl: string; role: "dev" | "qa"; - /** Developer tier (junior, medior, senior, qa) or raw model ID */ - tier: string; + /** Developer level (junior, medior, senior, reviewer) or raw model ID */ + level: string; /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ fromLabel: string; /** Label to transition TO (e.g. "Doing", "Testing") */ @@ -46,14 +46,14 @@ export type DispatchOpts = { export type DispatchResult = { sessionAction: "spawn" | "send"; sessionKey: string; - tier: string; + level: string; model: string; announcement: string; }; /** * Build the task message sent to a worker session. - * Reads role-specific instructions from workspace/projects/prompts//.md. + * Reads role-specific instructions from workspace/projects/roles//.md (falls back to projects/roles/default/). */ export async function buildTaskMessage(opts: { workspaceDir: string; @@ -125,13 +125,13 @@ export async function dispatchTask( ): Promise { const { workspaceDir, agentId, groupId, project, issueId, issueTitle, - issueDescription, issueUrl, role, tier, fromLabel, toLabel, + issueDescription, issueUrl, role, level, fromLabel, toLabel, transitionLabel, pluginConfig, } = opts; - const model = resolveTierToModel(tier, pluginConfig); + const model = resolveModel(role, level, pluginConfig); const worker = getWorker(project, role); - const existingSessionKey = getSessionForTier(worker, tier); + const existingSessionKey = getSessionForLevel(worker, level); const sessionAction = existingSessionKey ? "send" : "spawn"; const taskMessage = await buildTaskMessage({ @@ -147,7 +147,7 @@ export async function dispatchTask( try { sessionKey = await ensureSession(sessionAction, sessionKey, { - agentId, projectName: project.name, role, tier, model, + agentId, projectName: project.name, role, level, model, }); await sendToAgent(sessionKey!, taskMessage, { @@ -158,7 +158,7 @@ export async function dispatchTask( dispatched = true; await recordWorkerState(workspaceDir, groupId, role, { - issueId, tier, sessionKey: sessionKey!, sessionAction, + issueId, level, sessionKey: sessionKey!, sessionAction, }); } catch (err) { if (dispatched) { @@ -179,13 +179,13 @@ export async function dispatchTask( await auditDispatch(workspaceDir, { project: project.name, groupId, issueId, issueTitle, - role, tier, model, sessionAction, sessionKey: sessionKey!, + role, level, model, sessionAction, sessionKey: sessionKey!, fromLabel, toLabel, }); - const announcement = buildAnnouncement(tier, role, sessionAction, issueId, issueTitle, issueUrl); + const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl); - return { sessionAction, sessionKey: sessionKey!, tier, model, announcement }; + return { sessionAction, sessionKey: sessionKey!, level, model, announcement }; } // --------------------------------------------------------------------------- @@ -195,19 +195,21 @@ export async function dispatchTask( async function loadRoleInstructions( workspaceDir: string, projectName: string, role: "dev" | "qa", ): Promise { - const projectFile = path.join(workspaceDir, "projects", "prompts", projectName, `${role}.md`); + const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); try { return await fs.readFile(projectFile, "utf-8"); } catch { /* none */ } + const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`); + try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ } return ""; } async function ensureSession( action: "spawn" | "send", existingKey: string | null, - opts: { agentId?: string; projectName: string; role: string; tier: string; model: string }, + opts: { agentId?: string; projectName: string; role: string; level: string; model: string }, ): Promise { if (action === "send") return existingKey!; - const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.tier}`; + const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.level}`; await execFileAsync( "openclaw", ["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })], @@ -239,12 +241,12 @@ function sendToAgent( async function recordWorkerState( workspaceDir: string, groupId: string, role: "dev" | "qa", - opts: { issueId: number; tier: string; sessionKey: string; sessionAction: "spawn" | "send" }, + opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" }, ): Promise { - const params: { issueId: string; tier: string; sessionKey?: string; startTime: string } = { + const params: { issueId: string; level: string; sessionKey?: string; startTime: string } = { issueId: String(opts.issueId), - tier: opts.tier, - startTime: new Date().toISOString(), // Always reset startTime for new task assignment + level: opts.level, + startTime: new Date().toISOString(), }; if (opts.sessionAction === "spawn") { params.sessionKey = opts.sessionKey; @@ -256,27 +258,27 @@ async function auditDispatch( workspaceDir: string, opts: { project: string; groupId: string; issueId: number; issueTitle: string; - role: string; tier: string; model: string; sessionAction: string; + role: string; level: string; model: string; sessionAction: string; sessionKey: string; fromLabel: string; toLabel: string; }, ): Promise { await auditLog(workspaceDir, "work_start", { project: opts.project, groupId: opts.groupId, issue: opts.issueId, issueTitle: opts.issueTitle, - role: opts.role, tier: opts.tier, + role: opts.role, level: opts.level, sessionAction: opts.sessionAction, sessionKey: opts.sessionKey, labelTransition: `${opts.fromLabel} → ${opts.toLabel}`, }); await auditLog(workspaceDir, "model_selection", { - issue: opts.issueId, role: opts.role, tier: opts.tier, model: opts.model, + issue: opts.issueId, role: opts.role, level: opts.level, model: opts.model, }); } function buildAnnouncement( - tier: string, role: string, sessionAction: "spawn" | "send", + level: string, role: string, sessionAction: "spawn" | "send", issueId: number, issueTitle: string, issueUrl: string, ): string { - const emoji = tierEmoji(tier) ?? (role === "qa" ? "šŸ”" : "šŸ”§"); + const emoji = levelEmoji(role as "dev" | "qa", level) ?? (role === "qa" ? "šŸ”" : "šŸ”§"); const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; - return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\nšŸ”— ${issueUrl}`; + return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\nšŸ”— ${issueUrl}`; } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index 905a58a..1c1dd6c 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -1,11 +1,11 @@ /** * Model selection for dev/qa tasks. - * Keyword heuristic fallback — used when the orchestrator doesn't specify a tier. - * Returns full tier names (dev.junior, dev.medior, dev.senior, qa.reviewer, qa.tester). + * Keyword heuristic fallback — used when the orchestrator doesn't specify a level. + * Returns plain level names (junior, medior, senior, reviewer, tester). */ -export type TierRecommendation = { - tier: string; +export type LevelSelection = { + level: string; reason: string; }; @@ -39,24 +39,24 @@ const COMPLEX_KEYWORDS = [ ]; /** - * Select appropriate developer tier based on task description. + * Select appropriate developer level based on task description. * - * Developer tiers: - * - dev.junior: very simple (typos, single-file fixes, CSS tweaks) - * - dev.medior: standard DEV (features, bug fixes, multi-file changes) - * - dev.senior: deep/architectural (system-wide refactoring, novel design) - * - qa.reviewer: QA code inspection and validation - * - qa.tester: QA manual testing + * Developer levels: + * - junior: very simple (typos, single-file fixes, CSS tweaks) + * - medior: standard DEV (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 selectTier( +export function selectLevel( issueTitle: string, issueDescription: string, role: "dev" | "qa", -): TierRecommendation { +): LevelSelection { if (role === "qa") { return { - tier: "qa.reviewer", - reason: "Default QA tier for code inspection and validation", + level: "reviewer", + reason: "Default QA level for code inspection and validation", }; } @@ -67,7 +67,7 @@ export function selectTier( const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw)); if (isSimple && wordCount < 100) { return { - tier: "dev.junior", + level: "junior", reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`, }; } @@ -76,14 +76,14 @@ export function selectTier( const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); if (isComplex || wordCount > 500) { return { - tier: "dev.senior", + level: "senior", reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`, }; } // Default: medior for standard dev work return { - tier: "dev.medior", + level: "medior", reason: "Standard dev task — multi-file changes, features, bug fixes", }; } diff --git a/lib/notify.ts b/lib/notify.ts index 5fc6be2..3d5b4dd 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -34,7 +34,7 @@ export type NotifyEvent = issueTitle: string; issueUrl: string; role: "dev" | "qa"; - tier: string; + level: string; sessionAction: "spawn" | "send"; } | { @@ -69,7 +69,7 @@ function buildMessage(event: NotifyEvent): string { switch (event.type) { case "workerStart": { const action = event.sessionAction === "spawn" ? "šŸš€ Started" : "ā–¶ļø Resumed"; - return `${action} ${event.role.toUpperCase()} (${event.tier}) on #${event.issueId}: ${event.issueTitle}\nšŸ”— ${event.issueUrl}`; + return `${action} ${event.role.toUpperCase()} (${event.level}) on #${event.issueId}: ${event.issueTitle}\nšŸ”— ${event.issueUrl}`; } case "workerComplete": { @@ -253,7 +253,7 @@ export async function notifyTickPickups( issueTitle: pickup.issueTitle, issueUrl: pickup.issueUrl, role: pickup.role, - tier: pickup.tier, + level: pickup.level, sessionAction: pickup.sessionAction, }, { diff --git a/lib/onboarding.ts b/lib/onboarding.ts index 1ce28b4..59f21b9 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -5,7 +5,7 @@ */ import fs from "node:fs/promises"; import path from "node:path"; -import { ALL_TIERS, defaultModel } from "./tiers.js"; +import { DEFAULT_MODELS } from "./tiers.js"; // --------------------------------------------------------------------------- // Detection @@ -38,12 +38,15 @@ export async function hasWorkspaceFiles( // --------------------------------------------------------------------------- function buildModelTable(pluginConfig?: Record): string { - const models = - (pluginConfig as { models?: Record })?.models ?? {}; - return ALL_TIERS.map( - (t) => - ` - **${t}**: ${models[t] || defaultModel(t)} (default: ${defaultModel(t)})`, - ).join("\n"); + const cfg = (pluginConfig as { models?: { dev?: Record; qa?: Record } })?.models; + const lines: string[] = []; + for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { + for (const [level, defaultModel] of Object.entries(levels)) { + const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel; + lines.push(` - **${role} ${level}**: ${model} (default: ${defaultModel})`); + } + } + return lines.join("\n"); } export function buildReconfigContext( @@ -57,7 +60,7 @@ The user wants to reconfigure DevClaw. Current model configuration: ${modelTable} ## What can be changed -1. **Model tiers** — call \`setup\` with a \`models\` object containing only the tiers to change +1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change 2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files) 3. **Register new projects** — use \`project_register\` @@ -67,12 +70,28 @@ Ask what they want to change, then call the appropriate tool. } export function buildOnboardToolContext(): string { + // Build the model table dynamically from DEFAULT_MODELS + const rows: string[] = []; + const purposes: Record = { + junior: "Typos, single-file fixes", + medior: "Features, bug fixes", + senior: "Architecture, refactoring", + reviewer: "Code review", + tester: "Testing", + }; + for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { + for (const [level, model] of Object.entries(levels)) { + rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`); + } + } + const modelTable = rows.join("\n"); + return `# DevClaw Onboarding ## 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 tiers) that write code in isolated sessions +- **DEV workers** (junior/medior/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 @@ -92,21 +111,17 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de - If none selected, user can add bindings manually later via openclaw.json **Step 2: Model Configuration** -Show the default tier-to-model mapping and ask if they want to customize: +Show the default level-to-model mapping and ask if they want to customize: -| Tier | Default Model | Purpose | -|------|---------------|---------| -| dev.junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes | -| dev.medior | anthropic/claude-sonnet-4-5 | Features, bug fixes | -| dev.senior | anthropic/claude-opus-4-5 | Architecture, refactoring | -| qa.reviewer | anthropic/claude-sonnet-4-5 | Code review | -| qa.tester | anthropic/claude-haiku-4-5 | Testing | +| Role | Level | Default Model | Purpose | +|------|-------|---------------|---------| +${modelTable} -If the defaults are fine, proceed. If customizing, ask which tiers to change. +If the defaults are fine, proceed. If customizing, ask which levels to change. **Step 3: Run Setup** Call \`setup\` with the collected answers: -- Current agent: \`setup({})\` or \`setup({ models: { ... } })\` +- Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\` - New agent: \`setup({ newAgentName: "", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: ""|null, models: { ... } })\` - \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding diff --git a/lib/projects.ts b/lib/projects.ts index eb5d51b..af8a87e 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -9,7 +9,7 @@ export type WorkerState = { active: boolean; issueId: string | null; startTime: string | null; - tier: string | null; + level: string | null; sessions: Record; }; @@ -39,36 +39,36 @@ function parseWorkerState(worker: Record): WorkerState { active: worker.active as boolean, issueId: worker.issueId as string | null, startTime: worker.startTime as string | null, - tier: worker.tier as string | null, + level: (worker.level ?? worker.tier ?? null) as string | null, sessions: (worker.sessions as Record) ?? {}, }; } /** - * Create a blank WorkerState with null sessions for given tier names. + * Create a blank WorkerState with null sessions for given level names. */ -export function emptyWorkerState(tiers: string[]): WorkerState { +export function emptyWorkerState(levels: string[]): WorkerState { const sessions: Record = {}; - for (const t of tiers) { - sessions[t] = null; + for (const l of levels) { + sessions[l] = null; } return { active: false, issueId: null, startTime: null, - tier: null, + level: null, sessions, }; } /** - * Get session key for a specific tier from a worker's sessions map. + * Get session key for a specific level from a worker's sessions map. */ -export function getSessionForTier( +export function getSessionForLevel( worker: WorkerState, - tier: string, + level: string, ): string | null { - return worker.sessions[tier] ?? null; + return worker.sessions[level] ?? null; } function projectsPath(workspaceDir: string): string { @@ -148,7 +148,7 @@ export async function updateWorker( /** * Mark a worker as active with a new task. - * Stores session key in sessions[tier] when a new session is spawned. + * Stores session key in sessions[level] when a new session is spawned. */ export async function activateWorker( workspaceDir: string, @@ -156,7 +156,7 @@ export async function activateWorker( role: "dev" | "qa", params: { issueId: string; - tier: string; + level: string; sessionKey?: string; startTime?: string; }, @@ -164,10 +164,10 @@ export async function activateWorker( const updates: Partial = { active: true, issueId: params.issueId, - tier: params.tier, + level: params.level, }; if (params.sessionKey !== undefined) { - updates.sessions = { [params.tier]: params.sessionKey }; + updates.sessions = { [params.level]: params.sessionKey }; } if (params.startTime !== undefined) { updates.startTime = params.startTime; @@ -177,7 +177,7 @@ export async function activateWorker( /** * Mark a worker as inactive after task completion. - * Preserves sessions map and tier for reuse via updateWorker's spread. + * Preserves sessions map and level for reuse via updateWorker's spread. * Clears startTime to prevent stale timestamps on inactive workers. */ export async function deactivateWorker( diff --git a/lib/services/health.ts b/lib/services/health.ts index 031bbdb..e4a910b 100644 --- a/lib/services/health.ts +++ b/lib/services/health.ts @@ -6,7 +6,7 @@ */ import type { StateLabel } from "../providers/provider.js"; import { - getSessionForTier, + getSessionForLevel, getWorker, updateWorker, type Project, @@ -19,7 +19,7 @@ export type HealthIssue = { groupId: string; role: "dev" | "qa"; message: string; - tier?: string | null; + level?: string | null; sessionKey?: string | null; hoursActive?: number; issueId?: string | null; @@ -46,7 +46,7 @@ export async function checkWorkerHealth(opts: { const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts; const fixes: HealthFix[] = []; const worker = getWorker(project, role); - const sessionKey = worker.tier ? getSessionForTier(worker, worker.tier) : null; + const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null; const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test"; const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; @@ -62,14 +62,14 @@ export async function checkWorkerHealth(opts: { } } - // Check 1: Active but no session key for current tier + // Check 1: Active but no session key for current level if (worker.active && !sessionKey) { const fix: HealthFix = { issue: { type: "active_no_session", severity: "critical", project: project.name, groupId, role, - tier: worker.tier, - message: `${role.toUpperCase()} active but no session for tier "${worker.tier}"`, + level: worker.level, + message: `${role.toUpperCase()} active but no session for level "${worker.level}"`, }, fixed: false, }; @@ -86,7 +86,7 @@ export async function checkWorkerHealth(opts: { issue: { type: "zombie_session", severity: "critical", project: project.name, groupId, role, - sessionKey, tier: worker.tier, + sessionKey, level: worker.level, message: `${role.toUpperCase()} session not in active sessions list`, }, fixed: false, @@ -94,7 +94,7 @@ export async function checkWorkerHealth(opts: { if (autoFix) { await revertIssueLabel(fix); const sessions = { ...worker.sessions }; - if (worker.tier) sessions[worker.tier] = null; + if (worker.level) sessions[worker.level] = null; await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, startTime: null, sessions }); fix.fixed = true; } diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 68a36d2..e50645b 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -7,10 +7,10 @@ import type { Issue, StateLabel } from "../providers/provider.js"; import type { IssueProvider } from "../providers/provider.js"; import { createProvider } from "../providers/index.js"; -import { selectTier } from "../model-selector.js"; -import { getWorker, getSessionForTier, readProjects } from "../projects.js"; +import { selectLevel } from "../model-selector.js"; +import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { ALL_TIERS, isDevTier, type Tier } from "../tiers.js"; +import { DEV_LEVELS, QA_LEVELS, isDevLevel } from "../tiers.js"; // --------------------------------------------------------------------------- // Shared constants + helpers (used by tick, work-start, auto-pickup) @@ -20,9 +20,22 @@ export const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"]; export const QA_LABELS: StateLabel[] = ["To Test"]; export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"]; -export function detectTierFromLabels(labels: string[]): Tier | null { +export function detectLevelFromLabels(labels: string[]): string | null { const lower = labels.map((l) => l.toLowerCase()); - return (ALL_TIERS as readonly string[]).find((t) => lower.includes(t)) as Tier | undefined ?? null; + + // Match role.level labels (e.g., "dev.senior", "qa.reviewer") + for (const l of lower) { + const dot = l.indexOf("."); + if (dot === -1) continue; + const role = l.slice(0, dot); + const level = l.slice(dot + 1); + if (role === "dev" && (DEV_LEVELS as readonly string[]).includes(level)) return level; + if (role === "qa" && (QA_LEVELS as readonly string[]).includes(level)) return level; + } + + // Fallback: plain level name + const all = [...DEV_LEVELS, ...QA_LEVELS] as readonly string[]; + return all.find((l) => lower.includes(l)) ?? null; } export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null { @@ -77,7 +90,7 @@ export type TickAction = { issueTitle: string; issueUrl: string; role: "dev" | "qa"; - tier: string; + level: string; sessionAction: "spawn" | "send"; announcement: string; }; @@ -145,14 +158,14 @@ export async function projectTick(opts: { const { issue, label: currentLabel } = next; const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; - // Tier selection: label → heuristic - const selectedTier = resolveTierForIssue(issue, role); + // Level selection: label → heuristic + const selectedLevel = resolveLevelForIssue(issue, role); if (dryRun) { pickups.push({ project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, - role, tier: selectedTier, - sessionAction: getSessionForTier(worker, selectedTier) ? "send" : "spawn", + role, level: selectedLevel, + sessionAction: getSessionForLevel(worker, selectedLevel) ? "send" : "spawn", announcement: `[DRY RUN] Would pick up #${issue.iid}`, }); } else { @@ -160,13 +173,13 @@ export async function projectTick(opts: { const dr = await dispatchTask({ workspaceDir, agentId, groupId, project: fresh, issueId: issue.iid, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, - role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel, + role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel, transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), pluginConfig, sessionKey, }); pickups.push({ project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, - role, tier: dr.tier, sessionAction: dr.sessionAction, announcement: dr.announcement, + role, level: dr.level, sessionAction: dr.sessionAction, announcement: dr.announcement, }); } catch (err) { skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` }); @@ -184,16 +197,16 @@ export async function projectTick(opts: { // --------------------------------------------------------------------------- /** - * Determine the tier for an issue based on labels, role overrides, and heuristic fallback. + * Determine the level for an issue based on labels, role overrides, and heuristic fallback. */ -function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string { - const labelTier = detectTierFromLabels(issue.labels); - if (labelTier) { - // 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; +function resolveLevelForIssue(issue: Issue, role: "dev" | "qa"): string { + const labelLevel = detectLevelFromLabels(issue.labels); + if (labelLevel) { + // QA role but label specifies a dev level → heuristic picks the right QA level + if (role === "qa" && isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level; + // DEV role but label specifies a QA level → heuristic picks the right dev level + if (role === "dev" && !isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level; + return labelLevel; } - return selectTier(issue.title, issue.description ?? "", role).tier; + return selectLevel(issue.title, issue.description ?? "", role).level; } diff --git a/lib/setup/config.ts b/lib/setup/config.ts index 0cc7949..6d1c991 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -1,36 +1,20 @@ /** * setup/config.ts — Plugin config writer (openclaw.json). * - * Handles: model tier config, devClawAgentIds, tool restrictions, subagent cleanup. + * Handles: model level config, devClawAgentIds, tool restrictions, subagent cleanup. */ import fs from "node:fs/promises"; import path from "node:path"; -import { DEV_TIERS, QA_TIERS, tierName, type Tier } from "../tiers.js"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; +type ModelConfig = { dev: Record; qa: Record }; + function openclawConfigPath(): string { return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json"); } /** - * Convert flat tier map to nested role-tier structure. - */ -function buildRoleTierModels(models: Record): { dev: Record; qa: Record } { - const dev: Record = {}; - const qa: Record = {}; - - for (const tier of DEV_TIERS) { - dev[tierName(tier)] = models[tier]; - } - for (const tier of QA_TIERS) { - qa[tierName(tier)] = models[tier]; - } - - return { dev, qa }; -} - -/** - * Write DevClaw model tier config and devClawAgentIds to openclaw.json plugins section. + * Write DevClaw model level config and devClawAgentIds to openclaw.json plugins section. * * Also configures: * - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents @@ -39,7 +23,7 @@ function buildRoleTierModels(models: Record): { dev: Record, + models: ModelConfig, agentId?: string, projectExecution?: "parallel" | "sequential", ): Promise { @@ -47,7 +31,7 @@ export async function writePluginConfig( const config = JSON.parse(await fs.readFile(configPath, "utf-8")); ensurePluginStructure(config); - config.plugins.entries.devclaw.config.models = buildRoleTierModels(models); + config.plugins.entries.devclaw.config.models = models; if (projectExecution) { config.plugins.entries.devclaw.config.projectExecution = projectExecution; diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 9fac6d5..b1b8339 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -4,12 +4,14 @@ * Coordinates: agent creation → model config → workspace scaffolding. * Used by both the `setup` tool and the `openclaw devclaw setup` CLI command. */ -import { ALL_TIERS, allDefaultModels, type Tier } from "../tiers.js"; +import { DEFAULT_MODELS } from "../tiers.js"; import { migrateChannelBinding } from "../binding-manager.js"; import { createAgent, resolveWorkspacePath } from "./agent.js"; import { writePluginConfig } from "./config.js"; import { scaffoldWorkspace } from "./workspace.js"; +export type ModelConfig = { dev: Record; qa: Record }; + export type SetupOpts = { /** Create a new agent with this name. Mutually exclusive with agentId. */ newAgentName?: string; @@ -21,8 +23,8 @@ export type SetupOpts = { agentId?: string; /** Override workspace path (auto-detected from agent if not given). */ workspacePath?: string; - /** Model overrides per tier. Missing tiers use defaults. */ - models?: Partial>; + /** Model overrides per role.level. Missing levels use defaults. */ + models?: { dev?: Partial>; qa?: Partial> }; /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ projectExecution?: "parallel" | "sequential"; }; @@ -31,7 +33,7 @@ export type SetupResult = { agentId: string; agentCreated: boolean; workspacePath: string; - models: Record; + models: ModelConfig; filesWritten: string[]; warnings: string[]; bindingMigrated?: { @@ -107,14 +109,20 @@ async function tryMigrateBinding( } } -function buildModelConfig(overrides?: Partial>): Record { - const models = allDefaultModels(); - if (overrides) { - for (const [tier, model] of Object.entries(overrides)) { - if (model && (ALL_TIERS as readonly string[]).includes(tier)) { - models[tier as Tier] = model; - } +function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { + const dev: Record = { ...DEFAULT_MODELS.dev }; + const qa: Record = { ...DEFAULT_MODELS.qa }; + + if (overrides?.dev) { + for (const [level, model] of Object.entries(overrides.dev)) { + if (model) dev[level] = model; } } - return models; + if (overrides?.qa) { + for (const [level, model] of Object.entries(overrides.qa)) { + if (model) qa[level] = model; + } + } + + return { dev, qa }; } diff --git a/lib/setup/workspace.ts b/lib/setup/workspace.ts index 650112e..db5c182 100644 --- a/lib/setup/workspace.ts +++ b/lib/setup/workspace.ts @@ -8,6 +8,8 @@ import path from "node:path"; import { AGENTS_MD_TEMPLATE, HEARTBEAT_MD_TEMPLATE, + DEFAULT_DEV_INSTRUCTIONS, + DEFAULT_QA_INSTRUCTIONS, } from "../templates.js"; /** @@ -34,6 +36,20 @@ export async function scaffoldWorkspace(workspacePath: string): Promise \`To Test\` (QA) > \`To Do\` (new work) -3. Evaluate complexity, choose developer tier -4. Call \`work_start\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`model\` (tier name) +3. Evaluate complexity, choose developer level +4. Call \`work_start\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`level\` 5. Post the \`announcement\` from the tool response to Telegram ### When Work Completes @@ -146,7 +146,7 @@ The response includes \`tickPickups\` showing any tasks that were auto-dispatche ### Prompt Instructions -Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/prompts//.md\` in the workspace. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project. +Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/roles//.md\` in the workspace, falling back to \`projects/roles/default/.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project. ### Heartbeats diff --git a/lib/tiers.ts b/lib/tiers.ts index 2e2eb81..de4af82 100644 --- a/lib/tiers.ts +++ b/lib/tiers.ts @@ -1,17 +1,16 @@ /** - * Developer tier definitions and model resolution. + * Developer level definitions and model resolution. * - * Tier names always include the role prefix: "dev.junior", "qa.reviewer", etc. - * This makes tier names globally unique and self-documenting. + * Level names are plain: "junior", "senior", "reviewer", etc. + * Role context (dev/qa) is always provided by the caller or parent structure. */ -export const DEV_TIERS = ["dev.junior", "dev.medior", "dev.senior"] as const; -export const QA_TIERS = ["qa.reviewer", "qa.tester"] as const; -export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const; +export const DEV_LEVELS = ["junior", "medior", "senior"] as const; +export const QA_LEVELS = ["reviewer", "tester"] as const; -export type DevTier = (typeof DEV_TIERS)[number]; -export type QaTier = (typeof QA_TIERS)[number]; -export type Tier = (typeof ALL_TIERS)[number]; +export type DevLevel = (typeof DEV_LEVELS)[number]; +export type QaLevel = (typeof QA_LEVELS)[number]; +export type Level = DevLevel | QaLevel; /** Default models, nested by role. */ export const DEFAULT_MODELS = { @@ -27,7 +26,7 @@ export const DEFAULT_MODELS = { }; /** Emoji used in announcements, nested by role. */ -export const TIER_EMOJI = { +export const LEVEL_EMOJI = { dev: { junior: "⚔", medior: "šŸ”§", @@ -39,77 +38,52 @@ export const TIER_EMOJI = { }, }; -/** Check if a string is a valid tier name. */ -export function isTier(value: string): value is Tier { - return (ALL_TIERS as readonly string[]).includes(value); +/** Check if a level belongs to the dev role. */ +export function isDevLevel(value: string): value is DevLevel { + return (DEV_LEVELS as readonly string[]).includes(value); } -/** Check if a tier belongs to the dev role. */ -export function isDevTier(value: string): value is DevTier { - return (DEV_TIERS as readonly string[]).includes(value); +/** Check if a level belongs to the qa role. */ +export function isQaLevel(value: string): value is QaLevel { + return (QA_LEVELS as readonly string[]).includes(value); } -/** Extract the role from a tier name (e.g. "dev.junior" → "dev"). */ -export function tierRole(tier: string): "dev" | "qa" | undefined { - if (tier.startsWith("dev.")) return "dev"; - if (tier.startsWith("qa.")) return "qa"; +/** Determine the role a level belongs to. */ +export function levelRole(level: string): "dev" | "qa" | undefined { + if (isDevLevel(level)) return "dev"; + if (isQaLevel(level)) return "qa"; return undefined; } -/** Extract the short name from a tier (e.g. "dev.junior" → "junior"). */ -export function tierName(tier: string): string { - const dot = tier.indexOf("."); - return dot >= 0 ? tier.slice(dot + 1) : tier; +/** Get the default model for a role + level. */ +export function defaultModel(role: "dev" | "qa", level: string): string | undefined { + return (DEFAULT_MODELS[role] as Record)[level]; } -/** Look up a value from a nested role structure using a full tier name. */ -function lookupNested(map: Record>, tier: string): T | undefined { - const role = tierRole(tier); - if (!role) return undefined; - return map[role]?.[tierName(tier)]; -} - -/** Get the default model for a tier. */ -export function defaultModel(tier: string): string | undefined { - return lookupNested(DEFAULT_MODELS, tier); -} - -/** Get the emoji for a tier. */ -export function tierEmoji(tier: string): string | undefined { - return lookupNested(TIER_EMOJI, tier); -} - -/** Build a flat Record of all default models. */ -export function allDefaultModels(): Record { - const result = {} as Record; - for (const tier of ALL_TIERS) { - result[tier] = defaultModel(tier)!; - } - return result; +/** Get the emoji for a role + level. */ +export function levelEmoji(role: "dev" | "qa", level: string): string | undefined { + return (LEVEL_EMOJI[role] as Record)[level]; } /** - * Resolve a tier name to a full model ID. + * Resolve a level to a full model ID. * * Resolution order: - * 1. Parse "role.name" → look up config `models..` - * 2. DEFAULT_MODELS[role][name] + * 1. Plugin config `models..` + * 2. DEFAULT_MODELS[role][level] * 3. Passthrough (treat as raw model ID) */ -export function resolveTierToModel( - tier: string, +export function resolveModel( + role: "dev" | "qa", + level: string, pluginConfig?: Record, ): string { const models = (pluginConfig as { models?: Record })?.models; if (models && typeof models === "object") { - const role = tierRole(tier); - const name = tierName(tier); - if (role) { - const roleModels = models[role] as Record | undefined; - if (roleModels?.[name]) return roleModels[name]; - } + const roleModels = models[role] as Record | undefined; + if (roleModels?.[level]) return roleModels[level]; } - return defaultModel(tier) ?? tier; + return defaultModel(role, level) ?? level; } diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 832972b..fef9862 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -15,7 +15,7 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js"; import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; -import { DEV_TIERS, QA_TIERS } from "../tiers.js"; +import { DEV_LEVELS, QA_LEVELS } from "../tiers.js"; import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js"; import { detectContext, generateGuardrails } from "../context-guard.js"; @@ -24,7 +24,7 @@ import { detectContext, generateGuardrails } from "../context-guard.js"; * Returns true if files were created, false if they already existed. */ async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise { - const projectDir = path.join(workspaceDir, "projects", "prompts", projectName); + const projectDir = path.join(workspaceDir, "projects", "roles", projectName); await fs.mkdir(projectDir, { recursive: true }); const projectDev = path.join(projectDir, "dev.md"); @@ -185,8 +185,8 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) { deployBranch, channel: context.channel, roleExecution, - dev: emptyWorkerState([...DEV_TIERS]), - qa: emptyWorkerState([...QA_TIERS]), + dev: emptyWorkerState([...DEV_LEVELS]), + qa: emptyWorkerState([...QA_LEVELS]), }; await writeProjects(workspaceDir, data); diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index e5287be..3c85d19 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -1,20 +1,20 @@ /** * setup — Agent-driven DevClaw setup. * - * Creates agent, configures model tiers, writes workspace files. + * Creates agent, configures model levels, writes workspace files. * Thin wrapper around lib/setup/. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; -import { runSetup } from "../setup/index.js"; -import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js"; +import { runSetup, type SetupOpts } from "../setup/index.js"; +import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js"; export function createSetupTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "setup", label: "Setup", - description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model tier config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`, + description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`, parameters: { type: "object", properties: { @@ -35,11 +35,11 @@ export function createSetupTool(api: OpenClawPluginApi) { }, models: { type: "object", - description: "Model overrides per role and tier.", + description: "Model overrides per role and level.", properties: { dev: { type: "object", - description: "Developer tier models", + description: "Developer level models", properties: { junior: { type: "string", @@ -57,7 +57,7 @@ export function createSetupTool(api: OpenClawPluginApi) { }, qa: { type: "object", - description: "QA tier models", + description: "QA level models", properties: { reviewer: { type: "string", @@ -87,7 +87,7 @@ export function createSetupTool(api: OpenClawPluginApi) { migrateFrom: params.migrateFrom as string | undefined, agentId: params.newAgentName ? undefined : ctx.agentId, workspacePath: params.newAgentName ? undefined : ctx.workspaceDir, - models: params.models as Partial> | undefined, + models: params.models as SetupOpts["models"], projectExecution: params.projectExecution as | "parallel" | "sequential" @@ -108,7 +108,8 @@ export function createSetupTool(api: OpenClawPluginApi) { } lines.push( "Models:", - ...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`), + ...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`), + ...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`), "", "Files:", ...result.filesWritten.map((f) => ` ${f}`), diff --git a/lib/tools/status.ts b/lib/tools/status.ts index 9b557e1..b5edc79 100644 --- a/lib/tools/status.ts +++ b/lib/tools/status.ts @@ -62,8 +62,8 @@ export function createStatusTool(api: OpenClawPluginApi) { name: project.name, groupId: pid, roleExecution: project.roleExecution ?? "parallel", - dev: { active: project.dev.active, issueId: project.dev.issueId, tier: project.dev.tier, startTime: project.dev.startTime }, - qa: { active: project.qa.active, issueId: project.qa.issueId, tier: project.qa.tier, startTime: project.qa.startTime }, + dev: { active: project.dev.active, issueId: project.dev.issueId, level: project.dev.level, startTime: project.dev.startTime }, + qa: { active: project.qa.active, issueId: project.qa.issueId, level: project.qa.level, startTime: project.qa.startTime }, queue: { toImprove: count("To Improve"), toTest: count("To Test"), toDo: count("To Do") }, }; }), diff --git a/lib/tools/work-heartbeat.test.ts b/lib/tools/work-heartbeat.test.ts index 01b7776..7e92ffe 100644 --- a/lib/tools/work-heartbeat.test.ts +++ b/lib/tools/work-heartbeat.test.ts @@ -22,17 +22,17 @@ import type { StateLabel } from "../providers/provider.js"; // --------------------------------------------------------------------------- const INACTIVE_WORKER: WorkerState = { - active: false, issueId: null, startTime: null, tier: null, sessions: {}, + active: false, issueId: null, startTime: null, level: null, sessions: {}, }; const ACTIVE_DEV: WorkerState = { - active: true, issueId: "42", startTime: new Date().toISOString(), tier: "dev.medior", - sessions: { "dev.medior": "session-dev-42" }, + active: true, issueId: "42", startTime: new Date().toISOString(), level: "medior", + sessions: { medior: "session-dev-42" }, }; const ACTIVE_QA: WorkerState = { - active: true, issueId: "42", startTime: new Date().toISOString(), tier: "qa.reviewer", - sessions: { "qa.reviewer": "session-qa-42" }, + active: true, issueId: "42", startTime: new Date().toISOString(), level: "reviewer", + sessions: { reviewer: "session-qa-42" }, }; function makeProject(overrides: Partial = {}): Project { @@ -280,11 +280,11 @@ describe("work_heartbeat: worker slot guards", () => { }); }); -describe("work_heartbeat: tier assignment", () => { +describe("work_heartbeat: level assignment", () => { afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); - it("uses label-based tier when present", async () => { - // Given: issue with "dev.senior" label → tier should be "dev.senior" + it("uses label-based level when present", async () => { + // Given: issue with "dev.senior" label → level should be "senior" const workspaceDir = await setupWorkspace({ "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), }); @@ -299,12 +299,12 @@ describe("work_heartbeat: tier assignment", () => { const pickup = result.pickups.find((p) => p.role === "dev"); assert.ok(pickup); - assert.strictEqual(pickup.tier, "dev.senior", "Should use label-based tier"); + assert.strictEqual(pickup.level, "senior", "Should use label-based level"); }); - it("overrides to reviewer tier for qa role regardless of label", async () => { + it("overrides to reviewer level for qa role regardless of label", async () => { // Given: issue with "dev.senior" label but picked up by QA - // Expected: tier = "qa.reviewer" (QA always uses reviewer tier) + // Expected: level = "reviewer" (QA always uses reviewer level) const workspaceDir = await setupWorkspace({ "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), }); @@ -319,11 +319,11 @@ describe("work_heartbeat: tier assignment", () => { const qaPickup = result.pickups.find((p) => p.role === "qa"); assert.ok(qaPickup); - assert.strictEqual(qaPickup.tier, "qa.reviewer", "QA always uses reviewer tier regardless of issue label"); + assert.strictEqual(qaPickup.level, "reviewer", "QA always uses reviewer level regardless of issue label"); }); - it("falls back to heuristic when no tier label", async () => { - // Given: issue with no tier label → heuristic selects based on title/description + it("falls back to heuristic when no level label", async () => { + // Given: issue with no level label → heuristic selects based on title/description const workspaceDir = await setupWorkspace({ "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), }); @@ -339,7 +339,7 @@ describe("work_heartbeat: tier assignment", () => { const pickup = result.pickups.find((p) => p.role === "dev"); assert.ok(pickup); // Heuristic should select junior for a typo fix - assert.strictEqual(pickup.tier, "dev.junior", "Heuristic should assign junior for simple typo fix"); + assert.strictEqual(pickup.level, "junior", "Heuristic should assign junior for simple typo fix"); }); }); @@ -394,7 +394,7 @@ describe("work_heartbeat: TickAction output shape", () => { assert.strictEqual(pickup.issueTitle, "Build feature"); assert.strictEqual(pickup.issueUrl, "https://github.com/test/alpha/issues/10"); assert.ok(["dev", "qa"].includes(pickup.role)); - assert.ok(typeof pickup.tier === "string"); + assert.ok(typeof pickup.level === "string"); assert.ok(["spawn", "send"].includes(pickup.sessionAction)); assert.ok(pickup.announcement.includes("[DRY RUN]")); }); diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 1f73b6d..a02d96d 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -2,33 +2,33 @@ * work_start — Pick up a task from the issue queue. * * Context-aware: ONLY works in project group chats. - * Auto-detects: projectGroupId, role, tier, issueId. + * Auto-detects: projectGroupId, role, level, issueId. * After dispatch, ticks the project queue to fill parallel slots. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import type { StateLabel } from "../providers/provider.js"; -import { selectTier } from "../model-selector.js"; +import { selectLevel } from "../model-selector.js"; 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 { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; +import { isDevLevel } from "../tiers.js"; import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js"; export function createWorkStartTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "work_start", label: "Work Start", - description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, tier assignment, session creation, dispatch, audit, and ticks the queue to fill parallel slots.`, + description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, level assignment, session creation, dispatch, audit, and ticks the queue to fill parallel slots.`, parameters: { type: "object", properties: { issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." }, role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." }, projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." }, - tier: { type: "string", description: "Developer tier (junior/medior/senior/qa). Auto-detected if omitted." }, + level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." }, }, }, @@ -36,7 +36,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const issueIdParam = params.issueId as number | undefined; const roleParam = params.role as "dev" | "qa" | undefined; const groupIdParam = params.projectGroupId as string | undefined; - const tierParam = params.tier as string | undefined; + const levelParam = (params.level ?? params.tier) as string | undefined; const workspaceDir = requireWorkspaceDir(ctx); // Context guard: group only @@ -76,20 +76,20 @@ export function createWorkStartTool(api: OpenClawPluginApi) { if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`); } - // Select tier + // Select level const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; - let selectedTier: string, tierReason: string, tierSource: string; - if (tierParam) { - selectedTier = tierParam; tierReason = "LLM-selected"; tierSource = "llm"; + let selectedLevel: string, levelReason: string, levelSource: string; + if (levelParam) { + selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm"; } else { - const labelTier = detectTierFromLabels(issue.labels); - if (labelTier) { - 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"; } + const labelLevel = detectLevelFromLabels(issue.labels); + if (labelLevel) { + if (role === "qa" && isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = `QA overrides dev level "${labelLevel}"`; levelSource = "role-override"; } + else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; } + else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; } } else { - const s = selectTier(issue.title, issue.description ?? "", role); - selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; + const s = selectLevel(issue.title, issue.description ?? "", role); + selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; } } @@ -98,7 +98,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const dr = await dispatchTask({ workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, - role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel, + role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel, transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), pluginConfig, sessionKey: ctx.sessionKey, }); @@ -106,7 +106,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { // Notify const notifyConfig = getNotificationConfig(pluginConfig); await notify( - { type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, tier: dr.tier, sessionAction: dr.sessionAction }, + { type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, level: dr.level, sessionAction: dr.sessionAction }, { workspaceDir, config: notifyConfig, groupId, channel: context.channel }, ); @@ -119,10 +119,10 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const output: Record = { success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, - role, tier: dr.tier, model: dr.model, sessionAction: dr.sessionAction, + role, level: dr.level, model: dr.model, sessionAction: dr.sessionAction, announcement: dr.announcement, labelTransition: `${currentLabel} → ${targetLabel}`, - tierReason, tierSource, - autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, tier: !tierParam }, + levelReason, levelSource, + autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, level: !levelParam }, }; if (tickPickups.length) output.tickPickups = tickPickups;