From be8e0f4db1f65c8de0934bdf7792c640183a545d Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sat, 14 Feb 2026 17:15:54 +0800 Subject: [PATCH] refactor: Centralize role types and configuration (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates a single source of truth for all worker roles via lib/roles/. ## New: lib/roles/ - **registry.ts** — All role definitions (dev, qa, architect) with levels, models, emoji, completion results, session key patterns - **types.ts** — RoleConfig interface - **selectors.ts** — Query helpers: getRole(), getLevelsForRole(), resolveModel(), isValidResult(), roleForLevel(), etc. - **index.ts** — Barrel exports ## Migrated Files - **lib/tiers.ts** — Now delegates to registry (backward compat kept) - **lib/dispatch.ts** — Uses registry for emoji resolution - **lib/bootstrap-hook.ts** — Uses registry for session key pattern - **lib/services/tick.ts** — Uses registry for level detection - **lib/services/heartbeat.ts** — Uses registry for role iteration - **lib/tools/health.ts** — Uses registry for role iteration - **lib/tools/work-start.ts** — Uses registry for role enum - **lib/tools/work-finish.ts** — Uses registry for result validation - **lib/tools/project-register.ts** — Uses registry for level lists ## Key Benefits - Adding a new role = add entry to registry.ts (single file) - No more scattered role unions ("dev" | "qa" | "architect") - Type-safe role/level/result validation from registry - Session key pattern auto-generated from registry - All 64 tests passing (22 new registry tests + 42 existing) --- lib/bootstrap-hook.ts | 12 ++- lib/dispatch.ts | 4 +- lib/roles/index.ts | 36 +++++++ lib/roles/registry.test.ts | 190 ++++++++++++++++++++++++++++++++++ lib/roles/registry.ts | 75 ++++++++++++++ lib/roles/selectors.ts | 157 ++++++++++++++++++++++++++++ lib/roles/types.ts | 36 +++++++ lib/services/heartbeat.ts | 7 +- lib/services/tick.ts | 19 ++-- lib/tiers.ts | 128 ++++++++++------------- lib/tools/health.ts | 7 +- lib/tools/project-register.ts | 8 +- lib/tools/work-finish.ts | 15 ++- lib/tools/work-start.ts | 3 +- lib/workflow.ts | 1 + 15 files changed, 589 insertions(+), 109 deletions(-) create mode 100644 lib/roles/index.ts create mode 100644 lib/roles/registry.test.ts create mode 100644 lib/roles/registry.ts create mode 100644 lib/roles/selectors.ts create mode 100644 lib/roles/types.ts diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index a17e7aa..b82acd0 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -10,6 +10,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { getSessionKeyRolePattern } from "./roles/index.js"; /** * Parse a DevClaw subagent session key to extract project name and role. @@ -23,11 +24,12 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; */ export function parseDevClawSessionKey( sessionKey: string, -): { projectName: string; role: "dev" | "qa" | "architect" } | null { - // Match `:subagent:` prefix, then capture everything up to the last `-dev-`, `-qa-`, or `-architect-` - const match = sessionKey.match(/:subagent:(.+)-(dev|qa|architect)-[^-]+$/); +): { projectName: string; role: string } | null { + // Match `:subagent:` prefix, then capture project name and role (derived from registry) + const rolePattern = getSessionKeyRolePattern(); + const match = sessionKey.match(new RegExp(`:subagent:(.+)-(${rolePattern})-[^-]+$`)); if (!match) return null; - return { projectName: match[1], role: match[2] as "dev" | "qa" | "architect" }; + return { projectName: match[1], role: match[2] }; } /** @@ -40,7 +42,7 @@ export function parseDevClawSessionKey( export async function loadRoleInstructions( workspaceDir: string, projectName: string, - role: "dev" | "qa" | "architect", + role: string, ): Promise { const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); try { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 53f64f9..3409787 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -13,7 +13,7 @@ import { getSessionForLevel, getWorker, } from "./projects.js"; -import { resolveModel, levelEmoji } from "./tiers.js"; +import { resolveModel, getEmoji, getFallbackEmoji } from "./roles/index.js"; import { notify, getNotificationConfig } from "./notify.js"; export type DispatchOpts = { @@ -302,7 +302,7 @@ function buildAnnouncement( level: string, role: string, sessionAction: "spawn" | "send", issueId: number, issueTitle: string, issueUrl: string, ): string { - const emoji = levelEmoji(role as "dev" | "qa" | "architect", level) ?? (role === "qa" ? "🔍" : role === "architect" ? "🏗️" : "🔧"); + const emoji = getEmoji(role, level) ?? getFallbackEmoji(role); const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; } diff --git a/lib/roles/index.ts b/lib/roles/index.ts new file mode 100644 index 0000000..3db1f9d --- /dev/null +++ b/lib/roles/index.ts @@ -0,0 +1,36 @@ +/** + * roles/ — Centralized role configuration. + * + * Single source of truth for all worker roles in DevClaw. + * To add a new role, add an entry to registry.ts — everything else derives from it. + */ +export { ROLE_REGISTRY } from "./registry.js"; +export type { RoleConfig, RoleId } from "./types.js"; +export { + // Role IDs + type WorkerRole, + getAllRoleIds, + isValidRole, + getRole, + requireRole, + // Levels + getLevelsForRole, + getAllLevels, + isLevelForRole, + roleForLevel, + getDefaultLevel, + // Models + getDefaultModel, + getAllDefaultModels, + resolveModel, + // Emoji + getEmoji, + getFallbackEmoji, + // Completion + getCompletionResults, + isValidResult, + // Session keys + getSessionKeyRolePattern, + // Notifications + isNotificationEnabled, +} from "./selectors.js"; diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts new file mode 100644 index 0000000..fa5bdb7 --- /dev/null +++ b/lib/roles/registry.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for centralized role registry. + * Run with: npx tsx --test lib/roles/registry.test.ts + */ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + ROLE_REGISTRY, + getAllRoleIds, + isValidRole, + getRole, + requireRole, + getLevelsForRole, + getAllLevels, + isLevelForRole, + roleForLevel, + getDefaultLevel, + getDefaultModel, + getAllDefaultModels, + resolveModel, + getEmoji, + getFallbackEmoji, + getCompletionResults, + isValidResult, + getSessionKeyRolePattern, +} from "./index.js"; + +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("architect")); + }); + + it("should validate role IDs", () => { + assert.strictEqual(isValidRole("dev"), true); + assert.strictEqual(isValidRole("qa"), true); + assert.strictEqual(isValidRole("architect"), true); + assert.strictEqual(isValidRole("nonexistent"), false); + }); + + it("should get role config", () => { + const dev = getRole("dev"); + assert.ok(dev); + assert.strictEqual(dev.id, "dev"); + assert.strictEqual(dev.displayName, "DEV"); + }); + + it("should throw for unknown role in requireRole", () => { + assert.throws(() => requireRole("nonexistent"), /Unknown role/); + }); +}); + +describe("levels", () => { + it("should return levels for each role", () => { + assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "medior", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("qa")], ["reviewer", "tester"]); + assert.deepStrictEqual([...getLevelsForRole("architect")], ["opus", "sonnet"]); + }); + + it("should return empty for unknown role", () => { + assert.deepStrictEqual([...getLevelsForRole("nonexistent")], []); + }); + + it("should return all levels", () => { + const all = getAllLevels(); + assert.ok(all.includes("junior")); + assert.ok(all.includes("reviewer")); + assert.ok(all.includes("opus")); + }); + + it("should check level membership", () => { + assert.strictEqual(isLevelForRole("junior", "dev"), true); + assert.strictEqual(isLevelForRole("junior", "qa"), false); + assert.strictEqual(isLevelForRole("opus", "architect"), true); + }); + + it("should find role for level", () => { + assert.strictEqual(roleForLevel("junior"), "dev"); + assert.strictEqual(roleForLevel("reviewer"), "qa"); + assert.strictEqual(roleForLevel("opus"), "architect"); + assert.strictEqual(roleForLevel("nonexistent"), undefined); + }); + + it("should return default level", () => { + assert.strictEqual(getDefaultLevel("dev"), "medior"); + assert.strictEqual(getDefaultLevel("qa"), "reviewer"); + assert.strictEqual(getDefaultLevel("architect"), "sonnet"); + }); +}); + +describe("models", () => { + it("should return default models", () => { + assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5"); + assert.strictEqual(getDefaultModel("qa", "reviewer"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("architect", "opus"), "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.architect); + assert.strictEqual(models.dev.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"); + }); + + it("should fall back to default", () => { + assert.strictEqual(resolveModel("dev", "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"); + }); +}); + +describe("emoji", () => { + it("should return level emoji", () => { + assert.strictEqual(getEmoji("dev", "junior"), "⚡"); + assert.strictEqual(getEmoji("architect", "opus"), "🏗️"); + }); + + it("should return fallback emoji", () => { + assert.strictEqual(getFallbackEmoji("dev"), "🔧"); + assert.strictEqual(getFallbackEmoji("qa"), "🔍"); + assert.strictEqual(getFallbackEmoji("architect"), "🏗️"); + assert.strictEqual(getFallbackEmoji("nonexistent"), "📋"); + }); +}); + +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("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); + }); +}); + +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("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("architect")); + assert.ok(!regex.test("nonexistent")); + }); +}); + +describe("registry consistency", () => { + it("every role should have all required fields", () => { + for (const [id, config] of Object.entries(ROLE_REGISTRY)) { + assert.strictEqual(config.id, id, `${id}: id mismatch`); + assert.ok(config.displayName, `${id}: missing displayName`); + assert.ok(config.levels.length > 0, `${id}: empty levels`); + assert.ok(config.levels.includes(config.defaultLevel), `${id}: defaultLevel not in levels`); + assert.ok(config.completionResults.length > 0, `${id}: empty completionResults`); + assert.ok(config.fallbackEmoji, `${id}: missing fallbackEmoji`); + + // Every level should have a model + for (const level of config.levels) { + assert.ok(config.models[level], `${id}: missing model for level "${level}"`); + } + + // Every level should have an emoji + for (const level of config.levels) { + assert.ok(config.emoji[level], `${id}: missing emoji for level "${level}"`); + } + } + }); +}); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts new file mode 100644 index 0000000..1243428 --- /dev/null +++ b/lib/roles/registry.ts @@ -0,0 +1,75 @@ +/** + * roles/registry.ts — Single source of truth for all worker roles. + * + * Adding a new role? Just add an entry here. Everything else derives from this. + * + * Each role defines: + * - Identity (id, displayName) + * - Levels and models + * - Emoji for announcements + * - Valid completion results + * - Session key matching + * - Notification preferences + */ +import type { RoleConfig } from "./types.js"; + +export const ROLE_REGISTRY: Record = { + dev: { + id: "dev", + displayName: "DEV", + levels: ["junior", "medior", "senior"], + defaultLevel: "medior", + models: { + junior: "anthropic/claude-haiku-4-5", + medior: "anthropic/claude-sonnet-4-5", + senior: "anthropic/claude-opus-4-5", + }, + emoji: { + junior: "⚡", + medior: "🔧", + senior: "🧠", + }, + fallbackEmoji: "🔧", + completionResults: ["done", "blocked"], + sessionKeyPattern: "dev", + notifications: { onStart: true, onComplete: true }, + }, + + qa: { + id: "qa", + displayName: "QA", + levels: ["reviewer", "tester"], + defaultLevel: "reviewer", + models: { + reviewer: "anthropic/claude-sonnet-4-5", + tester: "anthropic/claude-haiku-4-5", + }, + emoji: { + reviewer: "🔍", + tester: "👀", + }, + fallbackEmoji: "🔍", + completionResults: ["pass", "fail", "refine", "blocked"], + sessionKeyPattern: "qa", + notifications: { onStart: true, onComplete: true }, + }, + + architect: { + id: "architect", + displayName: "ARCHITECT", + levels: ["opus", "sonnet"], + defaultLevel: "sonnet", + models: { + opus: "anthropic/claude-opus-4-5", + sonnet: "anthropic/claude-sonnet-4-5", + }, + emoji: { + opus: "🏗️", + sonnet: "📐", + }, + fallbackEmoji: "🏗️", + completionResults: ["done", "blocked"], + sessionKeyPattern: "architect", + notifications: { onStart: true, onComplete: true }, + }, +}; diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts new file mode 100644 index 0000000..6fd88c1 --- /dev/null +++ b/lib/roles/selectors.ts @@ -0,0 +1,157 @@ +/** + * roles/selectors.ts — Query helpers for the role registry. + * + * All role-related lookups go through these functions. + * No other file should access ROLE_REGISTRY directly for role logic. + */ +import { ROLE_REGISTRY } from "./registry.js"; +import type { RoleConfig } from "./types.js"; + +// --------------------------------------------------------------------------- +// Role IDs +// --------------------------------------------------------------------------- + +/** All registered role IDs. */ +export function getAllRoleIds(): string[] { + return Object.keys(ROLE_REGISTRY); +} + +/** The role ID union type, derived from registry. */ +export type WorkerRole = keyof typeof ROLE_REGISTRY; + +/** Check if a string is a valid role ID. */ +export function isValidRole(role: string): boolean { + return role in ROLE_REGISTRY; +} + +/** Get role config by ID. Returns undefined if not found. */ +export function getRole(role: string): RoleConfig | undefined { + return ROLE_REGISTRY[role]; +} + +/** Get role config by ID. Throws if not found. */ +export function requireRole(role: string): RoleConfig { + const config = ROLE_REGISTRY[role]; + if (!config) throw new Error(`Unknown role: "${role}". Valid roles: ${getAllRoleIds().join(", ")}`); + return config; +} + +// --------------------------------------------------------------------------- +// Levels +// --------------------------------------------------------------------------- + +/** Get valid levels for a role. */ +export function getLevelsForRole(role: string): readonly string[] { + return getRole(role)?.levels ?? []; +} + +/** Get all levels across all roles. */ +export function getAllLevels(): string[] { + return Object.values(ROLE_REGISTRY).flatMap(r => [...r.levels]); +} + +/** Check if a level belongs to a specific role. */ +export function isLevelForRole(level: string, role: string): boolean { + return getLevelsForRole(role).includes(level); +} + +/** Determine which role a level belongs to. Returns undefined if no match. */ +export function roleForLevel(level: string): string | undefined { + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + if (config.levels.includes(level)) return roleId; + } + return undefined; +} + +/** Get the default level for a role. */ +export function getDefaultLevel(role: string): string | undefined { + return getRole(role)?.defaultLevel; +} + +// --------------------------------------------------------------------------- +// Models +// --------------------------------------------------------------------------- + +/** Get default model for a role + level. */ +export function getDefaultModel(role: string, level: string): string | undefined { + return getRole(role)?.models[level]; +} + +/** Get all default models, nested by role (for config schema). */ +export function getAllDefaultModels(): Record> { + const result: Record> = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + result[roleId] = { ...config.models }; + } + return result; +} + +/** + * Resolve a level to a full model ID. + * + * Resolution order: + * 1. Plugin config `models..` + * 2. Registry default model + * 3. Passthrough (treat level as raw model ID) + */ +export function resolveModel( + role: string, + level: string, + pluginConfig?: Record, +): string { + const models = (pluginConfig as { models?: Record })?.models; + if (models && typeof models === "object") { + const roleModels = models[role] as Record | undefined; + if (roleModels?.[level]) return roleModels[level]; + } + return getDefaultModel(role, level) ?? level; +} + +// --------------------------------------------------------------------------- +// Emoji +// --------------------------------------------------------------------------- + +/** Get emoji for a role + level. */ +export function getEmoji(role: string, level: string): string | undefined { + return getRole(role)?.emoji[level]; +} + +/** Get fallback emoji for a role. */ +export function getFallbackEmoji(role: string): string { + return getRole(role)?.fallbackEmoji ?? "📋"; +} + +// --------------------------------------------------------------------------- +// Completion +// --------------------------------------------------------------------------- + +/** Get valid completion results for a role. */ +export function getCompletionResults(role: string): readonly string[] { + return getRole(role)?.completionResults ?? []; +} + +/** Check if a result is valid for a role. */ +export function isValidResult(role: string, result: string): boolean { + return getCompletionResults(role).includes(result); +} + +// --------------------------------------------------------------------------- +// Session keys +// --------------------------------------------------------------------------- + +/** Build regex pattern that matches any registered role in session keys. */ +export function getSessionKeyRolePattern(): string { + return Object.values(ROLE_REGISTRY).map(r => r.sessionKeyPattern).join("|"); +} + +// --------------------------------------------------------------------------- +// Notifications +// --------------------------------------------------------------------------- + +/** Check if a role has a specific notification enabled. */ +export function isNotificationEnabled( + role: string, + event: "onStart" | "onComplete", +): boolean { + return getRole(role)?.notifications[event] ?? true; +} diff --git a/lib/roles/types.ts b/lib/roles/types.ts new file mode 100644 index 0000000..6a2f4c2 --- /dev/null +++ b/lib/roles/types.ts @@ -0,0 +1,36 @@ +/** + * roles/types.ts — Type definitions for the role registry. + * + * RoleConfig is the single interface describing everything about a role. + * All role-related behavior should be derived from this config. + */ + +/** Configuration for a single worker role. */ +export type RoleConfig = { + /** Unique role identifier (e.g., "dev", "qa", "architect"). */ + id: string; + /** Human-readable display name. */ + displayName: string; + /** Valid levels for this role. */ + levels: readonly string[]; + /** Default level when none specified. */ + defaultLevel: string; + /** Default model per level. */ + models: Record; + /** Emoji per level (used in announcements). */ + emoji: Record; + /** Fallback emoji when level-specific emoji not found. */ + fallbackEmoji: string; + /** Valid completion results for this role. */ + completionResults: readonly string[]; + /** Regex pattern fragment for session key matching (e.g., "dev|qa|architect"). */ + sessionKeyPattern: string; + /** Notification config per event type. */ + notifications: { + onStart: boolean; + onComplete: boolean; + }; +}; + +/** A role ID string (typed from registry keys). */ +export type RoleId = string; diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index 5505021..17302b0 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -18,6 +18,7 @@ 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 @@ -306,13 +307,13 @@ async function performHealthPass( const { provider } = await createProvider({ repo: project.repo }); let fixedCount = 0; - for (const role of ["dev", "qa", "architect"] as const) { + for (const role of getAllRoleIds()) { // Check worker health (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, groupId, project, - role, + role: role as any, sessions, autoFix: true, provider, @@ -324,7 +325,7 @@ async function performHealthPass( workspaceDir, groupId, project, - role, + role: role as any, autoFix: true, provider, }); diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 29bb853..077688a 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -11,7 +11,7 @@ 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 { DEV_LEVELS, QA_LEVELS, ARCHITECT_LEVELS, isDevLevel, levelRole } from "../tiers.js"; +import { getAllRoleIds, getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js"; import { DEFAULT_WORKFLOW, getQueueLabels, @@ -48,19 +48,18 @@ export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW); export function detectLevelFromLabels(labels: string[]): string | null { const lower = labels.map((l) => l.toLowerCase()); - // Match role.level labels (e.g., "dev.senior", "qa.reviewer") + // Match role.level labels (e.g., "dev.senior", "qa.reviewer", "architect.opus") for (const l of lower) { const dot = l.indexOf("."); if (dot === -1) continue; const role = l.slice(0, dot); const level = l.slice(dot + 1); - if (role === "dev" && (DEV_LEVELS as readonly string[]).includes(level)) return level; - if (role === "qa" && (QA_LEVELS as readonly string[]).includes(level)) return level; - if (role === "architect" && (ARCHITECT_LEVELS as readonly string[]).includes(level)) return level; + const roleLevels = getLevelsForRole(role); + if (roleLevels.includes(level)) return level; } // Fallback: plain level name - const all = [...DEV_LEVELS, ...QA_LEVELS, ...ARCHITECT_LEVELS] as readonly string[]; + const all = getAllLevels(); return all.find((l) => lower.includes(l)) ?? null; } @@ -165,7 +164,7 @@ export async function projectTick(opts: { const provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider; const roleExecution = project.roleExecution ?? "parallel"; - const roles: Role[] = targetRole ? [targetRole] : ["dev", "qa", "architect"]; + const roles: Role[] = targetRole ? [targetRole] : getAllRoleIds() as Role[]; const pickups: TickAction[] = []; const skipped: TickResult["skipped"] = []; @@ -187,8 +186,8 @@ export async function projectTick(opts: { continue; } // Check sequential role execution: any other role must be inactive - const otherRoles = (["dev", "qa", "architect"] as const).filter(r => r !== role); - if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r).active)) { + const otherRoles = getAllRoleIds().filter(r => r !== role); + if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r as any).active)) { skipped.push({ role, reason: "Sequential: other role active" }); continue; } @@ -247,7 +246,7 @@ export async function projectTick(opts: { function resolveLevelForIssue(issue: Issue, role: Role): string { const labelLevel = detectLevelFromLabels(issue.labels); if (labelLevel) { - const labelRole = levelRole(labelLevel); + const labelRole = roleForLevel(labelLevel); // If label level belongs to a different role, use heuristic for correct role if (labelRole && labelRole !== role) return selectLevel(issue.title, issue.description ?? "", role).level; return labelLevel; diff --git a/lib/tiers.ts b/lib/tiers.ts index 98e4d76..ae73b90 100644 --- a/lib/tiers.ts +++ b/lib/tiers.ts @@ -1,108 +1,90 @@ /** - * Developer level definitions and model resolution. + * 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", "senior", "reviewer", etc. - * Role context (dev/qa) is always provided by the caller or parent structure. + * 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"; -export const DEV_LEVELS = ["junior", "medior", "senior"] as const; -export const QA_LEVELS = ["reviewer", "tester"] as const; -export const ARCHITECT_LEVELS = ["opus", "sonnet"] as const; +// Re-export WorkerRole from the registry +export type { WorkerRole }; -export type DevLevel = (typeof DEV_LEVELS)[number]; -export type QaLevel = (typeof QA_LEVELS)[number]; -export type ArchitectLevel = (typeof ARCHITECT_LEVELS)[number]; -export type Level = DevLevel | QaLevel | ArchitectLevel; +// --------------------------------------------------------------------------- +// Level constants — derived from registry +// --------------------------------------------------------------------------- -/** Default models, nested by role. */ -export const DEFAULT_MODELS = { - dev: { - junior: "anthropic/claude-haiku-4-5", - medior: "anthropic/claude-sonnet-4-5", - senior: "anthropic/claude-opus-4-5", - }, - qa: { - reviewer: "anthropic/claude-sonnet-4-5", - tester: "anthropic/claude-haiku-4-5", - }, - architect: { - opus: "anthropic/claude-opus-4-5", - sonnet: "anthropic/claude-sonnet-4-5", - }, -}; +/** @deprecated Use roles/selectors.getAllDefaultModels() */ +export const DEFAULT_MODELS = getAllDefaultModels(); -/** Emoji used in announcements, nested by role. */ -export const LEVEL_EMOJI = { - dev: { - junior: "⚡", - medior: "🔧", - senior: "🧠", - }, - qa: { - reviewer: "🔍", - tester: "👀", - }, - architect: { - opus: "🏗️", - sonnet: "📐", - }, -}; +/** @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): value is DevLevel { - return (DEV_LEVELS as readonly string[]).includes(value); +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): value is QaLevel { - return (QA_LEVELS as readonly string[]).includes(value); +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): value is ArchitectLevel { - return (ARCHITECT_LEVELS as readonly string[]).includes(value); +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 { - if (isDevLevel(level)) return "dev"; - if (isQaLevel(level)) return "qa"; - if (isArchitectLevel(level)) return "architect"; - return undefined; + return roleForLevel(level) as WorkerRole | undefined; } -/** All valid worker roles. */ -export type WorkerRole = "dev" | "qa" | "architect"; +// --------------------------------------------------------------------------- +// Model + emoji — delegate to registry +// --------------------------------------------------------------------------- -/** Get the default model for a role + level. */ +/** @deprecated Use roles/selectors.getDefaultModel() */ export function defaultModel(role: WorkerRole, level: string): string | undefined { - return (DEFAULT_MODELS[role] as Record)[level]; + return getDefaultModel(role, level); } -/** Get the emoji for a role + level. */ +/** @deprecated Use roles/selectors.getEmoji() */ export function levelEmoji(role: WorkerRole, level: string): string | undefined { - return (LEVEL_EMOJI[role] as Record)[level]; + return getEmoji(role, level); } -/** - * Resolve a level to a full model ID. - * - * Resolution order: - * 1. Plugin config `models..` - * 2. DEFAULT_MODELS[role][level] - * 3. Passthrough (treat as raw model ID) - */ +/** @deprecated Use roles/selectors.resolveModel() */ export function resolveModel( role: WorkerRole, level: string, pluginConfig?: Record, ): string { - const models = (pluginConfig as { models?: Record })?.models; - - if (models && typeof models === "object") { - const roleModels = models[role] as Record | undefined; - if (roleModels?.[level]) return roleModels[level]; - } - - return defaultModel(role, level) ?? level; + return registryResolveModel(role, level, pluginConfig); } diff --git a/lib/tools/health.ts b/lib/tools/health.ts index 4d053a8..d3bec26 100644 --- a/lib/tools/health.ts +++ b/lib/tools/health.ts @@ -18,6 +18,7 @@ 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) => ({ @@ -51,13 +52,13 @@ export function createHealthTool() { if (!project) continue; const { provider } = await resolveProvider(project); - for (const role of ["dev", "qa", "architect"] as const) { + for (const role of getAllRoleIds()) { // Worker health check (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, groupId: pid, project, - role, + role: role as any, sessions, autoFix: fix, provider, @@ -69,7 +70,7 @@ export function createHealthTool() { workspaceDir, groupId: pid, project, - role, + role: role as any, autoFix: fix, provider, }); diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index ff4efac..f3135dd 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -14,7 +14,7 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js"; import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; -import { DEV_LEVELS, QA_LEVELS, ARCHITECT_LEVELS } from "../tiers.js"; +import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js"; /** @@ -162,9 +162,9 @@ export function createProjectRegisterTool() { deployBranch, channel, roleExecution, - dev: emptyWorkerState([...DEV_LEVELS]), - qa: emptyWorkerState([...QA_LEVELS]), - architect: emptyWorkerState([...ARCHITECT_LEVELS]), + dev: emptyWorkerState([...getLevelsForRole("dev")]), + qa: emptyWorkerState([...getLevelsForRole("qa")]), + architect: emptyWorkerState([...getLevelsForRole("architect")]), }; await writeProjects(workspaceDir, data); diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index 5384bb5..d38d4cd 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -11,6 +11,7 @@ import { getWorker, resolveRepoPath } from "../projects.js"; import { executeCompletion, getRule, NEXT_STATE } 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"; export function createWorkFinishTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -21,7 +22,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { type: "object", required: ["role", "result", "projectGroupId"], properties: { - role: { type: "string", enum: ["dev", "qa", "architect"], description: "Worker role" }, + role: { type: "string", enum: getAllRoleIds(), description: "Worker role" }, result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" }, projectGroupId: { type: "string", description: "Project group ID" }, summary: { type: "string", description: "Brief summary" }, @@ -37,13 +38,11 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { const prUrl = params.prUrl as string | undefined; const workspaceDir = requireWorkspaceDir(ctx); - // Validate role:result - if (role === "dev" && result !== "done" && result !== "blocked") - throw new Error(`DEV can only complete with "done" or "blocked", got "${result}"`); - if (role === "architect" && result !== "done" && result !== "blocked") - throw new Error(`ARCHITECT can only complete with "done" or "blocked", got "${result}"`); - if (role === "qa" && result === "done") - throw new Error(`QA cannot use "done". Use "pass", "fail", "refine", or "blocked".`); + // Validate role:result using registry + if (!isValidResult(role, result)) { + const valid = getCompletionResults(role); + throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`); + } if (!getRule(role, result)) throw new Error(`Invalid completion: ${role}:${result}`); diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 604eb9a..8c2e207 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -14,6 +14,7 @@ import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; import { isDevLevel } from "../tiers.js"; +import { getAllRoleIds } from "../roles/index.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js"; @@ -28,7 +29,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { properties: { projectGroupId: { type: "string", description: "Project group ID." }, issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." }, - role: { type: "string", enum: ["dev", "qa", "architect"], description: "Worker role. Auto-detected from label if omitted." }, + role: { type: "string", enum: getAllRoleIds(), description: "Worker role. Auto-detected from label if omitted." }, level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." }, }, }, diff --git a/lib/workflow.ts b/lib/workflow.ts index c5908ec..38da6d1 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -17,6 +17,7 @@ import path from "node:path"; // --------------------------------------------------------------------------- export type StateType = "queue" | "active" | "hold" | "terminal"; +/** @deprecated Use WorkerRole from lib/roles/ */ export type Role = "dev" | "qa" | "architect"; export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";