refactor: Centralize role types and configuration (#190)
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)
This commit is contained in:
36
lib/roles/index.ts
Normal file
36
lib/roles/index.ts
Normal file
@@ -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";
|
||||
190
lib/roles/registry.test.ts
Normal file
190
lib/roles/registry.test.ts
Normal file
@@ -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}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
75
lib/roles/registry.ts
Normal file
75
lib/roles/registry.ts
Normal file
@@ -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<string, RoleConfig> = {
|
||||
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 },
|
||||
},
|
||||
};
|
||||
157
lib/roles/selectors.ts
Normal file
157
lib/roles/selectors.ts
Normal file
@@ -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<string, Record<string, string>> {
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
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.<role>.<level>`
|
||||
* 2. Registry default model
|
||||
* 3. Passthrough (treat level as raw model ID)
|
||||
*/
|
||||
export function resolveModel(
|
||||
role: string,
|
||||
level: string,
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): string {
|
||||
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
|
||||
if (models && typeof models === "object") {
|
||||
const roleModels = models[role] as Record<string, string> | 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;
|
||||
}
|
||||
36
lib/roles/types.ts
Normal file
36
lib/roles/types.ts
Normal file
@@ -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<string, string>;
|
||||
/** Emoji per level (used in announcements). */
|
||||
emoji: Record<string, string>;
|
||||
/** 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;
|
||||
Reference in New Issue
Block a user