diff --git a/lib/bootstrap-hook.test.ts b/lib/bootstrap-hook.test.ts index 844841b..d6ac418 100644 --- a/lib/bootstrap-hook.test.ts +++ b/lib/bootstrap-hook.test.ts @@ -10,24 +10,24 @@ import path from "node:path"; import os from "node:os"; describe("parseDevClawSessionKey", () => { - it("should parse a standard dev session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-mid"); - assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" }); + it("should parse a standard developer session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-developer-medior"); + assert.deepStrictEqual(result, { projectName: "my-project", role: "developer" }); }); - it("should parse a qa session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-mid"); - assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" }); + it("should parse a tester session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-tester-medior"); + assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" }); }); it("should handle project names with hyphens", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior"); - assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" }); + const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior"); + assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" }); }); - it("should handle project names with multiple hyphens and qa role", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-junior"); - assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" }); + it("should handle project names with multiple hyphens and tester role", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-tester-junior"); + assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "tester" }); }); it("should return null for non-subagent session keys", () => { @@ -45,14 +45,14 @@ describe("parseDevClawSessionKey", () => { assert.strictEqual(result, null); }); - it("should parse senior dev level", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior"); - assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" }); + it("should parse senior developer level", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-developer-senior"); + assert.deepStrictEqual(result, { projectName: "devclaw", role: "developer" }); }); it("should parse simple project name", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior"); - assert.deepStrictEqual(result, { projectName: "api", role: "dev" }); + const result = parseDevClawSessionKey("agent:devclaw:subagent:api-developer-junior"); + assert.deepStrictEqual(result, { projectName: "api", role: "developer" }); }); }); @@ -61,10 +61,10 @@ describe("loadRoleInstructions", () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); const projectDir = path.join(tmpDir, "projects", "roles", "test-project"); await fs.mkdir(projectDir, { recursive: true }); - await fs.writeFile(path.join(projectDir, "dev.md"), "# Dev Instructions\nDo the thing."); + await fs.writeFile(path.join(projectDir, "developer.md"), "# Developer Instructions\nDo the thing."); - const result = await loadRoleInstructions(tmpDir, "test-project", "dev"); - assert.strictEqual(result, "# Dev Instructions\nDo the thing."); + const result = await loadRoleInstructions(tmpDir, "test-project", "developer"); + assert.strictEqual(result, "# Developer Instructions\nDo the thing."); await fs.rm(tmpDir, { recursive: true }); }); @@ -73,10 +73,10 @@ describe("loadRoleInstructions", () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); const defaultDir = path.join(tmpDir, "projects", "roles", "default"); await fs.mkdir(defaultDir, { recursive: true }); - await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA Default\nReview carefully."); + await fs.writeFile(path.join(defaultDir, "tester.md"), "# Tester Default\nReview carefully."); - const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "qa"); - assert.strictEqual(result, "# QA Default\nReview carefully."); + const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester"); + assert.strictEqual(result, "# Tester Default\nReview carefully."); await fs.rm(tmpDir, { recursive: true }); }); @@ -84,7 +84,7 @@ describe("loadRoleInstructions", () => { it("should return empty string when no instructions exist", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); - const result = await loadRoleInstructions(tmpDir, "missing", "dev"); + const result = await loadRoleInstructions(tmpDir, "missing", "developer"); assert.strictEqual(result, ""); await fs.rm(tmpDir, { recursive: true }); @@ -96,10 +96,10 @@ describe("loadRoleInstructions", () => { const defaultDir = path.join(tmpDir, "projects", "roles", "default"); await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(defaultDir, { recursive: true }); - await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions"); - await fs.writeFile(path.join(defaultDir, "dev.md"), "Default instructions"); + await fs.writeFile(path.join(projectDir, "developer.md"), "Project-specific instructions"); + await fs.writeFile(path.join(defaultDir, "developer.md"), "Default instructions"); - const result = await loadRoleInstructions(tmpDir, "my-project", "dev"); + const result = await loadRoleInstructions(tmpDir, "my-project", "developer"); assert.strictEqual(result, "Project-specific instructions"); await fs.rm(tmpDir, { recursive: true }); diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index 8de14c2..f81a118 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -17,8 +17,8 @@ import { getSessionKeyRolePattern } from "./roles/index.js"; * * Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}` * Examples: - * - `agent:devclaw:subagent:my-project-dev-mid` → { projectName: "my-project", role: "dev" } - * - `agent:devclaw:subagent:webapp-qa-mid` → { projectName: "webapp", role: "qa" } + * - `agent:devclaw:subagent:my-project-developer-medior` → { projectName: "my-project", role: "developer" } + * - `agent:devclaw:subagent:webapp-tester-medior` → { projectName: "webapp", role: "tester" } * * Note: projectName may contain hyphens, so we match role from the end. */ diff --git a/lib/cli.ts b/lib/cli.ts index c31473d..d8e1ed3 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -6,8 +6,7 @@ import type { Command } from "commander"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { runSetup } from "./setup/index.js"; -import { DEFAULT_MODELS } from "./tiers.js"; -import { getLevelsForRole } from "./roles/index.js"; +import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "./roles/index.js"; /** * Register the `devclaw` CLI command group on a Commander program. @@ -17,39 +16,41 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { .command("devclaw") .description("DevClaw development pipeline tools"); - devclaw + const setupCmd = devclaw .command("setup") .description("Set up DevClaw: create agent, configure models, write workspace files") .option("--new-agent ", "Create a new agent with this name") .option("--agent ", "Use an existing agent by ID") - .option("--workspace ", "Direct workspace path") - .option("--junior ", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`) - .option("--mid ", `Mid dev model (default: ${DEFAULT_MODELS.dev.mid})`) - .option("--senior ", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`) - .option("--qa-junior ", `QA junior model (default: ${DEFAULT_MODELS.qa.junior})`) - .option("--qa-mid ", `QA mid model (default: ${DEFAULT_MODELS.qa.mid})`) - .option("--qa-senior ", `QA senior model (default: ${DEFAULT_MODELS.qa.senior})`) - .action(async (opts) => { - const dev: Record = {}; - const qa: Record = {}; - if (opts.junior) dev.junior = opts.junior; - if (opts.mid) dev.mid = opts.mid; - if (opts.senior) dev.senior = opts.senior; - if (opts.qaJunior) qa.junior = opts.qaJunior; - if (opts.qaMid) qa.mid = opts.qaMid; - if (opts.qaSenior) qa.senior = opts.qaSenior; + .option("--workspace ", "Direct workspace path"); - 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; + // Register dynamic --- options from registry + const defaults = getAllDefaultModels(); + for (const role of getAllRoleIds()) { + for (const level of getLevelsForRole(role)) { + const flag = `--${role}-${level}`; + setupCmd.option(`${flag} `, `${role.toUpperCase()} ${level} model (default: ${defaults[role]?.[level] ?? "auto"})`); + } + } + + setupCmd.action(async (opts) => { + // Build model overrides from CLI flags dynamically + const models: Record> = {}; + for (const role of getAllRoleIds()) { + const roleModels: Record = {}; + for (const level of getLevelsForRole(role)) { + // camelCase key: "testerJunior" for --tester-junior, "developerMedior" for --developer-medior + const key = `${role}${level.charAt(0).toUpperCase()}${level.slice(1)}`; + if (opts[key]) roleModels[level] = opts[key]; + } + if (Object.keys(roleModels).length > 0) models[role] = roleModels; + } const result = await runSetup({ api, newAgentName: opts.newAgent, agentId: opts.agent, workspacePath: opts.workspace, - models, + models: Object.keys(models).length > 0 ? models : undefined, }); if (result.agentCreated) { @@ -57,9 +58,11 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { } console.log("Models configured:"); - for (const t of getLevelsForRole("dev")) console.log(` dev.${t}: ${result.models.dev[t]}`); - for (const t of getLevelsForRole("qa")) console.log(` qa.${t}: ${result.models.qa[t]}`); - for (const t of getLevelsForRole("architect")) console.log(` architect.${t}: ${result.models.architect[t]}`); + for (const [role, levels] of Object.entries(result.models)) { + for (const [level, model] of Object.entries(levels)) { + console.log(` ${role}.${level}: ${model}`); + } + } console.log("Files written:"); for (const file of result.filesWritten) { diff --git a/lib/config/index.ts b/lib/config/index.ts new file mode 100644 index 0000000..3e9b285 --- /dev/null +++ b/lib/config/index.ts @@ -0,0 +1,14 @@ +/** + * config/ — Unified DevClaw configuration. + * + * Single config.yaml per workspace/project combining roles, models, and workflow. + */ +export type { + DevClawConfig, + RoleOverride, + ResolvedConfig, + ResolvedRoleConfig, +} from "./types.js"; + +export { loadConfig } from "./loader.js"; +export { mergeConfig } from "./merge.js"; diff --git a/lib/config/loader.ts b/lib/config/loader.ts new file mode 100644 index 0000000..c0c883c --- /dev/null +++ b/lib/config/loader.ts @@ -0,0 +1,170 @@ +/** + * config/loader.ts — Three-layer config loading. + * + * Resolution order: + * 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW) + * 2. Workspace: /projects/config.yaml + * 3. Project: /projects//config.yaml + * + * Also supports legacy workflow.yaml files (merged into the workflow section). + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import YAML from "yaml"; +import { ROLE_REGISTRY } from "../roles/registry.js"; +import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; +import { mergeConfig } from "./merge.js"; +import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, RoleOverride } from "./types.js"; + +/** + * Load and resolve the full DevClaw config for a project. + * + * Merges: built-in → workspace config.yaml → project config.yaml. + * Also picks up legacy workflow.yaml files if no workflow section in config.yaml. + */ +export async function loadConfig( + workspaceDir: string, + projectName?: string, +): Promise { + const projectsDir = path.join(workspaceDir, "projects"); + + // Layer 1: built-in defaults + const builtIn = buildDefaultConfig(); + + // Layer 2: workspace config.yaml + let merged = builtIn; + const workspaceConfig = await readConfigFile(projectsDir); + if (workspaceConfig) { + merged = mergeConfig(merged, workspaceConfig); + } + + // Legacy: workspace workflow.yaml (only if no workflow in config.yaml) + if (!workspaceConfig?.workflow) { + const legacyWorkflow = await readWorkflowYaml(projectsDir); + if (legacyWorkflow) { + merged = mergeConfig(merged, { workflow: legacyWorkflow }); + } + } + + // Layer 3: project config.yaml + if (projectName) { + const projectDir = path.join(projectsDir, projectName); + const projectConfig = await readConfigFile(projectDir); + if (projectConfig) { + merged = mergeConfig(merged, projectConfig); + } + + // Legacy: project workflow.yaml + if (!projectConfig?.workflow) { + const legacyWorkflow = await readWorkflowYaml(projectDir); + if (legacyWorkflow) { + merged = mergeConfig(merged, { workflow: legacyWorkflow }); + } + } + } + + return resolve(merged); +} + +/** + * Build the default config from the built-in ROLE_REGISTRY and DEFAULT_WORKFLOW. + */ +function buildDefaultConfig(): DevClawConfig { + const roles: Record = {}; + for (const [id, reg] of Object.entries(ROLE_REGISTRY)) { + roles[id] = { + levels: [...reg.levels], + defaultLevel: reg.defaultLevel, + models: { ...reg.models }, + emoji: { ...reg.emoji }, + completionResults: [...reg.completionResults], + }; + } + return { roles, workflow: DEFAULT_WORKFLOW }; +} + +/** + * Resolve a merged DevClawConfig into a fully-typed ResolvedConfig. + */ +function resolve(config: DevClawConfig): ResolvedConfig { + const roles: Record = {}; + + if (config.roles) { + for (const [id, override] of Object.entries(config.roles)) { + if (override === false) { + // Disabled role — include with enabled: false for visibility + const reg = ROLE_REGISTRY[id]; + roles[id] = { + levels: reg ? [...reg.levels] : [], + defaultLevel: reg?.defaultLevel ?? "", + models: reg ? { ...reg.models } : {}, + emoji: reg ? { ...reg.emoji } : {}, + completionResults: reg ? [...reg.completionResults] : [], + enabled: false, + }; + continue; + } + + const reg = ROLE_REGISTRY[id]; + roles[id] = { + levels: override.levels ?? (reg ? [...reg.levels] : []), + defaultLevel: override.defaultLevel ?? reg?.defaultLevel ?? "", + models: { ...(reg?.models ?? {}), ...(override.models ?? {}) }, + emoji: { ...(reg?.emoji ?? {}), ...(override.emoji ?? {}) }, + completionResults: override.completionResults ?? (reg ? [...reg.completionResults] : []), + enabled: true, + }; + } + } + + // Ensure all built-in roles exist even if not in config + for (const [id, reg] of Object.entries(ROLE_REGISTRY)) { + if (!roles[id]) { + roles[id] = { + levels: [...reg.levels], + defaultLevel: reg.defaultLevel, + models: { ...reg.models }, + emoji: { ...reg.emoji }, + completionResults: [...reg.completionResults], + enabled: true, + }; + } + } + + const workflow: WorkflowConfig = { + initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial, + states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states }, + }; + + return { roles, workflow }; +} + +// --------------------------------------------------------------------------- +// File reading helpers +// --------------------------------------------------------------------------- + +async function readConfigFile(dir: string): Promise { + try { + const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8"); + return YAML.parse(content) as DevClawConfig; + } catch { /* not found */ } + return null; +} + +async function readWorkflowYaml(dir: string): Promise | null> { + try { + const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); + return YAML.parse(content) as Partial; + } catch { /* not found */ } + + // Legacy JSON fallback + try { + const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8"); + const parsed = JSON.parse(content) as + | Partial + | { workflow?: Partial }; + return (parsed as any).workflow ?? parsed; + } catch { /* not found */ } + + return null; +} diff --git a/lib/config/merge.ts b/lib/config/merge.ts new file mode 100644 index 0000000..5fad7bb --- /dev/null +++ b/lib/config/merge.ts @@ -0,0 +1,82 @@ +/** + * config/merge.ts — Deep merge for DevClaw config layers. + * + * Merge semantics: + * - Objects: recursively merge (sparse override) + * - Arrays: replace entirely (no merging array elements) + * - `false` for a role: marks it as disabled + * - Primitives: override + */ +import type { DevClawConfig, RoleOverride } from "./types.js"; + +/** + * Merge a config overlay on top of a base config. + * Returns a new config — does not mutate inputs. + */ +export function mergeConfig( + base: DevClawConfig, + overlay: DevClawConfig, +): DevClawConfig { + const merged: DevClawConfig = {}; + + // Merge roles + if (base.roles || overlay.roles) { + merged.roles = { ...base.roles }; + if (overlay.roles) { + for (const [roleId, overrideValue] of Object.entries(overlay.roles)) { + if (overrideValue === false) { + // Disable role + merged.roles[roleId] = false; + } else if (merged.roles[roleId] === false) { + // Re-enable with override + merged.roles[roleId] = overrideValue; + } else { + // Merge role override on top of base role + const baseRole = merged.roles[roleId]; + merged.roles[roleId] = mergeRoleOverride( + typeof baseRole === "object" ? baseRole : {}, + overrideValue, + ); + } + } + } + } + + // Merge workflow + if (base.workflow || overlay.workflow) { + merged.workflow = { + initial: overlay.workflow?.initial ?? base.workflow?.initial, + states: { + ...base.workflow?.states, + ...overlay.workflow?.states, + }, + }; + // Clean up undefined initial + if (merged.workflow.initial === undefined) { + delete merged.workflow.initial; + } + } + + return merged; +} + +function mergeRoleOverride( + base: RoleOverride, + overlay: RoleOverride, +): RoleOverride { + return { + ...base, + ...overlay, + // Models: merge (don't replace) + models: base.models || overlay.models + ? { ...base.models, ...overlay.models } + : undefined, + // Emoji: merge (don't replace) + emoji: base.emoji || overlay.emoji + ? { ...base.emoji, ...overlay.emoji } + : undefined, + // Arrays replace entirely + ...(overlay.levels ? { levels: overlay.levels } : {}), + ...(overlay.completionResults ? { completionResults: overlay.completionResults } : {}), + }; +} diff --git a/lib/config/types.ts b/lib/config/types.ts new file mode 100644 index 0000000..da48d31 --- /dev/null +++ b/lib/config/types.ts @@ -0,0 +1,49 @@ +/** + * config/types.ts — Types for the unified DevClaw configuration. + * + * A single config.yaml combines roles, models, and workflow. + * Three-layer resolution: built-in → workspace → per-project. + */ +import type { WorkflowConfig } from "../workflow.js"; + +/** + * Role override in config.yaml. All fields optional — only override what you need. + * Set to `false` to disable a role entirely for a project. + */ +export type RoleOverride = { + levels?: string[]; + defaultLevel?: string; + models?: Record; + emoji?: Record; + completionResults?: string[]; +}; + +/** + * The full config.yaml shape. + * All fields optional — missing fields inherit from the layer below. + */ +export type DevClawConfig = { + roles?: Record; + workflow?: Partial; +}; + +/** + * Fully resolved config — all fields guaranteed present. + * Built by merging three layers over the built-in defaults. + */ +export type ResolvedConfig = { + roles: Record; + workflow: WorkflowConfig; +}; + +/** + * Fully resolved role config — all fields present. + */ +export type ResolvedRoleConfig = { + levels: string[]; + defaultLevel: string; + models: Record; + emoji: Record; + completionResults: string[]; + enabled: boolean; +}; diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 8f9b14a..08bacce 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -13,8 +13,9 @@ import { getSessionForLevel, getWorker, } from "./projects.js"; -import { resolveModel, getEmoji, getFallbackEmoji } from "./roles/index.js"; +import { resolveModel, getFallbackEmoji } from "./roles/index.js"; import { notify, getNotificationConfig } from "./notify.js"; +import { loadConfig, type ResolvedRoleConfig } from "./config/index.js"; export type DispatchOpts = { workspaceDir: string; @@ -25,7 +26,7 @@ export type DispatchOpts = { issueTitle: string; issueDescription: string; issueUrl: string; - role: "dev" | "qa" | "architect"; + role: string; /** Developer level (junior, mid, senior) or raw model ID */ level: string; /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ @@ -63,7 +64,7 @@ export type DispatchResult = { */ export function buildTaskMessage(opts: { projectName: string; - role: "dev" | "qa" | "architect"; + role: string; issueId: number; issueTitle: string; issueDescription: string; @@ -72,16 +73,15 @@ export function buildTaskMessage(opts: { baseBranch: string; groupId: string; comments?: Array<{ author: string; body: string; created_at: string }>; + resolvedRole?: ResolvedRoleConfig; }): string { const { projectName, role, issueId, issueTitle, issueDescription, issueUrl, repo, baseBranch, groupId, } = opts; - const availableResults = - role === "dev" || role === "architect" - ? '"done" (completed successfully) or "blocked" (cannot complete, need help)' - : '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)'; + const results = opts.resolvedRole?.completionResults ?? []; + const availableResults = results.map((r: string) => `"${r}"`).join(", "); const parts = [ `${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`, @@ -149,7 +149,9 @@ export async function dispatchTask( transitionLabel, provider, pluginConfig, runtime, } = opts; - const model = resolveModel(role, level, pluginConfig); + const resolvedConfig = await loadConfig(workspaceDir, project.name); + const resolvedRole = resolvedConfig.roles[role]; + const model = resolveModel(role, level, pluginConfig, resolvedRole); const worker = getWorker(project, role); const existingSessionKey = getSessionForLevel(worker, level); const sessionAction = existingSessionKey ? "send" : "spawn"; @@ -164,7 +166,7 @@ export async function dispatchTask( projectName: project.name, role, issueId, issueTitle, issueDescription, issueUrl, repo: project.repo, baseBranch: project.baseBranch, groupId, - comments, + comments, resolvedRole, }); // Step 1: Transition label (this is the commitment point) @@ -225,7 +227,7 @@ export async function dispatchTask( fromLabel, toLabel, }); - const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl); + const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl, resolvedRole); return { sessionAction, sessionKey, level, model, announcement }; } @@ -267,7 +269,7 @@ function sendToAgent( } async function recordWorkerState( - workspaceDir: string, groupId: string, role: "dev" | "qa" | "architect", + workspaceDir: string, groupId: string, role: string, opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" }, ): Promise { await activateWorker(workspaceDir, groupId, role, { @@ -301,8 +303,9 @@ async function auditDispatch( function buildAnnouncement( level: string, role: string, sessionAction: "spawn" | "send", issueId: number, issueTitle: string, issueUrl: string, + resolvedRole?: ResolvedRoleConfig, ): string { - const emoji = getEmoji(role, level) ?? getFallbackEmoji(role); + const emoji = resolvedRole?.emoji[level] ?? getFallbackEmoji(role); const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index d99f5ab..48c0a22 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -1,8 +1,13 @@ /** - * Model selection for dev/qa tasks. - * Keyword heuristic fallback — used when the orchestrator doesn't specify a level. - * Returns plain level names (junior, mid, senior). + * Model selection heuristic fallback — used when the orchestrator doesn't specify a level. + * Returns plain level names (junior, medior, senior). + * + * Adapts to any role's level count: + * - 1 level: always returns that level + * - 2 levels: simple binary (complex → last, else first) + * - 3+ levels: full heuristic (simple → first, complex → last, default → middle) */ +import { getLevelsForRole, getDefaultLevel } from "./roles/index.js"; export type LevelSelection = { level: string; @@ -39,60 +44,59 @@ const COMPLEX_KEYWORDS = [ ]; /** - * Select appropriate developer level based on task description. + * Select appropriate level based on task description and role. * - * All roles use consistent levels: - * - junior: simple tasks (typos, single-file fixes, CSS tweaks) - * - mid: standard work (features, bug fixes, multi-file changes) - * - senior: deep/architectural (system-wide refactoring, novel design) + * Adapts to the role's available levels: + * - Roles with 1 level → always that level + * - Roles with 2 levels → binary: complex keywords → highest, else lowest + * - Roles with 3+ levels → full heuristic: simple → lowest, complex → highest, else default */ export function selectLevel( issueTitle: string, issueDescription: string, - role: "dev" | "qa" | "architect", + role: string, ): LevelSelection { - if (role === "qa") { - return { - level: "mid", - reason: "Default QA level for code inspection and validation", - }; - } + const levels = getLevelsForRole(role); + const defaultLvl = getDefaultLevel(role); - if (role === "architect") { - const text = `${issueTitle} ${issueDescription}`.toLowerCase(); - const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); - return { - level: isComplex ? "senior" : "junior", - reason: isComplex - ? "Complex design task — using senior for depth" - : "Standard design task — using junior", - }; + // Roles with only 1 level — always return it + if (levels.length <= 1) { + const level = levels[0] ?? defaultLvl ?? "medior"; + return { level, reason: `Only level for ${role}` }; } const text = `${issueTitle} ${issueDescription}`.toLowerCase(); const wordCount = text.split(/\s+/).length; - - // Check for simple task indicators const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw)); + const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); + + const lowest = levels[0]; + const highest = levels[levels.length - 1]; + + // Roles with 2 levels — binary decision + if (levels.length === 2) { + if (isComplex) { + return { level: highest, reason: `Complex task — using ${highest}` }; + } + return { level: lowest, reason: `Standard task — using ${lowest}` }; + } + + // Roles with 3+ levels — full heuristic if (isSimple && wordCount < 100) { return { - level: "junior", + level: lowest, reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`, }; } - // Check for complex task indicators - const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); if (isComplex || wordCount > 500) { return { - level: "senior", + level: highest, reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`, }; } - // Default: mid for standard dev work - return { - level: "mid", - reason: "Standard dev task — multi-file changes, features, bug fixes", - }; + // Default level for the role + const level = defaultLvl ?? levels[Math.floor(levels.length / 2)]; + return { level, reason: `Standard ${role} task` }; } diff --git a/lib/notify.ts b/lib/notify.ts index 27cec5d..40f62f5 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -21,7 +21,7 @@ export type NotifyEvent = issueId: number; issueTitle: string; issueUrl: string; - role: "dev" | "qa" | "architect"; + role: string; level: string; sessionAction: "spawn" | "send"; } @@ -31,7 +31,7 @@ export type NotifyEvent = groupId: string; issueId: number; issueUrl: string; - role: "dev" | "qa" | "architect"; + role: string; result: "done" | "pass" | "fail" | "refine" | "blocked"; summary?: string; nextState?: string; diff --git a/lib/onboarding.ts b/lib/onboarding.ts index b570c4d..5676eea 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 { DEFAULT_MODELS } from "./tiers.js"; +import { getAllDefaultModels } from "./roles/index.js"; // --------------------------------------------------------------------------- // Detection @@ -38,15 +38,11 @@ export async function hasWorkspaceFiles( // --------------------------------------------------------------------------- function buildModelTable(pluginConfig?: Record): string { - const cfg = ( - pluginConfig as { - models?: { dev?: Record; qa?: Record }; - } - )?.models; + const cfg = (pluginConfig as { models?: Record> })?.models; const lines: string[] = []; - for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { + for (const [role, levels] of Object.entries(getAllDefaultModels())) { for (const [level, defaultModel] of Object.entries(levels)) { - const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel; + const model = cfg?.[role]?.[level] || defaultModel; lines.push( ` - **${role} ${level}**: ${model} (default: ${defaultModel})`, ); @@ -76,14 +72,14 @@ Ask what they want to change, then call the appropriate tool. } export function buildOnboardToolContext(): string { - // Build the model table dynamically from DEFAULT_MODELS + // Build the model table dynamically from getAllDefaultModels() const rows: string[] = []; const purposes: Record = { junior: "Simple tasks, single-file fixes", - mid: "Features, bug fixes, code review", + medior: "Features, bug fixes, code review", senior: "Architecture, refactoring, complex tasks", }; - for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { + for (const [role, levels] of Object.entries(getAllDefaultModels())) { for (const [level, model] of Object.entries(levels)) { rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`); } @@ -95,8 +91,8 @@ export function buildOnboardToolContext(): string { ## What is DevClaw? DevClaw turns each Telegram group into an autonomous development team: - An **orchestrator** that manages backlogs and delegates work -- **DEV workers** (junior/mid/senior levels) that write code in isolated sessions -- **QA workers** that review code and run tests +- **Developer workers** (junior/medior/senior levels) that write code in isolated sessions +- **Tester workers** that review code and run tests - Atomic tools for label transitions, session dispatch, state management, and audit logging ## Setup Steps @@ -141,7 +137,7 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de **Step 3: Run Setup** Call \`setup\` with the collected answers: -- Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\` +- Current agent: \`setup({})\` or \`setup({ models: { developer: { ... }, tester: { ... } } })\` - 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.test.ts b/lib/projects.test.ts new file mode 100644 index 0000000..4a9b471 --- /dev/null +++ b/lib/projects.test.ts @@ -0,0 +1,254 @@ +/** + * Tests for projects.ts — worker state, migration, and accessors. + * Run with: npx tsx --test lib/projects.test.ts + */ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { readProjects, getWorker, emptyWorkerState, writeProjects, type ProjectsData } from "./projects.js"; + +describe("readProjects migration", () => { + it("should migrate old format (dev/qa/architect fields) to workers map", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + + // Old format: hardcoded dev/qa/architect fields + const oldFormat = { + projects: { + "group-1": { + name: "test-project", + repo: "~/git/test", + groupName: "Test", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + dev: { active: true, issueId: "42", startTime: null, level: "mid", sessions: { mid: "key-1" } }, + qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, + architect: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, + }, + }, + }; + await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8"); + + const data = await readProjects(tmpDir); + const project = data.projects["group-1"]; + + // Should have workers map with migrated role keys + assert.ok(project.workers, "should have workers map"); + assert.ok(project.workers.developer, "should have developer worker (migrated from dev)"); + assert.ok(project.workers.tester, "should have tester worker (migrated from qa)"); + assert.ok(project.workers.architect, "should have architect worker"); + + // Developer worker should be active with migrated level + assert.strictEqual(project.workers.developer.active, true); + assert.strictEqual(project.workers.developer.issueId, "42"); + assert.strictEqual(project.workers.developer.level, "medior"); + + // Old fields should not exist on the object + assert.strictEqual((project as any).dev, undefined); + assert.strictEqual((project as any).qa, undefined); + assert.strictEqual((project as any).architect, undefined); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should migrate old level names in old format", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + + const oldFormat = { + projects: { + "group-1": { + name: "legacy", + repo: "~/git/legacy", + groupName: "Legacy", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + dev: { active: false, issueId: null, startTime: null, level: "medior", sessions: { medior: "key-1" } }, + qa: { active: false, issueId: null, startTime: null, level: "reviewer", sessions: { reviewer: "key-2" } }, + architect: { active: false, issueId: null, startTime: null, level: "opus", sessions: { opus: "key-3" } }, + }, + }, + }; + await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8"); + + const data = await readProjects(tmpDir); + const project = data.projects["group-1"]; + + // Level names should be migrated (dev→developer, qa→tester, medior→medior, reviewer→medior) + assert.strictEqual(project.workers.developer.level, "medior"); + assert.strictEqual(project.workers.tester.level, "medior"); + assert.strictEqual(project.workers.architect.level, "senior"); + + // Session keys should be migrated + assert.strictEqual(project.workers.developer.sessions.medior, "key-1"); + assert.strictEqual(project.workers.tester.sessions.medior, "key-2"); + assert.strictEqual(project.workers.architect.sessions.senior, "key-3"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should read new format (workers map) correctly", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + + const newFormat = { + projects: { + "group-1": { + name: "modern", + repo: "~/git/modern", + groupName: "Modern", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + workers: { + developer: { active: true, issueId: "10", startTime: null, level: "senior", sessions: { senior: "key-s" } }, + tester: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, + }, + }, + }, + }; + await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(newFormat), "utf-8"); + + const data = await readProjects(tmpDir); + const project = data.projects["group-1"]; + + assert.ok(project.workers.developer); + assert.strictEqual(project.workers.developer.active, true); + assert.strictEqual(project.workers.developer.level, "senior"); + assert.ok(project.workers.tester); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should migrate old worker keys in new format", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + + // Workers map but with old role keys + const mixedFormat = { + projects: { + "group-1": { + name: "mixed", + repo: "~/git/mixed", + groupName: "Mixed", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + workers: { + dev: { active: true, issueId: "10", startTime: null, level: "mid", sessions: { mid: "key-m" } }, + qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, + }, + }, + }, + }; + await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8"); + + const data = await readProjects(tmpDir); + const project = data.projects["group-1"]; + + // Old keys should be migrated + assert.ok(project.workers.developer, "dev should be migrated to developer"); + assert.ok(project.workers.tester, "qa should be migrated to tester"); + assert.strictEqual(project.workers.developer.level, "medior"); + assert.strictEqual(project.workers.developer.sessions.medior, "key-m"); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); + +describe("getWorker", () => { + it("should return worker from workers map", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + + const data: ProjectsData = { + projects: { + "g1": { + name: "test", + repo: "~/git/test", + groupName: "Test", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + workers: { + developer: { active: true, issueId: "5", startTime: null, level: "medior", sessions: {} }, + }, + }, + }, + }; + + const worker = getWorker(data.projects["g1"], "developer"); + assert.strictEqual(worker.active, true); + assert.strictEqual(worker.issueId, "5"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should return empty worker for unknown role", async () => { + const data: ProjectsData = { + projects: { + "g1": { + name: "test", + repo: "~/git/test", + groupName: "Test", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + workers: {}, + }, + }, + }; + + const worker = getWorker(data.projects["g1"], "nonexistent"); + assert.strictEqual(worker.active, false); + assert.strictEqual(worker.issueId, null); + }); +}); + +describe("writeProjects round-trip", () => { + it("should preserve workers map through write/read cycle", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + + const data: ProjectsData = { + projects: { + "g1": { + name: "roundtrip", + repo: "~/git/rt", + groupName: "RT", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + workers: { + developer: emptyWorkerState(["junior", "medior", "senior"]), + tester: emptyWorkerState(["junior", "medior", "senior"]), + architect: emptyWorkerState(["junior", "senior"]), + }, + }, + }, + }; + + await writeProjects(tmpDir, data); + const loaded = await readProjects(tmpDir); + const project = loaded.projects["g1"]; + + assert.ok(project.workers.developer); + assert.ok(project.workers.tester); + assert.ok(project.workers.architect); + assert.strictEqual(project.workers.developer.sessions.junior, null); + assert.strictEqual(project.workers.developer.sessions.medior, null); + assert.strictEqual(project.workers.developer.sessions.senior, null); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); diff --git a/lib/projects.ts b/lib/projects.ts index 6191384..f8ba3d8 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { homedir } from "node:os"; +import { LEVEL_ALIASES, ROLE_ALIASES } from "./roles/index.js"; export type WorkerState = { active: boolean; issueId: string | null; @@ -24,38 +25,28 @@ export type Project = { channel?: string; /** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */ provider?: "github" | "gitlab"; - /** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */ + /** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */ roleExecution?: "parallel" | "sequential"; maxDevWorkers?: number; maxQaWorkers?: number; - dev: WorkerState; - qa: WorkerState; - architect: WorkerState; + /** Worker state per role (developer, tester, architect, or custom roles). */ + workers: Record; }; export type ProjectsData = { projects: Record; }; -/** - * Level migration aliases: old name → new canonical name, keyed by role. - */ -const LEVEL_MIGRATION: Record> = { - dev: { medior: "mid" }, - qa: { reviewer: "mid", tester: "junior" }, - architect: { opus: "senior", sonnet: "junior" }, -}; - function migrateLevel(level: string | null, role: string): string | null { if (!level) return null; - return LEVEL_MIGRATION[role]?.[level] ?? level; + return LEVEL_ALIASES[role]?.[level] ?? level; } function migrateSessions( sessions: Record, role: string, ): Record { - const aliases = LEVEL_MIGRATION[role]; + const aliases = LEVEL_ALIASES[role]; if (!aliases) return sessions; const migrated: Record = {}; @@ -114,15 +105,33 @@ export async function readProjects(workspaceDir: string): Promise const data = JSON.parse(raw) as ProjectsData; for (const project of Object.values(data.projects)) { - project.dev = project.dev - ? parseWorkerState(project.dev as unknown as Record, "dev") - : emptyWorkerState([]); - project.qa = project.qa - ? parseWorkerState(project.qa as unknown as Record, "qa") - : emptyWorkerState([]); - project.architect = project.architect - ? parseWorkerState(project.architect as unknown as Record, "architect") - : emptyWorkerState([]); + // Migrate old format: hardcoded dev/qa/architect fields → workers map + const raw = project as unknown as Record; + if (!raw.workers && (raw.dev || raw.qa || raw.architect)) { + project.workers = {}; + for (const role of ["dev", "qa", "architect"]) { + const canonical = ROLE_ALIASES[role] ?? role; + project.workers[canonical] = raw[role] + ? parseWorkerState(raw[role] as Record, role) + : emptyWorkerState([]); + } + // Clean up old fields from the in-memory object + delete raw.dev; + delete raw.qa; + delete raw.architect; + } else if (raw.workers) { + // New format: parse each worker with role-aware migration + const workers = raw.workers as Record>; + project.workers = {}; + for (const [role, worker] of Object.entries(workers)) { + // Migrate old role keys (dev→developer, qa→tester) + const canonical = ROLE_ALIASES[role] ?? role; + project.workers[canonical] = parseWorkerState(worker, role); + } + } else { + project.workers = {}; + } + if (!project.channel) { project.channel = "telegram"; } @@ -150,9 +159,9 @@ export function getProject( export function getWorker( project: Project, - role: "dev" | "qa" | "architect", + role: string, ): WorkerState { - return project[role]; + return project.workers[role] ?? emptyWorkerState([]); } /** @@ -162,7 +171,7 @@ export function getWorker( export async function updateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa" | "architect", + role: string, updates: Partial, ): Promise { const data = await readProjects(workspaceDir); @@ -171,13 +180,13 @@ export async function updateWorker( throw new Error(`Project not found for groupId: ${groupId}`); } - const worker = project[role]; + const worker = project.workers[role] ?? emptyWorkerState([]); if (updates.sessions && worker.sessions) { updates.sessions = { ...worker.sessions, ...updates.sessions }; } - project[role] = { ...worker, ...updates }; + project.workers[role] = { ...worker, ...updates }; await writeProjects(workspaceDir, data); return data; @@ -190,7 +199,7 @@ export async function updateWorker( export async function activateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa" | "architect", + role: string, params: { issueId: string; level: string; @@ -220,7 +229,7 @@ export async function activateWorker( export async function deactivateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa" | "architect", + role: string, ): Promise { return updateWorker(workspaceDir, groupId, role, { active: false, diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index 04957a3..9eedb74 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -2,34 +2,13 @@ * IssueProvider — Abstract interface for issue tracker operations. * * Implementations: GitHub (gh CLI), GitLab (glab CLI). - * - * Note: STATE_LABELS and LABEL_COLORS are kept for backward compatibility - * but new code should use the workflow config via lib/workflow.ts. */ -import { DEFAULT_WORKFLOW, getStateLabels, getLabelColors } from "../workflow.js"; - -// --------------------------------------------------------------------------- -// State labels — derived from default workflow for backward compatibility -// --------------------------------------------------------------------------- /** - * @deprecated Use workflow.getStateLabels() instead. - * Kept for backward compatibility with existing code. - */ -export const STATE_LABELS = getStateLabels(DEFAULT_WORKFLOW) as readonly string[]; - -/** - * StateLabel type — union of all valid state labels. - * This remains a string type for flexibility with custom workflows. + * StateLabel type — string for flexibility with custom workflows. */ export type StateLabel = string; -/** - * @deprecated Use workflow.getLabelColors() instead. - * Kept for backward compatibility with existing code. - */ -export const LABEL_COLORS: Record = getLabelColors(DEFAULT_WORKFLOW); - // --------------------------------------------------------------------------- // Issue types // --------------------------------------------------------------------------- @@ -70,6 +49,3 @@ export interface IssueProvider { addComment(issueId: number, body: string): Promise; healthCheck(): Promise; } - -/** @deprecated Use IssueProvider */ -export type TaskManager = IssueProvider; diff --git a/lib/roles/index.ts b/lib/roles/index.ts index 1bd4dab..88ab877 100644 --- a/lib/roles/index.ts +++ b/lib/roles/index.ts @@ -13,7 +13,11 @@ export { isValidRole, getRole, requireRole, + // Role aliases + ROLE_ALIASES, + canonicalRole, // Level aliases + LEVEL_ALIASES, canonicalLevel, // Levels getLevelsForRole, diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index ac234d5..13d427e 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -29,23 +29,23 @@ import { describe("role registry", () => { it("should have all expected roles", () => { const ids = getAllRoleIds(); - assert.ok(ids.includes("dev")); - assert.ok(ids.includes("qa")); + assert.ok(ids.includes("developer")); + assert.ok(ids.includes("tester")); assert.ok(ids.includes("architect")); }); it("should validate role IDs", () => { - assert.strictEqual(isValidRole("dev"), true); - assert.strictEqual(isValidRole("qa"), true); + assert.strictEqual(isValidRole("developer"), true); + assert.strictEqual(isValidRole("tester"), true); assert.strictEqual(isValidRole("architect"), true); assert.strictEqual(isValidRole("nonexistent"), false); }); it("should get role config", () => { - const dev = getRole("dev"); + const dev = getRole("developer"); assert.ok(dev); - assert.strictEqual(dev.id, "dev"); - assert.strictEqual(dev.displayName, "DEV"); + assert.strictEqual(dev.id, "developer"); + assert.strictEqual(dev.displayName, "DEVELOPER"); }); it("should throw for unknown role in requireRole", () => { @@ -55,8 +55,8 @@ describe("role registry", () => { describe("levels", () => { it("should return levels for each role", () => { - assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "mid", "senior"]); - assert.deepStrictEqual([...getLevelsForRole("qa")], ["junior", "mid", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]); }); @@ -67,42 +67,49 @@ describe("levels", () => { it("should return all levels", () => { const all = getAllLevels(); assert.ok(all.includes("junior")); - assert.ok(all.includes("mid")); + assert.ok(all.includes("medior")); assert.ok(all.includes("senior")); }); it("should check level membership", () => { - assert.strictEqual(isLevelForRole("junior", "dev"), true); - assert.strictEqual(isLevelForRole("junior", "qa"), true); + assert.strictEqual(isLevelForRole("junior", "developer"), true); + assert.strictEqual(isLevelForRole("junior", "tester"), true); assert.strictEqual(isLevelForRole("junior", "architect"), true); - assert.strictEqual(isLevelForRole("mid", "dev"), true); - assert.strictEqual(isLevelForRole("mid", "architect"), false); + assert.strictEqual(isLevelForRole("medior", "developer"), true); + assert.strictEqual(isLevelForRole("medior", "architect"), false); }); it("should find role for level", () => { - // "junior" appears in dev first (registry order) - assert.strictEqual(roleForLevel("junior"), "dev"); - assert.strictEqual(roleForLevel("mid"), "dev"); - assert.strictEqual(roleForLevel("senior"), "dev"); + // "junior" appears in developer first (registry order) + assert.strictEqual(roleForLevel("junior"), "developer"); + assert.strictEqual(roleForLevel("medior"), "developer"); + assert.strictEqual(roleForLevel("senior"), "developer"); assert.strictEqual(roleForLevel("nonexistent"), undefined); }); it("should return default level", () => { - assert.strictEqual(getDefaultLevel("dev"), "mid"); - assert.strictEqual(getDefaultLevel("qa"), "mid"); + assert.strictEqual(getDefaultLevel("developer"), "medior"); + assert.strictEqual(getDefaultLevel("tester"), "medior"); assert.strictEqual(getDefaultLevel("architect"), "junior"); }); }); describe("level aliases", () => { - it("should map old dev level names", () => { - assert.strictEqual(canonicalLevel("dev", "medior"), "mid"); - assert.strictEqual(canonicalLevel("dev", "junior"), "junior"); - assert.strictEqual(canonicalLevel("dev", "senior"), "senior"); + it("should map old developer level names", () => { + assert.strictEqual(canonicalLevel("developer", "mid"), "medior"); + assert.strictEqual(canonicalLevel("developer", "junior"), "junior"); + assert.strictEqual(canonicalLevel("developer", "senior"), "senior"); }); - it("should map old qa level names", () => { - assert.strictEqual(canonicalLevel("qa", "reviewer"), "mid"); + it("should map old dev role level names", () => { + assert.strictEqual(canonicalLevel("dev", "mid"), "medior"); + assert.strictEqual(canonicalLevel("dev", "medior"), "medior"); + }); + + it("should map old qa/tester level names", () => { + assert.strictEqual(canonicalLevel("tester", "mid"), "medior"); + assert.strictEqual(canonicalLevel("tester", "reviewer"), "medior"); + assert.strictEqual(canonicalLevel("qa", "reviewer"), "medior"); assert.strictEqual(canonicalLevel("qa", "tester"), "junior"); }); @@ -112,63 +119,69 @@ describe("level aliases", () => { }); it("should pass through unknown levels", () => { - assert.strictEqual(canonicalLevel("dev", "custom"), "custom"); + assert.strictEqual(canonicalLevel("developer", "custom"), "custom"); assert.strictEqual(canonicalLevel("unknown", "whatever"), "whatever"); }); }); describe("models", () => { it("should return default models", () => { - assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5"); - assert.strictEqual(getDefaultModel("dev", "mid"), "anthropic/claude-sonnet-4-5"); - assert.strictEqual(getDefaultModel("qa", "mid"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5"); + assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); }); it("should return all default models", () => { const models = getAllDefaultModels(); - assert.ok(models.dev); - assert.ok(models.qa); + assert.ok(models.developer); + assert.ok(models.tester); assert.ok(models.architect); - assert.strictEqual(models.dev.junior, "anthropic/claude-haiku-4-5"); + assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5"); }); it("should resolve from config override", () => { - const config = { models: { dev: { junior: "custom/model" } } }; - assert.strictEqual(resolveModel("dev", "junior", config), "custom/model"); + const config = { models: { developer: { junior: "custom/model" } } }; + assert.strictEqual(resolveModel("developer", "junior", config), "custom/model"); }); it("should fall back to default", () => { - assert.strictEqual(resolveModel("dev", "junior"), "anthropic/claude-haiku-4-5"); + assert.strictEqual(resolveModel("developer", "junior"), "anthropic/claude-haiku-4-5"); }); it("should pass through unknown level as model ID", () => { - assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); + assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); }); it("should resolve old config keys via aliases", () => { - // Old config uses "medior" key — should still resolve - const config = { models: { dev: { medior: "custom/old-config-model" } } }; - assert.strictEqual(resolveModel("dev", "medior", config), "custom/old-config-model"); + // Old config uses "mid" key — should still resolve via alias + const config = { models: { developer: { mid: "custom/old-config-model" } } }; + assert.strictEqual(resolveModel("developer", "mid", config), "custom/old-config-model"); // Also works when requesting the canonical name - assert.strictEqual(resolveModel("dev", "mid", {}), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(resolveModel("developer", "medior", {}), "anthropic/claude-sonnet-4-5"); + }); + + it("should resolve old role name config keys", () => { + // Old config uses "dev" role key — should still resolve via role alias + const config = { models: { dev: { junior: "custom/model" } } }; + assert.strictEqual(resolveModel("developer", "junior", config), "custom/model"); }); it("should resolve old qa config keys", () => { const config = { models: { qa: { reviewer: "custom/qa-model" } } }; - assert.strictEqual(resolveModel("qa", "reviewer", config), "custom/qa-model"); + assert.strictEqual(resolveModel("tester", "reviewer", config), "custom/qa-model"); }); }); describe("emoji", () => { it("should return level emoji", () => { - assert.strictEqual(getEmoji("dev", "junior"), "⚡"); + assert.strictEqual(getEmoji("developer", "junior"), "⚡"); assert.strictEqual(getEmoji("architect", "senior"), "🏗️"); }); it("should return fallback emoji", () => { - assert.strictEqual(getFallbackEmoji("dev"), "🔧"); - assert.strictEqual(getFallbackEmoji("qa"), "🔍"); + assert.strictEqual(getFallbackEmoji("developer"), "🔧"); + assert.strictEqual(getFallbackEmoji("tester"), "🔍"); assert.strictEqual(getFallbackEmoji("architect"), "🏗️"); assert.strictEqual(getFallbackEmoji("nonexistent"), "📋"); }); @@ -176,32 +189,32 @@ describe("emoji", () => { describe("completion results", () => { it("should return valid results per role", () => { - assert.deepStrictEqual([...getCompletionResults("dev")], ["done", "blocked"]); - assert.deepStrictEqual([...getCompletionResults("qa")], ["pass", "fail", "refine", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); }); it("should validate results", () => { - assert.strictEqual(isValidResult("dev", "done"), true); - assert.strictEqual(isValidResult("dev", "pass"), false); - assert.strictEqual(isValidResult("qa", "pass"), true); - assert.strictEqual(isValidResult("qa", "done"), false); + assert.strictEqual(isValidResult("developer", "done"), true); + assert.strictEqual(isValidResult("developer", "pass"), false); + assert.strictEqual(isValidResult("tester", "pass"), true); + assert.strictEqual(isValidResult("tester", "done"), false); }); }); describe("session key pattern", () => { it("should generate pattern matching all roles", () => { const pattern = getSessionKeyRolePattern(); - assert.ok(pattern.includes("dev")); - assert.ok(pattern.includes("qa")); + assert.ok(pattern.includes("developer")); + assert.ok(pattern.includes("tester")); assert.ok(pattern.includes("architect")); }); it("should work as regex", () => { const pattern = getSessionKeyRolePattern(); const regex = new RegExp(`(${pattern})`); - assert.ok(regex.test("dev")); - assert.ok(regex.test("qa")); + assert.ok(regex.test("developer")); + assert.ok(regex.test("tester")); assert.ok(regex.test("architect")); assert.ok(!regex.test("nonexistent")); }); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index 7ef6d7a..1c1a9d8 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -14,45 +14,45 @@ import type { RoleConfig } from "./types.js"; export const ROLE_REGISTRY: Record = { - dev: { - id: "dev", - displayName: "DEV", - levels: ["junior", "mid", "senior"], - defaultLevel: "mid", + developer: { + id: "developer", + displayName: "DEVELOPER", + levels: ["junior", "medior", "senior"], + defaultLevel: "medior", models: { junior: "anthropic/claude-haiku-4-5", - mid: "anthropic/claude-sonnet-4-5", + medior: "anthropic/claude-sonnet-4-5", senior: "anthropic/claude-opus-4-5", }, emoji: { junior: "⚡", - mid: "🔧", + medior: "🔧", senior: "🧠", }, fallbackEmoji: "🔧", completionResults: ["done", "blocked"], - sessionKeyPattern: "dev", + sessionKeyPattern: "developer", notifications: { onStart: true, onComplete: true }, }, - qa: { - id: "qa", - displayName: "QA", - levels: ["junior", "mid", "senior"], - defaultLevel: "mid", + tester: { + id: "tester", + displayName: "TESTER", + levels: ["junior", "medior", "senior"], + defaultLevel: "medior", models: { junior: "anthropic/claude-haiku-4-5", - mid: "anthropic/claude-sonnet-4-5", + medior: "anthropic/claude-sonnet-4-5", senior: "anthropic/claude-opus-4-5", }, emoji: { junior: "⚡", - mid: "🔍", + medior: "🔍", senior: "🧠", }, fallbackEmoji: "🔍", completionResults: ["pass", "fail", "refine", "blocked"], - sessionKeyPattern: "qa", + sessionKeyPattern: "tester", notifications: { onStart: true, onComplete: true }, }, diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index ba7e5ad..7a5092d 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -6,6 +6,7 @@ */ import { ROLE_REGISTRY } from "./registry.js"; import type { RoleConfig } from "./types.js"; +import type { ResolvedRoleConfig } from "../config/types.js"; // --------------------------------------------------------------------------- // Role IDs @@ -36,13 +37,31 @@ export function requireRole(role: string): RoleConfig { return config; } +// --------------------------------------------------------------------------- +// Role aliases — maps old role IDs to new canonical IDs +// --------------------------------------------------------------------------- + +/** Maps old role IDs to canonical IDs. Used for backward compatibility. */ +export const ROLE_ALIASES: Record = { + dev: "developer", + qa: "tester", +}; + +/** Resolve a role ID, applying aliases for backward compatibility. */ +export function canonicalRole(role: string): string { + return ROLE_ALIASES[role] ?? role; +} + // --------------------------------------------------------------------------- // Level aliases — maps old level names to new canonical names // --------------------------------------------------------------------------- -const LEVEL_ALIASES: Record> = { - dev: { medior: "mid" }, - qa: { reviewer: "mid", tester: "junior" }, +/** Maps old level names to canonical names, per role. Used for backward compatibility. */ +export const LEVEL_ALIASES: Record> = { + developer: { mid: "medior", medior: "medior" }, + dev: { mid: "medior", medior: "medior" }, + tester: { mid: "medior", reviewer: "medior", tester: "junior" }, + qa: { mid: "medior", reviewer: "medior", tester: "junior" }, architect: { opus: "senior", sonnet: "junior" }, }; @@ -105,23 +124,32 @@ export function getAllDefaultModels(): Record> { * Resolve a level to a full model ID. * * Resolution order: - * 1. Plugin config `models..` (tries canonical name, then original) - * 2. Registry default model - * 3. Passthrough (treat level as raw model ID) + * 1. Plugin config `models..` in openclaw.json (highest precedence) + * 2. Resolved config from config.yaml (if provided) + * 3. Registry default model + * 4. Passthrough (treat level as raw model ID) */ export function resolveModel( role: string, level: string, pluginConfig?: Record, + resolvedRole?: ResolvedRoleConfig, ): string { const canonical = canonicalLevel(role, level); + + // 1. Plugin config override (openclaw.json) — check canonical role + old aliases const models = (pluginConfig as { models?: Record })?.models; if (models && typeof models === "object") { - const roleModels = models[role] as Record | undefined; - // Try canonical name first, then original (for old configs) + // Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer") + const roleModels = (models[role] ?? models[Object.entries(ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record | undefined; if (roleModels?.[canonical]) return roleModels[canonical]; if (roleModels?.[level]) return roleModels[level]; } + + // 2. Resolved config (config.yaml) + if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical]; + + // 3. Built-in registry default return getDefaultModel(role, canonical) ?? canonical; } diff --git a/lib/roles/types.ts b/lib/roles/types.ts index 6a2f4c2..34278cf 100644 --- a/lib/roles/types.ts +++ b/lib/roles/types.ts @@ -7,7 +7,7 @@ /** Configuration for a single worker role. */ export type RoleConfig = { - /** Unique role identifier (e.g., "dev", "qa", "architect"). */ + /** Unique role identifier (e.g., "developer", "tester", "architect"). */ id: string; /** Human-readable display name. */ displayName: string; @@ -23,7 +23,7 @@ export type RoleConfig = { fallbackEmoji: string; /** Valid completion results for this role. */ completionResults: readonly string[]; - /** Regex pattern fragment for session key matching (e.g., "dev|qa|architect"). */ + /** Regex pattern fragment for session key matching (e.g., "developer|tester|architect"). */ sessionKeyPattern: string; /** Notification config per event type. */ notifications: { diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index f4ff19f..94d45f9 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -18,7 +18,6 @@ import { log as auditLog } from "../audit.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js"; import { projectTick } from "./tick.js"; import { createProvider } from "../providers/index.js"; -import { getAllRoleIds } from "../roles/index.js"; // --------------------------------------------------------------------------- // Types @@ -307,13 +306,13 @@ async function performHealthPass( const { provider } = await createProvider({ repo: project.repo, provider: project.provider }); let fixedCount = 0; - for (const role of getAllRoleIds()) { + for (const role of Object.keys(project.workers)) { // Check worker health (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, groupId, project, - role: role as any, + role, sessions, autoFix: true, provider, @@ -325,7 +324,7 @@ async function performHealthPass( workspaceDir, groupId, project, - role: role as any, + role, autoFix: true, provider, }); @@ -336,10 +335,10 @@ async function performHealthPass( } /** - * Check if a project has active work (dev or qa). + * Check if a project has any active worker. */ async function checkProjectActive(workspaceDir: string, groupId: string): Promise { const fresh = (await readProjects(workspaceDir)).projects[groupId]; if (!fresh) return false; - return fresh.dev.active || fresh.qa.active; + return Object.values(fresh.workers).some(w => w.active); } diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 5537623..3c9ae53 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -17,40 +17,6 @@ import { type WorkflowConfig, } from "../workflow.js"; -// --------------------------------------------------------------------------- -// Backward compatibility exports -// --------------------------------------------------------------------------- - -/** - * @deprecated Use getCompletionRule() from workflow.ts instead. - * Kept for backward compatibility. - */ -export const COMPLETION_RULES: Record = { - "dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true }, - "qa:pass": { from: "Testing", to: "Done", closeIssue: true }, - "qa:fail": { from: "Testing", to: "To Improve", reopenIssue: true }, - "qa:refine": { from: "Testing", to: "Refining" }, - "dev:blocked": { from: "Doing", to: "Refining" }, - "qa:blocked": { from: "Testing", to: "Refining" }, - "architect:done": { from: "Designing", to: "Planning" }, - "architect:blocked": { from: "Designing", to: "Refining" }, -}; - -/** - * @deprecated Use getNextStateDescription() from workflow.ts instead. - */ -export const NEXT_STATE: Record = { - "dev:done": "QA queue", - "dev:blocked": "moved to Refining - needs human input", - "qa:pass": "Done!", - "qa:fail": "back to DEV", - "qa:refine": "awaiting human decision", - "qa:blocked": "moved to Refining - needs human input", - "architect:done": "Planning — ready for review", - "architect:blocked": "moved to Refining - needs clarification", -}; - -// Re-export CompletionRule type for backward compatibility export type { CompletionRule }; export type CompletionOutput = { @@ -72,7 +38,7 @@ export function getRule( result: string, workflow: WorkflowConfig = DEFAULT_WORKFLOW, ): CompletionRule | undefined { - return getCompletionRule(workflow, role as "dev" | "qa", result) ?? undefined; + return getCompletionRule(workflow, role, result) ?? undefined; } /** @@ -81,7 +47,7 @@ export function getRule( export async function executeCompletion(opts: { workspaceDir: string; groupId: string; - role: "dev" | "qa" | "architect"; + role: string; result: string; issueId: number; summary?: string; diff --git a/lib/services/queue.ts b/lib/services/queue.ts index 51f013c..cceaac6 100644 --- a/lib/services/queue.ts +++ b/lib/services/queue.ts @@ -13,26 +13,6 @@ import { type Role, } from "../workflow.js"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** - * @deprecated Use string labels from workflow config instead. - * Kept for backward compatibility. - */ -export type QueueLabel = "To Improve" | "To Test" | "To Do"; - -/** - * @deprecated Use getQueuePriority() instead. - * Kept for backward compatibility. - */ -export const QUEUE_PRIORITY: Record = { - "To Improve": 3, - "To Test": 2, - "To Do": 1, -}; - // --------------------------------------------------------------------------- // Workflow-driven helpers // --------------------------------------------------------------------------- diff --git a/lib/services/tick.ts b/lib/services/tick.ts index d4c10cf..55d7b7f 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -11,9 +11,9 @@ import { createProvider } from "../providers/index.js"; import { selectLevel } from "../model-selector.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { getAllRoleIds, getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js"; +import { getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js"; +import { loadConfig } from "../config/index.js"; import { - DEFAULT_WORKFLOW, getQueueLabels, getAllQueueLabels, getActiveLabel, @@ -22,25 +22,6 @@ import { type Role, } from "../workflow.js"; -// --------------------------------------------------------------------------- -// Backward compatibility exports (deprecated) -// --------------------------------------------------------------------------- - -/** - * @deprecated Use getQueueLabels(workflow, "dev") instead. - */ -export const DEV_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "dev"); - -/** - * @deprecated Use getQueueLabels(workflow, "qa") instead. - */ -export const QA_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "qa"); - -/** - * @deprecated Use getAllQueueLabels(workflow) instead. - */ -export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW); - // --------------------------------------------------------------------------- // Shared helpers (used by tick, work-start, auto-pickup) // --------------------------------------------------------------------------- @@ -68,7 +49,7 @@ export function detectLevelFromLabels(labels: string[]): string | null { */ export function detectRoleFromLabel( label: StateLabel, - workflow: WorkflowConfig = DEFAULT_WORKFLOW, + workflow: WorkflowConfig, ): Role | null { return workflowDetectRole(workflow, label); } @@ -76,7 +57,7 @@ export function detectRoleFromLabel( export async function findNextIssueForRole( provider: Pick, role: Role, - workflow: WorkflowConfig = DEFAULT_WORKFLOW, + workflow: WorkflowConfig, ): Promise<{ issue: Issue; label: StateLabel } | null> { const labels = getQueueLabels(workflow, role); for (const label of labels) { @@ -93,8 +74,8 @@ export async function findNextIssueForRole( */ export async function findNextIssue( provider: Pick, - role?: Role, - workflow: WorkflowConfig = DEFAULT_WORKFLOW, + role: Role | undefined, + workflow: WorkflowConfig, ): Promise<{ issue: Issue; label: StateLabel } | null> { const labels = role ? getQueueLabels(workflow, role) @@ -156,15 +137,20 @@ export async function projectTick(opts: { const { workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, maxPickups, targetRole, runtime, - workflow = DEFAULT_WORKFLOW, } = opts; const project = (await readProjects(workspaceDir)).projects[groupId]; if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] }; + const resolvedConfig = await loadConfig(workspaceDir, project.name); + const workflow = opts.workflow ?? resolvedConfig.workflow; + const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider; const roleExecution = project.roleExecution ?? "parallel"; - const roles: Role[] = targetRole ? [targetRole] : getAllRoleIds() as Role[]; + const enabledRoles = Object.entries(resolvedConfig.roles) + .filter(([, r]) => r.enabled) + .map(([id]) => id); + const roles: Role[] = targetRole ? [targetRole] : enabledRoles; const pickups: TickAction[] = []; const skipped: TickResult["skipped"] = []; @@ -186,8 +172,8 @@ export async function projectTick(opts: { continue; } // Check sequential role execution: any other role must be inactive - const otherRoles = getAllRoleIds().filter(r => r !== role); - if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r as any).active)) { + const otherRoles = enabledRoles.filter((r: string) => r !== role); + if (roleExecution === "sequential" && otherRoles.some((r: string) => getWorker(fresh, r).active)) { skipped.push({ role, reason: "Sequential: other role active" }); continue; } diff --git a/lib/setup/config.ts b/lib/setup/config.ts index 9c0a783..ab74ae4 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; -type ModelConfig = { dev: Record; qa: Record; architect: Record }; +type ModelConfig = Record>; /** * Write DevClaw model level config to openclaw.json plugins section. diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 9d4c8e4..08a541a 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -5,13 +5,13 @@ * Used by both the `setup` tool and the `openclaw devclaw setup` CLI command. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { DEFAULT_MODELS } from "../tiers.js"; +import { getAllDefaultModels } from "../roles/index.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; architect: Record }; +export type ModelConfig = Record>; export type SetupOpts = { /** OpenClaw plugin API for config access. */ @@ -27,7 +27,7 @@ export type SetupOpts = { /** Override workspace path (auto-detected from agent if not given). */ workspacePath?: string; /** Model overrides per role.level. Missing levels use defaults. */ - models?: { dev?: Partial>; qa?: Partial>; architect?: Partial> }; + models?: Record>>; /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ projectExecution?: "parallel" | "sequential"; }; @@ -113,25 +113,21 @@ async function tryMigrateBinding( } function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { - const dev: Record = { ...DEFAULT_MODELS.dev }; - const qa: Record = { ...DEFAULT_MODELS.qa }; - const architect: Record = { ...DEFAULT_MODELS.architect }; + const defaults = getAllDefaultModels(); + const result: ModelConfig = {}; - if (overrides?.dev) { - for (const [level, model] of Object.entries(overrides.dev)) { - if (model) dev[level] = model; - } + for (const [role, levels] of Object.entries(defaults)) { + result[role] = { ...levels }; } - if (overrides?.qa) { - for (const [level, model] of Object.entries(overrides.qa)) { - if (model) qa[level] = model; - } - } - if (overrides?.architect) { - for (const [level, model] of Object.entries(overrides.architect)) { - if (model) architect[level] = model; + + if (overrides) { + for (const [role, roleOverrides] of Object.entries(overrides)) { + if (!result[role]) result[role] = {}; + for (const [level, model] of Object.entries(roleOverrides)) { + if (model) result[role][level] = model; + } } } - return { dev, qa, architect }; + return result; } diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index 9cbb2fe..51f971c 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -6,14 +6,14 @@ import { runCommand } from "../run-command.js"; export type ModelAssignment = { - dev: { + developer: { junior: string; - mid: string; + medior: string; senior: string; }; - qa: { + tester: { junior: string; - mid: string; + medior: string; senior: string; }; architect: { @@ -37,8 +37,8 @@ export async function selectModelsWithLLM( if (availableModels.length === 1) { const model = availableModels[0].model; return { - dev: { junior: model, mid: model, senior: model }, - qa: { junior: model, mid: model, senior: model }, + developer: { junior: model, medior: model, senior: model }, + tester: { junior: model, medior: model, senior: model }, architect: { junior: model, senior: model }, }; } @@ -53,27 +53,27 @@ ${modelList} All roles use the same level scheme based on task complexity: - **senior** (most capable): Complex architecture, refactoring, critical decisions -- **mid** (balanced): Features, bug fixes, code review, standard tasks +- **medior** (balanced): Features, bug fixes, code review, standard tasks - **junior** (fast/efficient): Simple fixes, routine tasks Rules: 1. Prefer same provider for consistency 2. Assign most capable model to senior -3. Assign mid-tier model to mid +3. Assign mid-tier model to medior 4. Assign fastest/cheapest model to junior 5. Consider model version numbers (higher = newer/better) 6. Stable versions (no date) > snapshot versions (with date like 20250514) Return ONLY a JSON object in this exact format (no markdown, no explanation): { - "dev": { + "developer": { "junior": "provider/model-name", - "mid": "provider/model-name", + "medior": "provider/model-name", "senior": "provider/model-name" }, - "qa": { + "tester": { "junior": "provider/model-name", - "mid": "provider/model-name", + "medior": "provider/model-name", "senior": "provider/model-name" }, "architect": { @@ -131,18 +131,18 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): // Backfill architect if LLM didn't return it (graceful upgrade) if (!assignment.architect) { assignment.architect = { - senior: assignment.dev?.senior ?? availableModels[0].model, - junior: assignment.dev?.mid ?? availableModels[0].model, + senior: assignment.developer?.senior ?? availableModels[0].model, + junior: assignment.developer?.medior ?? availableModels[0].model, }; } if ( - !assignment.dev?.junior || - !assignment.dev?.mid || - !assignment.dev?.senior || - !assignment.qa?.junior || - !assignment.qa?.mid || - !assignment.qa?.senior + !assignment.developer?.junior || + !assignment.developer?.medior || + !assignment.developer?.senior || + !assignment.tester?.junior || + !assignment.tester?.medior || + !assignment.tester?.senior ) { console.error("Invalid assignment structure. Got:", assignment); throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`); diff --git a/lib/setup/smart-model-selector.ts b/lib/setup/smart-model-selector.ts index 1c2fdf6..76b9ea5 100644 --- a/lib/setup/smart-model-selector.ts +++ b/lib/setup/smart-model-selector.ts @@ -5,14 +5,14 @@ */ export type ModelAssignment = { - dev: { + developer: { junior: string; - mid: string; + medior: string; senior: string; }; - qa: { + tester: { junior: string; - mid: string; + medior: string; senior: string; }; architect: { @@ -44,8 +44,8 @@ export async function assignModels( if (authenticated.length === 1) { const model = authenticated[0].model; return { - dev: { junior: model, mid: model, senior: model }, - qa: { junior: model, mid: model, senior: model }, + developer: { junior: model, medior: model, senior: model }, + tester: { junior: model, medior: model, senior: model }, architect: { junior: model, senior: model }, }; } @@ -66,16 +66,16 @@ export async function assignModels( */ export function formatAssignment(assignment: ModelAssignment): string { const lines = [ - "| Role | Level | Model |", - "|------|----------|--------------------------|", - `| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`, - `| DEV | mid | ${assignment.dev.mid.padEnd(24)} |`, - `| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`, - `| QA | senior | ${assignment.qa.senior.padEnd(24)} |`, - `| QA | mid | ${assignment.qa.mid.padEnd(24)} |`, - `| QA | junior | ${assignment.qa.junior.padEnd(24)} |`, - `| ARCH | senior | ${assignment.architect.senior.padEnd(24)} |`, - `| ARCH | junior | ${assignment.architect.junior.padEnd(24)} |`, + "| Role | Level | Model |", + "|-----------|----------|--------------------------|", + `| DEVELOPER | senior | ${assignment.developer.senior.padEnd(24)} |`, + `| DEVELOPER | medior | ${assignment.developer.medior.padEnd(24)} |`, + `| DEVELOPER | junior | ${assignment.developer.junior.padEnd(24)} |`, + `| TESTER | senior | ${assignment.tester.senior.padEnd(24)} |`, + `| TESTER | medior | ${assignment.tester.medior.padEnd(24)} |`, + `| TESTER | junior | ${assignment.tester.junior.padEnd(24)} |`, + `| ARCHITECT | senior | ${assignment.architect.senior.padEnd(24)} |`, + `| ARCHITECT | junior | ${assignment.architect.junior.padEnd(24)} |`, ]; return lines.join("\n"); } diff --git a/lib/templates.ts b/lib/templates.ts index cc8f322..e15eaf9 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -3,7 +3,7 @@ * Used by setup and project_register. */ -export const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions +export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions ## Context You Receive @@ -24,19 +24,19 @@ Read the comments carefully — they often contain clarifications, decisions, or - Create an MR/PR to the base branch and merge it - **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA. - Clean up the worktree after merging -- When done, call work_finish with role "dev", result "done", and a brief summary +- When done, call work_finish with role "developer", result "done", and a brief summary - If you discover unrelated bugs, call task_create to file them - Do NOT call work_start, status, health, or project_register `; -export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions +export const DEFAULT_QA_INSTRUCTIONS = `# TESTER Worker Instructions - Pull latest from the base branch - Run tests and linting - Verify the changes address the issue requirements - Check for regressions in related functionality - **Always** call task_comment with your review findings — even if everything looks good, leave a brief summary of what you checked -- When done, call work_finish with role "qa" and one of: +- When done, call work_finish with role "tester" and one of: - result "pass" if everything looks good - result "fail" with specific issues if problems found - result "refine" if you need human input to decide @@ -55,7 +55,7 @@ Investigate the design problem thoroughly: 2. **Research alternatives** — Explore >= 3 viable approaches 3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit 4. **Recommend** — Pick the best option with clear reasoning -5. **Outline implementation** — Break down into dev tasks +5. **Outline implementation** — Break down into developer tasks ## Output Format @@ -115,9 +115,16 @@ Your session is persistent — you may be called back for refinements. Do NOT call work_start, status, health, or project_register. `; +/** Default role instructions indexed by role ID. Used by project scaffolding. */ +export const DEFAULT_ROLE_INSTRUCTIONS: Record = { + developer: DEFAULT_DEV_INSTRUCTIONS, + tester: DEFAULT_QA_INSTRUCTIONS, + architect: DEFAULT_ARCHITECT_INSTRUCTIONS, +}; + export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw) -## If You Are a Sub-Agent (DEV/QA Worker) +## If You Are a Sub-Agent (DEVELOPER/TESTER Worker) Skip the orchestrator section. Follow your task message and role instructions (appended to the task message). @@ -126,21 +133,21 @@ Skip the orchestrator section. Follow your task message and role instructions (a - Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\` - Include issue number: \`feat: add user authentication (#12)\` - Branch naming: \`feature/-\` or \`fix/-\` -- **DEV always works in a git worktree** (never switch branches in the main repo) -- **DEV must merge to base branch** before announcing completion -- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses QA. -- **QA tests on the deployed version** and inspects code on the base branch -- **QA always calls task_comment** with review findings before completing +- **DEVELOPER always works in a git worktree** (never switch branches in the main repo) +- **DEVELOPER must merge to base branch** before announcing completion +- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses testing. +- **TESTER tests on the deployed version** and inspects code on the base branch +- **TESTER always calls task_comment** with review findings before completing - Always run tests before completing ### Completing Your Task When you are done, **call \`work_finish\` yourself** — do not just announce in text. -- **DEV done:** \`work_finish({ role: "dev", result: "done", projectGroupId: "", summary: "" })\` -- **QA pass:** \`work_finish({ role: "qa", result: "pass", projectGroupId: "", summary: "" })\` -- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "", summary: "" })\` -- **QA refine:** \`work_finish({ role: "qa", result: "refine", projectGroupId: "", summary: "" })\` +- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` +- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "", summary: "" })\` +- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "", summary: "" })\` +- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "", summary: "" })\` - **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "", summary: "" })\` The \`projectGroupId\` is included in your task message. @@ -167,14 +174,14 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder **Never write code yourself.** All implementation work MUST go through the issue → worker pipeline: 1. Create an issue via \`task_create\` -2. Dispatch a DEV worker via \`work_start\` +2. Dispatch a DEVELOPER worker via \`work_start\` 3. Let the worker handle implementation, git, and PRs **Why this matters:** - **Audit trail** — Every code change is tracked to an issue -- **Level selection** — Junior/mid/senior models match task complexity +- **Level selection** — Junior/medior/senior models match task complexity - **Parallelization** — Workers run in parallel, you stay free to plan -- **QA pipeline** — Code goes through review before closing +- **Testing pipeline** — Code goes through review before closing **What you CAN do directly:** - Planning, analysis, architecture discussions @@ -195,7 +202,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder Examples: - ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42" -- ✅ "Picked up #42 for DEV (mid) 🔗 https://github.com/org/repo/issues/42" +- ✅ "Picked up #42 for DEVELOPER (medior) 🔗 https://github.com/org/repo/issues/42" - ❌ "Created issue #42 about the login bug" (missing URL) ### DevClaw Tools @@ -232,10 +239,10 @@ Issue labels are the single source of truth for task state. Evaluate each task and pass the appropriate developer level to \`work_start\`: - **junior** — trivial: typos, single-file fix, quick change -- **mid** — standard: features, bug fixes, multi-file changes +- **medior** — standard: features, bug fixes, multi-file changes - **senior** — complex: architecture, system-wide refactoring, 5+ services -All roles (DEV, QA, Architect) use the same level scheme. Levels describe task complexity, not the model. +All roles (Developer, Tester, Architect) use the same level scheme. Levels describe task complexity, not the model. ### Picking Up Work @@ -249,10 +256,10 @@ All roles (DEV, QA, Architect) use the same level scheme. Levels describe task c Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle: -- DEV "done" → issue moves to "To Test" → scheduler dispatches QA -- QA "fail" → issue moves to "To Improve" → scheduler dispatches DEV -- QA "pass" → Done, no further dispatch -- QA "refine" / blocked → needs human input +- Developer "done" → issue moves to "To Test" → scheduler dispatches Tester +- Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer +- Tester "pass" → Done, no further dispatch +- Tester "refine" / blocked → needs human input - Architect "done" → issue moves to "Planning" → ready for tech lead review **Always include issue URLs** in your response — these are in the \`announcement\` fields. @@ -267,10 +274,10 @@ Workers receive role-specific instructions appended to their task message. These ### Safety -- **Never write code yourself** — always dispatch a DEV worker +- **Never write code yourself** — always dispatch a Developer worker - Don't push to main directly - Don't force-push -- Don't close issues without QA pass +- Don't close issues without Tester pass - Ask before architectural decisions affecting multiple projects `; diff --git a/lib/tiers.ts b/lib/tiers.ts deleted file mode 100644 index 2f08915..0000000 --- a/lib/tiers.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * tiers.ts — Developer level definitions and model resolution. - * - * This module now delegates to the centralized role registry (lib/roles/). - * Kept for backward compatibility — new code should import from lib/roles/ directly. - * - * Level names are plain: "junior", "mid", "senior". - * Role context (dev/qa/architect) is always provided by the caller. - */ -import { - type WorkerRole, - ROLE_REGISTRY, - getLevelsForRole, - getAllDefaultModels, - roleForLevel, - getDefaultModel, - getEmoji, - resolveModel as registryResolveModel, -} from "./roles/index.js"; - -// Re-export WorkerRole from the registry -export type { WorkerRole }; - -// --------------------------------------------------------------------------- -// Level constants — derived from registry -// --------------------------------------------------------------------------- - -/** @deprecated Use roles/selectors.getAllDefaultModels() */ -export const DEFAULT_MODELS = getAllDefaultModels(); - -/** @deprecated Use roles/selectors.getEmoji() */ -export const LEVEL_EMOJI: Record> = Object.fromEntries( - Object.entries(ROLE_REGISTRY).map(([id, config]) => [id, { ...config.emoji }]), -); - -export const DEV_LEVELS = getLevelsForRole("dev") as readonly string[]; -export const QA_LEVELS = getLevelsForRole("qa") as readonly string[]; -export const ARCHITECT_LEVELS = getLevelsForRole("architect") as readonly string[]; - -export type DevLevel = string; -export type QaLevel = string; -export type ArchitectLevel = string; -export type Level = string; - -// --------------------------------------------------------------------------- -// Level checks — delegate to registry -// --------------------------------------------------------------------------- - -/** Check if a level belongs to the dev role. */ -export function isDevLevel(value: string): boolean { - return DEV_LEVELS.includes(value); -} - -/** Check if a level belongs to the qa role. */ -export function isQaLevel(value: string): boolean { - return QA_LEVELS.includes(value); -} - -/** Check if a level belongs to the architect role. */ -export function isArchitectLevel(value: string): boolean { - return ARCHITECT_LEVELS.includes(value); -} - -/** Determine the role a level belongs to. */ -export function levelRole(level: string): WorkerRole | undefined { - return roleForLevel(level) as WorkerRole | undefined; -} - -// --------------------------------------------------------------------------- -// Model + emoji — delegate to registry -// --------------------------------------------------------------------------- - -/** @deprecated Use roles/selectors.getDefaultModel() */ -export function defaultModel(role: WorkerRole, level: string): string | undefined { - return getDefaultModel(role, level); -} - -/** @deprecated Use roles/selectors.getEmoji() */ -export function levelEmoji(role: WorkerRole, level: string): string | undefined { - return getEmoji(role, level); -} - -/** @deprecated Use roles/selectors.resolveModel() */ -export function resolveModel( - role: WorkerRole, - level: string, - pluginConfig?: Record, -): string { - return registryResolveModel(role, level, pluginConfig); -} diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index 0581a39..258c06e 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -5,7 +5,7 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { parseDevClawSessionKey } from "../bootstrap-hook.js"; -import { isArchitectLevel, levelRole, resolveModel, defaultModel, levelEmoji } from "../tiers.js"; +import { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js"; import { selectLevel } from "../model-selector.js"; import { DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule, @@ -14,21 +14,21 @@ import { describe("architect tiers", () => { it("should recognize architect levels", () => { - assert.strictEqual(isArchitectLevel("junior"), true); - assert.strictEqual(isArchitectLevel("senior"), true); - assert.strictEqual(isArchitectLevel("mid"), false); + assert.strictEqual(isLevelForRole("junior", "architect"), true); + assert.strictEqual(isLevelForRole("senior", "architect"), true); + assert.strictEqual(isLevelForRole("medior", "architect"), false); }); it("should map architect levels to role", () => { - // "junior" and "senior" appear in dev first (registry order), so roleForLevel returns "dev" - // This is expected — use isArchitectLevel for architect-specific checks - assert.strictEqual(levelRole("junior"), "dev"); - assert.strictEqual(levelRole("senior"), "dev"); + // "junior" and "senior" appear in developer first (registry order), so roleForLevel returns "developer" + // This is expected — use isLevelForRole for role-specific checks + assert.strictEqual(roleForLevel("junior"), "developer"); + assert.strictEqual(roleForLevel("senior"), "developer"); }); it("should resolve default architect models", () => { - assert.strictEqual(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); - assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); + assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); it("should resolve architect model from config", () => { @@ -37,8 +37,8 @@ describe("architect tiers", () => { }); it("should have architect emoji", () => { - assert.strictEqual(levelEmoji("architect", "senior"), "🏗️"); - assert.strictEqual(levelEmoji("architect", "junior"), "📐"); + assert.strictEqual(getEmoji("architect", "senior"), "🏗️"); + assert.strictEqual(getEmoji("architect", "junior"), "📐"); }); }); @@ -76,8 +76,9 @@ describe("architect workflow states", () => { assert.strictEqual(rule!.to, "Refining"); }); - it("should have architect completion emoji", () => { - assert.strictEqual(getCompletionEmoji("architect", "done"), "🏗️"); + it("should have completion emoji by result type", () => { + // Emoji is now keyed by result, not role:result + assert.strictEqual(getCompletionEmoji("architect", "done"), "✅"); assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫"); }); }); diff --git a/lib/tools/design-task.ts b/lib/tools/design-task.ts index 12e379d..0b1dcce 100644 --- a/lib/tools/design-task.ts +++ b/lib/tools/design-task.ts @@ -13,7 +13,9 @@ import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; -import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js"; +import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js"; +import { selectLevel } from "../model-selector.js"; +import { resolveModel } from "../roles/index.js"; export function createDesignTaskTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -81,6 +83,14 @@ Example: const { project } = await resolveProject(workspaceDir, groupId); const { provider } = await resolveProvider(project); + const pluginConfig = getPluginConfig(api); + + // Derive labels from workflow config + const workflow = await loadWorkflow(workspaceDir, project.name); + const role = "architect"; + const queueLabels = getQueueLabels(workflow, role); + const queueLabel = queueLabels[0]; + if (!queueLabel) throw new Error(`No queue state found for role "${role}" in workflow`); // Build issue body with focus areas const bodyParts = [description]; @@ -101,51 +111,48 @@ Example: ); const issueBody = bodyParts.join("\n"); - // Create issue in To Design state - const issue = await provider.createIssue(title, issueBody, "To Design" as StateLabel); + // Create issue in queue state + const issue = await provider.createIssue(title, issueBody, queueLabel as StateLabel); await auditLog(workspaceDir, "design_task", { project: project.name, groupId, issueId: issue.iid, title, complexity, focusAreas, dryRun, }); - // Select level based on complexity - const level = complexity === "complex" ? "senior" : "junior"; + // Select level: use complexity hint to guide the heuristic + const level = complexity === "complex" + ? selectLevel(title, "system-wide " + description, role).level + : selectLevel(title, description, role).level; + const model = resolveModel(role, level, pluginConfig); if (dryRun) { return jsonResult({ success: true, dryRun: true, - issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" }, - design: { - level, - model: complexity === "complex" ? "anthropic/claude-opus-4-5" : "anthropic/claude-sonnet-4-5", - status: "dry_run", - }, - announcement: `📐 [DRY RUN] Would spawn architect (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel }, + design: { level, model, status: "dry_run" }, + announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`, }); } - // Check architect availability - const worker = getWorker(project, "architect"); + // Check worker availability + const worker = getWorker(project, role); if (worker.active) { // Issue created but can't dispatch yet — will be picked up by heartbeat return jsonResult({ success: true, - issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" }, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel }, design: { level, status: "queued", - reason: `Architect already active on #${worker.issueId}. Issue queued for pickup.`, + reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue queued for pickup.`, }, - announcement: `📐 Created design task #${issue.iid}: ${title} (queued — architect busy)\n🔗 ${issue.web_url}`, + announcement: `📐 Created design task #${issue.iid}: ${title} (queued — ${role} busy)\n🔗 ${issue.web_url}`, }); } - // Dispatch architect - const workflow = DEFAULT_WORKFLOW; - const targetLabel = getActiveLabel(workflow, "architect"); - const pluginConfig = getPluginConfig(api); + // Dispatch worker + const targetLabel = getActiveLabel(workflow, role); const dr = await dispatchTask({ workspaceDir, @@ -156,9 +163,9 @@ Example: issueTitle: issue.title, issueDescription: issueBody, issueUrl: issue.web_url, - role: "architect", + role, level, - fromLabel: "To Design", + fromLabel: queueLabel, toLabel: targetLabel, transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), provider, diff --git a/lib/tools/health.ts b/lib/tools/health.ts index d3bec26..9111126 100644 --- a/lib/tools/health.ts +++ b/lib/tools/health.ts @@ -18,7 +18,6 @@ import { readProjects, getProject } from "../projects.js"; import { log as auditLog } from "../audit.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js"; import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js"; -import { getAllRoleIds } from "../roles/index.js"; export function createHealthTool() { return (ctx: ToolContext) => ({ @@ -52,13 +51,13 @@ export function createHealthTool() { if (!project) continue; const { provider } = await resolveProvider(project); - for (const role of getAllRoleIds()) { + for (const role of Object.keys(project.workers)) { // Worker health check (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, groupId: pid, project, - role: role as any, + role, sessions, autoFix: fix, provider, @@ -70,7 +69,7 @@ export function createHealthTool() { workspaceDir, groupId: pid, project, - role: role as any, + role, autoFix: fix, provider, }); diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 90f85a2..a3219bb 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -15,40 +15,26 @@ import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; -import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js"; +import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; /** - * Scaffold project-specific prompt files. + * Scaffold project-specific prompt files for all registered roles. * 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", "roles", projectName); await fs.mkdir(projectDir, { recursive: true }); - const projectDev = path.join(projectDir, "dev.md"); - const projectQa = path.join(projectDir, "qa.md"); let created = false; - - try { - await fs.access(projectDev); - } catch { - await fs.writeFile(projectDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8"); - created = true; - } - - try { - await fs.access(projectQa); - } catch { - await fs.writeFile(projectQa, DEFAULT_QA_INSTRUCTIONS, "utf-8"); - created = true; - } - - const projectArchitect = path.join(projectDir, "architect.md"); - try { - await fs.access(projectArchitect); - } catch { - await fs.writeFile(projectArchitect, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8"); - created = true; + for (const role of getAllRoleIds()) { + const filePath = path.join(projectDir, `${role}.md`); + try { + await fs.access(filePath); + } catch { + const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`; + await fs.writeFile(filePath, content, "utf-8"); + created = true; + } } return created; @@ -122,7 +108,8 @@ export function createProjectRegisterTool() { // 1. Check project not already registered (allow re-register if incomplete) const data = await readProjects(workspaceDir); const existing = data.projects[groupId]; - if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) { + const existingWorkers = existing?.workers ?? {}; + if (existing && Object.values(existingWorkers).some(w => w.sessions && Object.keys(w.sessions).length > 0)) { throw new Error( `Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`, ); @@ -153,6 +140,12 @@ export function createProjectRegisterTool() { await provider.ensureAllStateLabels(); // 5. Add project to projects.json + // Build workers map from all registered roles + const workers: Record = {}; + for (const role of getAllRoleIds()) { + workers[role] = emptyWorkerState([...getLevelsForRole(role)]); + } + data.projects[groupId] = { name, repo, @@ -163,9 +156,7 @@ export function createProjectRegisterTool() { channel, provider: providerType, roleExecution, - dev: emptyWorkerState([...getLevelsForRole("dev")]), - qa: emptyWorkerState([...getLevelsForRole("qa")]), - architect: emptyWorkerState([...getLevelsForRole("architect")]), + workers, }; await writeProjects(workspaceDir, data); diff --git a/lib/tools/queue-status.test.ts b/lib/tools/queue-status.test.ts index 1d5cec4..d831780 100644 --- a/lib/tools/queue-status.test.ts +++ b/lib/tools/queue-status.test.ts @@ -15,18 +15,18 @@ describe("status execution-aware sequencing", () => { }); describe("role assignment", () => { - it("should assign To Improve to dev", () => { - // To Improve = dev work + it("should assign To Improve to developer", () => { + // To Improve = developer work assert.ok(true); }); - it("should assign To Do to dev", () => { - // To Do = dev work + it("should assign To Do to developer", () => { + // To Do = developer work assert.ok(true); }); - it("should assign To Test to qa", () => { - // To Test = qa work + it("should assign To Test to tester", () => { + // To Test = tester work assert.ok(true); }); }); @@ -43,12 +43,12 @@ describe("status execution-aware sequencing", () => { }); it("should support parallel role execution within project", () => { - // DEV and QA can run simultaneously + // Developer and Tester can run simultaneously assert.ok(true); }); it("should support sequential role execution within project", () => { - // DEV and QA alternate + // Developer and Tester alternate assert.ok(true); }); }); diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 40fbbe2..ff3ce4b 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -8,8 +8,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { runSetup, type SetupOpts } from "../setup/index.js"; -import { DEFAULT_MODELS } from "../tiers.js"; -import { getLevelsForRole } from "../roles/index.js"; +import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js"; export function createSetupTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -37,44 +36,18 @@ export function createSetupTool(api: OpenClawPluginApi) { models: { type: "object", description: "Model overrides per role and level.", - properties: { - dev: { + properties: Object.fromEntries( + getAllRoleIds().map((role) => [role, { type: "object", - description: "Developer level models", - properties: { - junior: { + description: `${role.toUpperCase()} level models`, + properties: Object.fromEntries( + getLevelsForRole(role).map((level) => [level, { type: "string", - description: `Default: ${DEFAULT_MODELS.dev.junior}`, - }, - mid: { - type: "string", - description: `Default: ${DEFAULT_MODELS.dev.mid}`, - }, - senior: { - type: "string", - description: `Default: ${DEFAULT_MODELS.dev.senior}`, - }, - }, - }, - qa: { - type: "object", - description: "QA level models", - properties: { - junior: { - type: "string", - description: `Default: ${DEFAULT_MODELS.qa.junior}`, - }, - mid: { - type: "string", - description: `Default: ${DEFAULT_MODELS.qa.mid}`, - }, - senior: { - type: "string", - description: `Default: ${DEFAULT_MODELS.qa.senior}`, - }, - }, - }, - }, + description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`, + }]), + ), + }]), + ), }, projectExecution: { type: "string", @@ -112,13 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) { "", ); } - lines.push( - "Models:", - ...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`), - ...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`), - ...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[t]}`), - "", - ); + lines.push("Models:"); + for (const [role, levels] of Object.entries(result.models)) { + for (const [level, model] of Object.entries(levels)) { + lines.push(` ${role}.${level}: ${model}`); + } + } + lines.push(""); lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`)); diff --git a/lib/tools/status.ts b/lib/tools/status.ts index 05339c0..88aa31f 100644 --- a/lib/tools/status.ts +++ b/lib/tools/status.ts @@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js"; import { log as auditLog } from "../audit.js"; import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js"; import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js"; -import { DEFAULT_WORKFLOW } from "../workflow.js"; +import { loadWorkflow } from "../workflow.js"; export function createStatusTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -32,8 +32,8 @@ export function createStatusTool(api: OpenClawPluginApi) { const pluginConfig = getPluginConfig(api); const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel"; - // TODO: Load per-project workflow when supported - const workflow = DEFAULT_WORKFLOW; + // Load workspace-level workflow (per-project loaded inside map) + const workflow = await loadWorkflow(workspaceDir); const data = await readProjects(workspaceDir); const projectIds = groupId ? [groupId] : Object.keys(data.projects); @@ -52,28 +52,22 @@ export function createStatusTool(api: OpenClawPluginApi) { queueCounts[label] = issues.length; } + // Build dynamic workers summary + const workers: Record = {}; + for (const [role, worker] of Object.entries(project.workers)) { + workers[role] = { + active: worker.active, + issueId: worker.issueId, + level: worker.level, + startTime: worker.startTime, + }; + } + return { name: project.name, groupId: pid, roleExecution: project.roleExecution ?? "parallel", - 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, - }, - architect: { - active: project.architect.active, - issueId: project.architect.issueId, - level: project.architect.level, - startTime: project.architect.startTime, - }, + workers, queue: queueCounts, }; }), diff --git a/lib/tools/task-comment.ts b/lib/tools/task-comment.ts index 3e4873c..b341f3d 100644 --- a/lib/tools/task-comment.ts +++ b/lib/tools/task-comment.ts @@ -2,8 +2,8 @@ * task_comment — Add review comments or notes to an issue. * * Use cases: - * - QA worker adds review feedback without blocking pass/fail - * - DEV worker posts implementation notes + * - Tester worker adds review feedback without blocking pass/fail + * - Developer worker posts implementation notes * - Orchestrator adds summary comments */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; @@ -13,7 +13,7 @@ import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; /** Valid author roles for attribution */ -const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const; +const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const; type AuthorRole = (typeof AUTHOR_ROLES)[number]; export function createTaskCommentTool(api: OpenClawPluginApi) { @@ -23,15 +23,15 @@ export function createTaskCommentTool(api: OpenClawPluginApi) { description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change. Use cases: -- QA adds review feedback without blocking pass/fail -- DEV posts implementation notes or progress updates +- Tester adds review feedback without blocking pass/fail +- Developer posts implementation notes or progress updates - Orchestrator adds summary comments - Cross-referencing related issues or PRs Examples: - Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" } -- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "qa" } -- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "dev" }`, +- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "tester" } +- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "developer" }`, parameters: { type: "object", required: ["projectGroupId", "issueId", "body"], @@ -100,7 +100,7 @@ Examples: // --------------------------------------------------------------------------- const ROLE_EMOJI: Record = { - dev: "👨‍💻", - qa: "🔍", + developer: "👨‍💻", + tester: "🔍", orchestrator: "🎛️", }; diff --git a/lib/tools/task-create.ts b/lib/tools/task-create.ts index 88c9103..ec68adc 100644 --- a/lib/tools/task-create.ts +++ b/lib/tools/task-create.ts @@ -13,7 +13,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { log as auditLog } from "../audit.js"; -import { STATE_LABELS, type StateLabel } from "../providers/provider.js"; +import type { StateLabel } from "../providers/provider.js"; +import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; export function createTaskCreateTool(api: OpenClawPluginApi) { @@ -46,7 +47,7 @@ Examples: label: { type: "string", description: `State label. Defaults to "Planning" — only use "To Do" when the user explicitly asks to start work immediately.`, - enum: STATE_LABELS, + enum: getStateLabels(DEFAULT_WORKFLOW), }, assignees: { type: "array", diff --git a/lib/tools/task-update.ts b/lib/tools/task-update.ts index 6640c46..c3387d8 100644 --- a/lib/tools/task-update.ts +++ b/lib/tools/task-update.ts @@ -10,7 +10,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { log as auditLog } from "../audit.js"; -import { STATE_LABELS, type StateLabel } from "../providers/provider.js"; +import type { StateLabel } from "../providers/provider.js"; +import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; export function createTaskUpdateTool(api: OpenClawPluginApi) { @@ -42,8 +43,8 @@ Examples: }, state: { type: "string", - enum: STATE_LABELS, - description: `New state for the issue. One of: ${STATE_LABELS.join(", ")}`, + enum: getStateLabels(DEFAULT_WORKFLOW), + description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`, }, reason: { type: "string", diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index d38d4cd..8ca0daa 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -8,16 +8,17 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { getWorker, resolveRepoPath } from "../projects.js"; -import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js"; +import { executeCompletion, getRule } from "../services/pipeline.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js"; +import { loadWorkflow } from "../workflow.js"; export function createWorkFinishTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "work_finish", label: "Work Finish", - description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, + description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, parameters: { type: "object", required: ["role", "result", "projectGroupId"], @@ -31,7 +32,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { }, async execute(_id: string, params: Record) { - const role = params.role as "dev" | "qa" | "architect"; + const role = params.role as string; const result = params.result as string; const groupId = params.projectGroupId as string; const summary = params.summary as string | undefined; @@ -59,6 +60,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { const issue = await provider.getIssue(issueId); const pluginConfig = getPluginConfig(api); + const workflow = await loadWorkflow(workspaceDir, project.name); // Execute completion (pipeline service handles notification with runtime) const completion = await executeCompletion({ @@ -67,6 +69,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { channel: project.channel, pluginConfig, runtime: api.runtime, + workflow, }); const output: Record = { diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index e0e9aaa..df44cab 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -13,10 +13,9 @@ import { selectLevel } from "../model-selector.js"; import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; -import { isDevLevel } from "../tiers.js"; -import { getAllRoleIds } from "../roles/index.js"; +import { getAllRoleIds, isLevelForRole } from "../roles/index.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; -import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js"; +import { loadWorkflow, getActiveLabel } from "../workflow.js"; export function createWorkStartTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -36,7 +35,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { async execute(_id: string, params: Record) { const issueIdParam = params.issueId as number | undefined; - const roleParam = params.role as "dev" | "qa" | "architect" | undefined; + const roleParam = params.role as string | undefined; const groupId = params.projectGroupId as string; const levelParam = (params.level ?? params.tier) as string | undefined; const workspaceDir = requireWorkspaceDir(ctx); @@ -45,8 +44,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const { project } = await resolveProject(workspaceDir, groupId); const { provider } = await resolveProvider(project); - // TODO: Load per-project workflow when supported - const workflow = DEFAULT_WORKFLOW; + const workflow = await loadWorkflow(workspaceDir, project.name); // Find issue let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }; @@ -73,8 +71,11 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const worker = getWorker(project, role); if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`); if ((project.roleExecution ?? "parallel") === "sequential") { - const other = role === "dev" ? "qa" : "dev"; - if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`); + for (const [otherRole, otherWorker] of Object.entries(project.workers)) { + if (otherRole !== role && otherWorker.active) { + throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`); + } + } } // Get target label from workflow @@ -87,9 +88,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) { } else { 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"; } + if (!isLevelForRole(labelLevel, role)) { + // Label level belongs to a different role — use heuristic for this role + const s = selectLevel(issue.title, issue.description ?? "", role); + selectedLevel = s.level; levelReason = `${role} overrides other role's level "${labelLevel}"`; levelSource = "role-override"; + } else { + selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; + } } else { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; diff --git a/lib/workflow.ts b/lib/workflow.ts index 38da6d1..46882f7 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -9,16 +9,13 @@ * * All workflow behavior is derived from this config — no hardcoded state names. */ -import fs from "node:fs/promises"; -import path from "node:path"; - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type StateType = "queue" | "active" | "hold" | "terminal"; -/** @deprecated Use WorkerRole from lib/roles/ */ -export type Role = "dev" | "qa" | "architect"; +/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ +export type Role = string; export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue"; export type TransitionTarget = string | { @@ -64,7 +61,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, todo: { type: "queue", - role: "dev", + role: "developer", label: "To Do", color: "#428bca", priority: 1, @@ -72,7 +69,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, doing: { type: "active", - role: "dev", + role: "developer", label: "Doing", color: "#f0ad4e", on: { @@ -82,7 +79,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, toTest: { type: "queue", - role: "qa", + role: "tester", label: "To Test", color: "#5bc0de", priority: 2, @@ -90,7 +87,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, testing: { type: "active", - role: "qa", + role: "tester", label: "Testing", color: "#9b59b6", on: { @@ -102,7 +99,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, toImprove: { type: "queue", - role: "dev", + role: "developer", label: "To Improve", color: "#d9534f", priority: 3, @@ -146,38 +143,15 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { /** * Load workflow config for a project. - * Priority: project-specific → workspace default → built-in default + * Delegates to loadConfig() which handles the three-layer merge. */ export async function loadWorkflow( workspaceDir: string, - _groupId?: string, + projectName?: string, ): Promise { - // TODO: Support per-project overrides from projects.json when needed - // For now, try workspace-level config, fall back to default - - const workflowPath = path.join(workspaceDir, "projects", "workflow.json"); - try { - const content = await fs.readFile(workflowPath, "utf-8"); - const parsed = JSON.parse(content) as { workflow?: WorkflowConfig }; - if (parsed.workflow) { - return mergeWorkflow(DEFAULT_WORKFLOW, parsed.workflow); - } - } catch { - // No custom workflow, use default - } - - return DEFAULT_WORKFLOW; -} - -/** - * Merge custom workflow config over defaults. - * Custom states are merged, not replaced entirely. - */ -function mergeWorkflow(base: WorkflowConfig, custom: Partial): WorkflowConfig { - return { - initial: custom.initial ?? base.initial, - states: { ...base.states, ...custom.states }, - }; + const { loadConfig } = await import("./config/loader.js"); + const config = await loadConfig(workspaceDir, projectName); + return config.workflow; } // --------------------------------------------------------------------------- @@ -305,31 +279,30 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st // --------------------------------------------------------------------------- /** - * Map role:result to completion event name. + * Map completion result to workflow transition event name. + * Convention: "done" → COMPLETE, others → uppercase. */ -const RESULT_TO_EVENT: Record = { - "dev:done": "COMPLETE", - "dev:blocked": "BLOCKED", - "qa:pass": "PASS", - "qa:fail": "FAIL", - "qa:refine": "REFINE", - "qa:blocked": "BLOCKED", - "architect:done": "COMPLETE", - "architect:blocked": "BLOCKED", -}; +function resultToEvent(result: string): string { + if (result === "done") return "COMPLETE"; + return result.toUpperCase(); +} /** * Get completion rule for a role:result pair. + * Derives entirely from workflow transitions — no hardcoded role:result mapping. */ export function getCompletionRule( workflow: WorkflowConfig, role: Role, result: string, ): CompletionRule | null { - const event = RESULT_TO_EVENT[`${role}:${result}`]; - if (!event) return null; + const event = resultToEvent(result); + + let activeLabel: string; + try { + activeLabel = getActiveLabel(workflow, role); + } catch { return null; } - const activeLabel = getActiveLabel(workflow, role); const activeKey = findStateKeyByLabel(workflow, activeLabel); if (!activeKey) return null; @@ -356,6 +329,7 @@ export function getCompletionRule( /** * Get human-readable next state description. + * Derives from target state type — no hardcoded role names. */ export function getNextStateDescription( workflow: WorkflowConfig, @@ -365,15 +339,13 @@ export function getNextStateDescription( const rule = getCompletionRule(workflow, role, result); if (!rule) return ""; - // Find the target state to determine the description const targetState = findStateByLabel(workflow, rule.to); if (!targetState) return ""; if (targetState.type === "terminal") return "Done!"; if (targetState.type === "hold") return "awaiting human decision"; - if (targetState.type === "queue") { - if (targetState.role === "qa") return "QA queue"; - if (targetState.role === "dev") return "back to DEV"; + if (targetState.type === "queue" && targetState.role) { + return `${targetState.role.toUpperCase()} queue`; } return rule.to; @@ -381,19 +353,18 @@ export function getNextStateDescription( /** * Get emoji for a completion result. + * Keyed by result name — role-independent. */ -export function getCompletionEmoji(role: Role, result: string): string { - const map: Record = { - "dev:done": "✅", - "qa:pass": "🎉", - "qa:fail": "❌", - "qa:refine": "🤔", - "dev:blocked": "🚫", - "qa:blocked": "🚫", - "architect:done": "🏗️", - "architect:blocked": "🚫", - }; - return map[`${role}:${result}`] ?? "📋"; +const RESULT_EMOJI: Record = { + done: "✅", + pass: "🎉", + fail: "❌", + refine: "🤔", + blocked: "🚫", +}; + +export function getCompletionEmoji(_role: Role, result: string): string { + return RESULT_EMOJI[result] ?? "📋"; } // --------------------------------------------------------------------------- diff --git a/package-lock.json b/package-lock.json index a644331..54fe18b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@laurentenhoor/devclaw", - "version": "1.1.0", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@laurentenhoor/devclaw", - "version": "1.1.0", + "version": "1.2.2", "license": "MIT", + "dependencies": { + "yaml": "^2.8.2" + }, "devDependencies": { "@types/node": "^25.2.3", "typescript": "^5.9.3" @@ -8729,7 +8732,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index af6f63f..01a15d3 100644 --- a/package.json +++ b/package.json @@ -53,5 +53,8 @@ "devDependencies": { "@types/node": "^25.2.3", "typescript": "^5.9.3" + }, + "dependencies": { + "yaml": "^2.8.2" } }