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:
@@ -10,6 +10,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
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.
|
* 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(
|
export function parseDevClawSessionKey(
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
): { projectName: string; role: "dev" | "qa" | "architect" } | null {
|
): { projectName: string; role: string } | null {
|
||||||
// Match `:subagent:` prefix, then capture everything up to the last `-dev-`, `-qa-`, or `-architect-`
|
// Match `:subagent:` prefix, then capture project name and role (derived from registry)
|
||||||
const match = sessionKey.match(/:subagent:(.+)-(dev|qa|architect)-[^-]+$/);
|
const rolePattern = getSessionKeyRolePattern();
|
||||||
|
const match = sessionKey.match(new RegExp(`:subagent:(.+)-(${rolePattern})-[^-]+$`));
|
||||||
if (!match) return null;
|
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(
|
export async function loadRoleInstructions(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
role: "dev" | "qa" | "architect",
|
role: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
getSessionForLevel,
|
getSessionForLevel,
|
||||||
getWorker,
|
getWorker,
|
||||||
} from "./projects.js";
|
} from "./projects.js";
|
||||||
import { resolveModel, levelEmoji } from "./tiers.js";
|
import { resolveModel, getEmoji, getFallbackEmoji } from "./roles/index.js";
|
||||||
import { notify, getNotificationConfig } from "./notify.js";
|
import { notify, getNotificationConfig } from "./notify.js";
|
||||||
|
|
||||||
export type DispatchOpts = {
|
export type DispatchOpts = {
|
||||||
@@ -302,7 +302,7 @@ function buildAnnouncement(
|
|||||||
level: string, role: string, sessionAction: "spawn" | "send",
|
level: string, role: string, sessionAction: "spawn" | "send",
|
||||||
issueId: number, issueTitle: string, issueUrl: string,
|
issueId: number, issueTitle: string, issueUrl: string,
|
||||||
): 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";
|
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
||||||
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
@@ -18,6 +18,7 @@ import { log as auditLog } from "../audit.js";
|
|||||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
||||||
import { projectTick } from "./tick.js";
|
import { projectTick } from "./tick.js";
|
||||||
import { createProvider } from "../providers/index.js";
|
import { createProvider } from "../providers/index.js";
|
||||||
|
import { getAllRoleIds } from "../roles/index.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -306,13 +307,13 @@ async function performHealthPass(
|
|||||||
const { provider } = await createProvider({ repo: project.repo });
|
const { provider } = await createProvider({ repo: project.repo });
|
||||||
let fixedCount = 0;
|
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)
|
// Check worker health (session liveness, label consistency, etc)
|
||||||
const healthFixes = await checkWorkerHealth({
|
const healthFixes = await checkWorkerHealth({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId,
|
groupId,
|
||||||
project,
|
project,
|
||||||
role,
|
role: role as any,
|
||||||
sessions,
|
sessions,
|
||||||
autoFix: true,
|
autoFix: true,
|
||||||
provider,
|
provider,
|
||||||
@@ -324,7 +325,7 @@ async function performHealthPass(
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId,
|
groupId,
|
||||||
project,
|
project,
|
||||||
role,
|
role: role as any,
|
||||||
autoFix: true,
|
autoFix: true,
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createProvider } from "../providers/index.js";
|
|||||||
import { selectLevel } from "../model-selector.js";
|
import { selectLevel } from "../model-selector.js";
|
||||||
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
||||||
import { dispatchTask } from "../dispatch.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 {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
getQueueLabels,
|
getQueueLabels,
|
||||||
@@ -48,19 +48,18 @@ export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW);
|
|||||||
export function detectLevelFromLabels(labels: string[]): string | null {
|
export function detectLevelFromLabels(labels: string[]): string | null {
|
||||||
const lower = labels.map((l) => l.toLowerCase());
|
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) {
|
for (const l of lower) {
|
||||||
const dot = l.indexOf(".");
|
const dot = l.indexOf(".");
|
||||||
if (dot === -1) continue;
|
if (dot === -1) continue;
|
||||||
const role = l.slice(0, dot);
|
const role = l.slice(0, dot);
|
||||||
const level = l.slice(dot + 1);
|
const level = l.slice(dot + 1);
|
||||||
if (role === "dev" && (DEV_LEVELS as readonly string[]).includes(level)) return level;
|
const roleLevels = getLevelsForRole(role);
|
||||||
if (role === "qa" && (QA_LEVELS as readonly string[]).includes(level)) return level;
|
if (roleLevels.includes(level)) return level;
|
||||||
if (role === "architect" && (ARCHITECT_LEVELS as readonly string[]).includes(level)) return level;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: plain level name
|
// 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;
|
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 provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider;
|
||||||
const roleExecution = project.roleExecution ?? "parallel";
|
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 pickups: TickAction[] = [];
|
||||||
const skipped: TickResult["skipped"] = [];
|
const skipped: TickResult["skipped"] = [];
|
||||||
@@ -187,8 +186,8 @@ export async function projectTick(opts: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Check sequential role execution: any other role must be inactive
|
// Check sequential role execution: any other role must be inactive
|
||||||
const otherRoles = (["dev", "qa", "architect"] as const).filter(r => r !== role);
|
const otherRoles = getAllRoleIds().filter(r => r !== role);
|
||||||
if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r).active)) {
|
if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r as any).active)) {
|
||||||
skipped.push({ role, reason: "Sequential: other role active" });
|
skipped.push({ role, reason: "Sequential: other role active" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -247,7 +246,7 @@ export async function projectTick(opts: {
|
|||||||
function resolveLevelForIssue(issue: Issue, role: Role): string {
|
function resolveLevelForIssue(issue: Issue, role: Role): string {
|
||||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||||
if (labelLevel) {
|
if (labelLevel) {
|
||||||
const labelRole = levelRole(labelLevel);
|
const labelRole = roleForLevel(labelLevel);
|
||||||
// If label level belongs to a different role, use heuristic for correct role
|
// 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;
|
if (labelRole && labelRole !== role) return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||||
return labelLevel;
|
return labelLevel;
|
||||||
|
|||||||
128
lib/tiers.ts
128
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.
|
* 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;
|
// Re-export WorkerRole from the registry
|
||||||
export const QA_LEVELS = ["reviewer", "tester"] as const;
|
export type { WorkerRole };
|
||||||
export const ARCHITECT_LEVELS = ["opus", "sonnet"] as const;
|
|
||||||
|
|
||||||
export type DevLevel = (typeof DEV_LEVELS)[number];
|
// ---------------------------------------------------------------------------
|
||||||
export type QaLevel = (typeof QA_LEVELS)[number];
|
// Level constants — derived from registry
|
||||||
export type ArchitectLevel = (typeof ARCHITECT_LEVELS)[number];
|
// ---------------------------------------------------------------------------
|
||||||
export type Level = DevLevel | QaLevel | ArchitectLevel;
|
|
||||||
|
|
||||||
/** Default models, nested by role. */
|
/** @deprecated Use roles/selectors.getAllDefaultModels() */
|
||||||
export const DEFAULT_MODELS = {
|
export const DEFAULT_MODELS = getAllDefaultModels();
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Emoji used in announcements, nested by role. */
|
/** @deprecated Use roles/selectors.getEmoji() */
|
||||||
export const LEVEL_EMOJI = {
|
export const LEVEL_EMOJI: Record<string, Record<string, string>> = Object.fromEntries(
|
||||||
dev: {
|
Object.entries(ROLE_REGISTRY).map(([id, config]) => [id, { ...config.emoji }]),
|
||||||
junior: "⚡",
|
);
|
||||||
medior: "🔧",
|
|
||||||
senior: "🧠",
|
export const DEV_LEVELS = getLevelsForRole("dev") as readonly string[];
|
||||||
},
|
export const QA_LEVELS = getLevelsForRole("qa") as readonly string[];
|
||||||
qa: {
|
export const ARCHITECT_LEVELS = getLevelsForRole("architect") as readonly string[];
|
||||||
reviewer: "🔍",
|
|
||||||
tester: "👀",
|
export type DevLevel = string;
|
||||||
},
|
export type QaLevel = string;
|
||||||
architect: {
|
export type ArchitectLevel = string;
|
||||||
opus: "🏗️",
|
export type Level = string;
|
||||||
sonnet: "📐",
|
|
||||||
},
|
// ---------------------------------------------------------------------------
|
||||||
};
|
// Level checks — delegate to registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Check if a level belongs to the dev role. */
|
/** Check if a level belongs to the dev role. */
|
||||||
export function isDevLevel(value: string): value is DevLevel {
|
export function isDevLevel(value: string): boolean {
|
||||||
return (DEV_LEVELS as readonly string[]).includes(value);
|
return DEV_LEVELS.includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a level belongs to the qa role. */
|
/** Check if a level belongs to the qa role. */
|
||||||
export function isQaLevel(value: string): value is QaLevel {
|
export function isQaLevel(value: string): boolean {
|
||||||
return (QA_LEVELS as readonly string[]).includes(value);
|
return QA_LEVELS.includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a level belongs to the architect role. */
|
/** Check if a level belongs to the architect role. */
|
||||||
export function isArchitectLevel(value: string): value is ArchitectLevel {
|
export function isArchitectLevel(value: string): boolean {
|
||||||
return (ARCHITECT_LEVELS as readonly string[]).includes(value);
|
return ARCHITECT_LEVELS.includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Determine the role a level belongs to. */
|
/** Determine the role a level belongs to. */
|
||||||
export function levelRole(level: string): WorkerRole | undefined {
|
export function levelRole(level: string): WorkerRole | undefined {
|
||||||
if (isDevLevel(level)) return "dev";
|
return roleForLevel(level) as WorkerRole | undefined;
|
||||||
if (isQaLevel(level)) return "qa";
|
|
||||||
if (isArchitectLevel(level)) return "architect";
|
|
||||||
return 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 {
|
export function defaultModel(role: WorkerRole, level: string): string | undefined {
|
||||||
return (DEFAULT_MODELS[role] as Record<string, string>)[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 {
|
export function levelEmoji(role: WorkerRole, level: string): string | undefined {
|
||||||
return (LEVEL_EMOJI[role] as Record<string, string>)[level];
|
return getEmoji(role, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @deprecated Use roles/selectors.resolveModel() */
|
||||||
* Resolve a level to a full model ID.
|
|
||||||
*
|
|
||||||
* Resolution order:
|
|
||||||
* 1. Plugin config `models.<role>.<level>`
|
|
||||||
* 2. DEFAULT_MODELS[role][level]
|
|
||||||
* 3. Passthrough (treat as raw model ID)
|
|
||||||
*/
|
|
||||||
export function resolveModel(
|
export function resolveModel(
|
||||||
role: WorkerRole,
|
role: WorkerRole,
|
||||||
level: string,
|
level: string,
|
||||||
pluginConfig?: Record<string, unknown>,
|
pluginConfig?: Record<string, unknown>,
|
||||||
): string {
|
): string {
|
||||||
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
|
return registryResolveModel(role, level, pluginConfig);
|
||||||
|
|
||||||
if (models && typeof models === "object") {
|
|
||||||
const roleModels = models[role] as Record<string, string> | undefined;
|
|
||||||
if (roleModels?.[level]) return roleModels[level];
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultModel(role, level) ?? level;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { readProjects, getProject } from "../projects.js";
|
|||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js";
|
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js";
|
||||||
import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js";
|
||||||
|
import { getAllRoleIds } from "../roles/index.js";
|
||||||
|
|
||||||
export function createHealthTool() {
|
export function createHealthTool() {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -51,13 +52,13 @@ export function createHealthTool() {
|
|||||||
if (!project) continue;
|
if (!project) continue;
|
||||||
const { provider } = await resolveProvider(project);
|
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)
|
// Worker health check (session liveness, label consistency, etc)
|
||||||
const healthFixes = await checkWorkerHealth({
|
const healthFixes = await checkWorkerHealth({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId: pid,
|
groupId: pid,
|
||||||
project,
|
project,
|
||||||
role,
|
role: role as any,
|
||||||
sessions,
|
sessions,
|
||||||
autoFix: fix,
|
autoFix: fix,
|
||||||
provider,
|
provider,
|
||||||
@@ -69,7 +70,7 @@ export function createHealthTool() {
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId: pid,
|
groupId: pid,
|
||||||
project,
|
project,
|
||||||
role,
|
role: role as any,
|
||||||
autoFix: fix,
|
autoFix: fix,
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
|
|||||||
import { resolveRepoPath } from "../projects.js";
|
import { resolveRepoPath } from "../projects.js";
|
||||||
import { createProvider } from "../providers/index.js";
|
import { createProvider } from "../providers/index.js";
|
||||||
import { log as auditLog } from "../audit.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";
|
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,9 +162,9 @@ export function createProjectRegisterTool() {
|
|||||||
deployBranch,
|
deployBranch,
|
||||||
channel,
|
channel,
|
||||||
roleExecution,
|
roleExecution,
|
||||||
dev: emptyWorkerState([...DEV_LEVELS]),
|
dev: emptyWorkerState([...getLevelsForRole("dev")]),
|
||||||
qa: emptyWorkerState([...QA_LEVELS]),
|
qa: emptyWorkerState([...getLevelsForRole("qa")]),
|
||||||
architect: emptyWorkerState([...ARCHITECT_LEVELS]),
|
architect: emptyWorkerState([...getLevelsForRole("architect")]),
|
||||||
};
|
};
|
||||||
|
|
||||||
await writeProjects(workspaceDir, data);
|
await writeProjects(workspaceDir, data);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getWorker, resolveRepoPath } from "../projects.js";
|
|||||||
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
|
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
|
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
|
||||||
|
|
||||||
export function createWorkFinishTool(api: OpenClawPluginApi) {
|
export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -21,7 +22,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["role", "result", "projectGroupId"],
|
required: ["role", "result", "projectGroupId"],
|
||||||
properties: {
|
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" },
|
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
||||||
projectGroupId: { type: "string", description: "Project group ID" },
|
projectGroupId: { type: "string", description: "Project group ID" },
|
||||||
summary: { type: "string", description: "Brief summary" },
|
summary: { type: "string", description: "Brief summary" },
|
||||||
@@ -37,13 +38,11 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
const prUrl = params.prUrl as string | undefined;
|
const prUrl = params.prUrl as string | undefined;
|
||||||
const workspaceDir = requireWorkspaceDir(ctx);
|
const workspaceDir = requireWorkspaceDir(ctx);
|
||||||
|
|
||||||
// Validate role:result
|
// Validate role:result using registry
|
||||||
if (role === "dev" && result !== "done" && result !== "blocked")
|
if (!isValidResult(role, result)) {
|
||||||
throw new Error(`DEV can only complete with "done" or "blocked", got "${result}"`);
|
const valid = getCompletionResults(role);
|
||||||
if (role === "architect" && result !== "done" && result !== "blocked")
|
throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`);
|
||||||
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".`);
|
|
||||||
if (!getRule(role, result))
|
if (!getRule(role, result))
|
||||||
throw new Error(`Invalid completion: ${role}:${result}`);
|
throw new Error(`Invalid completion: ${role}:${result}`);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getWorker } from "../projects.js";
|
|||||||
import { dispatchTask } from "../dispatch.js";
|
import { dispatchTask } from "../dispatch.js";
|
||||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
||||||
import { isDevLevel } from "../tiers.js";
|
import { isDevLevel } from "../tiers.js";
|
||||||
|
import { getAllRoleIds } from "../roles/index.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
properties: {
|
properties: {
|
||||||
projectGroupId: { type: "string", description: "Project group ID." },
|
projectGroupId: { type: "string", description: "Project group ID." },
|
||||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
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." },
|
level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import path from "node:path";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type StateType = "queue" | "active" | "hold" | "terminal";
|
export type StateType = "queue" | "active" | "hold" | "terminal";
|
||||||
|
/** @deprecated Use WorkerRole from lib/roles/ */
|
||||||
export type Role = "dev" | "qa" | "architect";
|
export type Role = "dev" | "qa" | "architect";
|
||||||
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
|
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user