From a85f4fd33e88b3b18cefe73954d0aa0e51385d17 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 18:46:00 +0800 Subject: [PATCH] refactor: implement dynamic role and level handling with migration support --- index.ts | 55 +++++------- lib/migrations.ts | 141 ++++++++++++++++++++++++++++++ lib/projects.ts | 66 +------------- lib/roles/registry.test.ts | 4 +- lib/roles/registry.ts | 6 +- lib/roles/selectors.ts | 36 ++------ lib/setup/llm-model-selector.ts | 119 +++++++++++++------------ lib/setup/smart-model-selector.ts | 58 ++++++------ lib/tools/design-task.test.ts | 2 +- lib/tools/task-comment.ts | 18 ++-- 10 files changed, 278 insertions(+), 227 deletions(-) create mode 100644 lib/migrations.ts diff --git a/index.ts b/index.ts index 32f405e..d8eebb9 100644 --- a/index.ts +++ b/index.ts @@ -15,6 +15,28 @@ import { registerCli } from "./lib/cli.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js"; import { registerBootstrapHook } from "./lib/bootstrap-hook.js"; import { initRunCommand } from "./lib/run-command.js"; +import { ROLE_REGISTRY } from "./lib/roles/index.js"; + +/** Build the models config schema dynamically from the role registry. */ +function buildModelsSchema(): Record { + const properties: Record = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + const levelProps: Record = {}; + for (const level of config.levels) { + levelProps[level] = { type: "string" }; + } + properties[roleId] = { + type: "object", + description: `${config.displayName} level models`, + properties: levelProps, + }; + } + return { + type: "object", + description: "Model mapping per role and level", + properties, + }; +} const plugin = { id: "devclaw", @@ -24,38 +46,7 @@ const plugin = { configSchema: { type: "object", properties: { - models: { - type: "object", - description: "Model mapping per role and level", - properties: { - dev: { - type: "object", - description: "Developer level models", - properties: { - junior: { type: "string" }, - mid: { type: "string" }, - senior: { type: "string" }, - }, - }, - qa: { - type: "object", - description: "QA level models", - properties: { - junior: { type: "string" }, - mid: { type: "string" }, - senior: { type: "string" }, - }, - }, - architect: { - type: "object", - description: "Architect level models", - properties: { - junior: { type: "string" }, - senior: { type: "string" }, - }, - }, - }, - }, + models: buildModelsSchema(), projectExecution: { type: "string", enum: ["parallel", "sequential"], diff --git a/lib/migrations.ts b/lib/migrations.ts new file mode 100644 index 0000000..803bbbd --- /dev/null +++ b/lib/migrations.ts @@ -0,0 +1,141 @@ +/** + * migrations.ts — Backward-compatibility aliases and migration logic. + * + * Contains all role/level renaming aliases and projects.json format migration. + * This file can be removed once all users have migrated to the new format. + * + * Migrations handled: + * - Role renames: dev → developer, qa → tester + * - Level renames: mid → medior, reviewer → medior, tester → junior, opus → senior, sonnet → junior + * - projects.json format: old hardcoded dev/qa/architect fields → workers map + * - projects.json format: old role keys in workers map → canonical role keys + */ + +import type { WorkerState, Project } from "./projects.js"; + +// --------------------------------------------------------------------------- +// Role aliases — old role IDs → canonical IDs +// --------------------------------------------------------------------------- + +/** Maps old role IDs to canonical IDs. */ +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 — old level names → canonical names, per role +// --------------------------------------------------------------------------- + +/** Maps old level names to canonical names, per role. */ +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" }, +}; + +/** Resolve a level name, applying aliases for backward compatibility. */ +export function canonicalLevel(role: string, level: string): string { + return LEVEL_ALIASES[role]?.[level] ?? level; +} + +// --------------------------------------------------------------------------- +// projects.json migration helpers +// --------------------------------------------------------------------------- + +function migrateLevel(level: string | null, role: string): string | null { + if (!level) return null; + return LEVEL_ALIASES[role]?.[level] ?? level; +} + +function migrateSessions( + sessions: Record, + role: string, +): Record { + const aliases = LEVEL_ALIASES[role]; + if (!aliases) return sessions; + + const migrated: Record = {}; + for (const [key, value] of Object.entries(sessions)) { + const newKey = aliases[key] ?? key; + migrated[newKey] = value; + } + return migrated; +} + +function parseWorkerState(worker: Record, role: string): WorkerState { + const level = (worker.level ?? worker.tier ?? null) as string | null; + const sessions = (worker.sessions as Record) ?? {}; + return { + active: worker.active as boolean, + issueId: worker.issueId as string | null, + startTime: worker.startTime as string | null, + level: migrateLevel(level, role), + sessions: migrateSessions(sessions, role), + }; +} + +/** Empty worker state with null sessions for given levels. */ +function emptyWorkerState(levels: string[]): WorkerState { + const sessions: Record = {}; + for (const l of levels) { + sessions[l] = null; + } + return { + active: false, + issueId: null, + startTime: null, + level: null, + sessions, + }; +} + +/** + * Migrate a raw project object from old format to current format. + * + * Handles: + * 1. Old format: hardcoded dev/qa/architect fields → workers map + * 2. Old role keys in workers map (dev → developer, qa → tester) + * 3. Old level names in worker state + * 4. Missing channel field defaults to "telegram" + */ +export function migrateProject(project: Project): void { + const raw = project as unknown as Record; + + if (!raw.workers && (raw.dev || raw.qa || raw.architect)) { + // Old format: hardcoded dev/qa/architect fields → workers map + 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"; + } +} diff --git a/lib/projects.ts b/lib/projects.ts index f8ba3d8..5eb99d9 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -5,7 +5,8 @@ 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"; +import { migrateProject } from "./migrations.js"; + export type WorkerState = { active: boolean; issueId: string | null; @@ -37,38 +38,6 @@ export type ProjectsData = { projects: Record; }; -function migrateLevel(level: string | null, role: string): string | null { - if (!level) return null; - return LEVEL_ALIASES[role]?.[level] ?? level; -} - -function migrateSessions( - sessions: Record, - role: string, -): Record { - const aliases = LEVEL_ALIASES[role]; - if (!aliases) return sessions; - - const migrated: Record = {}; - for (const [key, value] of Object.entries(sessions)) { - const newKey = aliases[key] ?? key; - migrated[newKey] = value; - } - return migrated; -} - -function parseWorkerState(worker: Record, role: string): WorkerState { - const level = (worker.level ?? worker.tier ?? null) as string | null; - const sessions = (worker.sessions as Record) ?? {}; - return { - active: worker.active as boolean, - issueId: worker.issueId as string | null, - startTime: worker.startTime as string | null, - level: migrateLevel(level, role), - sessions: migrateSessions(sessions, role), - }; -} - /** * Create a blank WorkerState with null sessions for given level names. */ @@ -105,36 +74,7 @@ export async function readProjects(workspaceDir: string): Promise const data = JSON.parse(raw) as ProjectsData; for (const project of Object.values(data.projects)) { - // 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"; - } + migrateProject(project); } return data; diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index 13d427e..b1cd9c9 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -129,7 +129,7 @@ describe("models", () => { 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"); + assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6"); }); it("should return all default models", () => { @@ -150,7 +150,7 @@ describe("models", () => { }); it("should pass through unknown level as model ID", () => { - assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); + assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6"); }); it("should resolve old config keys via aliases", () => { diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index 1c1a9d8..cd1fbee 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -22,7 +22,7 @@ export const ROLE_REGISTRY: Record = { models: { junior: "anthropic/claude-haiku-4-5", medior: "anthropic/claude-sonnet-4-5", - senior: "anthropic/claude-opus-4-5", + senior: "anthropic/claude-opus-4-6", }, emoji: { junior: "⚡", @@ -43,7 +43,7 @@ export const ROLE_REGISTRY: Record = { models: { junior: "anthropic/claude-haiku-4-5", medior: "anthropic/claude-sonnet-4-5", - senior: "anthropic/claude-opus-4-5", + senior: "anthropic/claude-opus-4-6", }, emoji: { junior: "⚡", @@ -63,7 +63,7 @@ export const ROLE_REGISTRY: Record = { defaultLevel: "junior", models: { junior: "anthropic/claude-sonnet-4-5", - senior: "anthropic/claude-opus-4-5", + senior: "anthropic/claude-opus-4-6", }, emoji: { junior: "📐", diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index 7a5092d..aa6191b 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -7,6 +7,7 @@ import { ROLE_REGISTRY } from "./registry.js"; import type { RoleConfig } from "./types.js"; import type { ResolvedRoleConfig } from "../config/types.js"; +import { ROLE_ALIASES as _ROLE_ALIASES, canonicalLevel as _canonicalLevel } from "../migrations.js"; // --------------------------------------------------------------------------- // Role IDs @@ -38,37 +39,10 @@ export function requireRole(role: string): RoleConfig { } // --------------------------------------------------------------------------- -// Role aliases — maps old role IDs to new canonical IDs +// Migration aliases — re-exported from lib/migrations.ts for backward compat // --------------------------------------------------------------------------- -/** 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 -// --------------------------------------------------------------------------- - -/** 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" }, -}; - -/** Resolve a level name, applying aliases for backward compatibility. */ -export function canonicalLevel(role: string, level: string): string { - return LEVEL_ALIASES[role]?.[level] ?? level; -} +export { ROLE_ALIASES, canonicalRole, LEVEL_ALIASES, canonicalLevel } from "../migrations.js"; // --------------------------------------------------------------------------- // Levels @@ -135,13 +109,13 @@ export function resolveModel( pluginConfig?: Record, resolvedRole?: ResolvedRoleConfig, ): string { - const canonical = canonicalLevel(role, level); + 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") { // 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; + 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]; } diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index 51f971c..ed648c8 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -4,23 +4,63 @@ * Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles. */ import { runCommand } from "../run-command.js"; +import { ROLE_REGISTRY } from "../roles/index.js"; +import type { ModelAssignment } from "./smart-model-selector.js"; -export type ModelAssignment = { - developer: { - junior: string; - medior: string; - senior: string; - }; - tester: { - junior: string; - medior: string; - senior: string; - }; - architect: { - junior: string; - senior: string; - }; -}; +/** + * Build a ModelAssignment where every role/level maps to the same model. + */ +function singleModelAssignment(model: string): ModelAssignment { + const result: ModelAssignment = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + result[roleId] = {}; + for (const level of config.levels) { + result[roleId][level] = model; + } + } + return result; +} + +/** + * Build the JSON format example for the LLM prompt, derived from registry. + */ +function buildJsonExample(): string { + const obj: Record> = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + obj[roleId] = {}; + for (const level of config.levels) { + obj[roleId][level] = "provider/model-name"; + } + } + return JSON.stringify(obj, null, 2); +} + +/** + * Validate that a parsed assignment has all required roles and levels. + */ +function validateAssignment(assignment: Record, fallbackModel: string): ModelAssignment | null { + const result: ModelAssignment = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + const roleData = assignment[roleId] as Record | undefined; + if (!roleData) { + // Backfill missing roles from the first available role or fallback + result[roleId] = {}; + for (const level of config.levels) { + result[roleId][level] = fallbackModel; + } + continue; + } + result[roleId] = {}; + for (const level of config.levels) { + if (!roleData[level]) { + console.error(`Missing ${roleId}.${level} in LLM assignment`); + return null; + } + result[roleId][level] = roleData[level]; + } + } + return result; +} /** * Use an LLM to intelligently select and assign models to DevClaw roles. @@ -35,16 +75,12 @@ export async function selectModelsWithLLM( // If only one model, assign it to all roles if (availableModels.length === 1) { - const model = availableModels[0].model; - return { - developer: { junior: model, medior: model, senior: model }, - tester: { junior: model, medior: model, senior: model }, - architect: { junior: model, senior: model }, - }; + return singleModelAssignment(availableModels[0].model); } // Create a prompt for the LLM const modelList = availableModels.map((m) => m.model).join("\n"); + const jsonExample = buildJsonExample(); const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities. @@ -65,22 +101,7 @@ Rules: 6. Stable versions (no date) > snapshot versions (with date like 20250514) Return ONLY a JSON object in this exact format (no markdown, no explanation): -{ - "developer": { - "junior": "provider/model-name", - "medior": "provider/model-name", - "senior": "provider/model-name" - }, - "tester": { - "junior": "provider/model-name", - "medior": "provider/model-name", - "senior": "provider/model-name" - }, - "architect": { - "junior": "provider/model-name", - "senior": "provider/model-name" - } -}`; +${jsonExample}`; try { const sessionId = sessionKey ?? "devclaw-model-selection"; @@ -127,28 +148,14 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): // Log what we got for debugging console.log("LLM returned:", JSON.stringify(assignment, null, 2)); - // Validate the structure - // Backfill architect if LLM didn't return it (graceful upgrade) - if (!assignment.architect) { - assignment.architect = { - senior: assignment.developer?.senior ?? availableModels[0].model, - junior: assignment.developer?.medior ?? availableModels[0].model, - }; - } - - if ( - !assignment.developer?.junior || - !assignment.developer?.medior || - !assignment.developer?.senior || - !assignment.tester?.junior || - !assignment.tester?.medior || - !assignment.tester?.senior - ) { + // Validate and backfill + const validated = validateAssignment(assignment, availableModels[0].model); + if (!validated) { console.error("Invalid assignment structure. Got:", assignment); throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`); } - return assignment as ModelAssignment; + return validated; } catch (err) { console.error("LLM model selection failed:", (err as Error).message); return null; diff --git a/lib/setup/smart-model-selector.ts b/lib/setup/smart-model-selector.ts index 76b9ea5..abb0ff4 100644 --- a/lib/setup/smart-model-selector.ts +++ b/lib/setup/smart-model-selector.ts @@ -3,23 +3,25 @@ * * Uses an LLM to intelligently analyze and assign models to DevClaw roles. */ +import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; +import { ROLE_REGISTRY } from "../roles/index.js"; -export type ModelAssignment = { - developer: { - junior: string; - medior: string; - senior: string; - }; - tester: { - junior: string; - medior: string; - senior: string; - }; - architect: { - junior: string; - senior: string; - }; -}; +/** Model assignment: role → level → model ID. Derived from registry structure. */ +export type ModelAssignment = Record>; + +/** + * Build a ModelAssignment where every role/level maps to the same model. + */ +function singleModelAssignment(model: string): ModelAssignment { + const result: ModelAssignment = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + result[roleId] = {}; + for (const level of config.levels) { + result[roleId][level] = model; + } + } + return result; +} /** * Intelligently assign available models to DevClaw roles using an LLM. @@ -42,12 +44,7 @@ export async function assignModels( // If only one model, use it for everything if (authenticated.length === 1) { - const model = authenticated[0].model; - return { - developer: { junior: model, medior: model, senior: model }, - tester: { junior: model, medior: model, senior: model }, - architect: { junior: model, senior: model }, - }; + return singleModelAssignment(authenticated[0].model); } // Multiple models: use LLM-based selection @@ -68,15 +65,16 @@ export function formatAssignment(assignment: ModelAssignment): string { const lines = [ "| 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)} |`, ]; + for (const roleId of getAllRoleIds()) { + const roleModels = assignment[roleId]; + if (!roleModels) continue; + const displayName = ROLE_REGISTRY[roleId]?.displayName ?? roleId.toUpperCase(); + for (const level of getLevelsForRole(roleId)) { + const model = roleModels[level] ?? ""; + lines.push(`| ${displayName.padEnd(9)} | ${level.padEnd(8)} | ${model.padEnd(24)} |`); + } + } return lines.join("\n"); } diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index 258c06e..e153634 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -27,7 +27,7 @@ describe("architect tiers", () => { }); it("should resolve default architect models", () => { - assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); + assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6"); assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); diff --git a/lib/tools/task-comment.ts b/lib/tools/task-comment.ts index b341f3d..be7baac 100644 --- a/lib/tools/task-comment.ts +++ b/lib/tools/task-comment.ts @@ -11,10 +11,11 @@ import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; +import { getAllRoleIds, getFallbackEmoji } from "../roles/index.js"; -/** Valid author roles for attribution */ -const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const; -type AuthorRole = (typeof AUTHOR_ROLES)[number]; +/** Valid author roles for attribution — all registry roles + orchestrator */ +const AUTHOR_ROLES = [...getAllRoleIds(), "orchestrator"]; +type AuthorRole = string; export function createTaskCommentTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -73,7 +74,7 @@ Examples: const issue = await provider.getIssue(issueId); const commentBody = authorRole - ? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}` + ? `${getRoleEmoji(authorRole)} **${authorRole.toUpperCase()}**: ${body}` : body; await provider.addComment(issueId, commentBody); @@ -99,8 +100,7 @@ Examples: // Private helpers // --------------------------------------------------------------------------- -const ROLE_EMOJI: Record = { - developer: "👨‍💻", - tester: "🔍", - orchestrator: "🎛️", -}; +function getRoleEmoji(role: string): string { + if (role === "orchestrator") return "🎛️"; + return getFallbackEmoji(role); +}