refactor: migrate role handling from tiers to roles module

- Removed the deprecated tiers.ts file and migrated all related functionality to roles/index.js.
- Updated tests and tools to reflect the new role structure, replacing references to "dev", "qa", and "architect" with "developer", "tester", and "architect".
- Adjusted workflow configurations and state management to accommodate the new role naming conventions.
- Enhanced project registration and health check tools to support dynamic role handling.
- Updated task creation, update, and completion processes to align with the new role definitions.
- Improved documentation and comments to clarify role responsibilities and usage.
This commit is contained in:
Lauren ten Hoor
2026-02-15 18:32:10 +08:00
parent 6a99752e5f
commit 0e24a68882
44 changed files with 1162 additions and 762 deletions

View File

@@ -10,24 +10,24 @@ import path from "node:path";
import os from "node:os";
describe("parseDevClawSessionKey", () => {
it("should parse a standard dev session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-mid");
assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" });
it("should parse a standard developer session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-developer-medior");
assert.deepStrictEqual(result, { projectName: "my-project", role: "developer" });
});
it("should parse a qa session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-mid");
assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" });
it("should parse a tester session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-tester-medior");
assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" });
});
it("should handle project names with hyphens", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior");
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" });
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior");
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" });
});
it("should handle project names with multiple hyphens and qa role", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-junior");
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" });
it("should handle project names with multiple hyphens and tester role", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-tester-junior");
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "tester" });
});
it("should return null for non-subagent session keys", () => {
@@ -45,14 +45,14 @@ describe("parseDevClawSessionKey", () => {
assert.strictEqual(result, null);
});
it("should parse senior dev level", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior");
assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" });
it("should parse senior developer level", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-developer-senior");
assert.deepStrictEqual(result, { projectName: "devclaw", role: "developer" });
});
it("should parse simple project name", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior");
assert.deepStrictEqual(result, { projectName: "api", role: "dev" });
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-developer-junior");
assert.deepStrictEqual(result, { projectName: "api", role: "developer" });
});
});
@@ -61,10 +61,10 @@ describe("loadRoleInstructions", () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const projectDir = path.join(tmpDir, "projects", "roles", "test-project");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(projectDir, "dev.md"), "# Dev Instructions\nDo the thing.");
await fs.writeFile(path.join(projectDir, "developer.md"), "# Developer Instructions\nDo the thing.");
const result = await loadRoleInstructions(tmpDir, "test-project", "dev");
assert.strictEqual(result, "# Dev Instructions\nDo the thing.");
const result = await loadRoleInstructions(tmpDir, "test-project", "developer");
assert.strictEqual(result, "# Developer Instructions\nDo the thing.");
await fs.rm(tmpDir, { recursive: true });
});
@@ -73,10 +73,10 @@ describe("loadRoleInstructions", () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
await fs.mkdir(defaultDir, { recursive: true });
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA Default\nReview carefully.");
await fs.writeFile(path.join(defaultDir, "tester.md"), "# Tester Default\nReview carefully.");
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "qa");
assert.strictEqual(result, "# QA Default\nReview carefully.");
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester");
assert.strictEqual(result, "# Tester Default\nReview carefully.");
await fs.rm(tmpDir, { recursive: true });
});
@@ -84,7 +84,7 @@ describe("loadRoleInstructions", () => {
it("should return empty string when no instructions exist", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const result = await loadRoleInstructions(tmpDir, "missing", "dev");
const result = await loadRoleInstructions(tmpDir, "missing", "developer");
assert.strictEqual(result, "");
await fs.rm(tmpDir, { recursive: true });
@@ -96,10 +96,10 @@ describe("loadRoleInstructions", () => {
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(defaultDir, { recursive: true });
await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions");
await fs.writeFile(path.join(defaultDir, "dev.md"), "Default instructions");
await fs.writeFile(path.join(projectDir, "developer.md"), "Project-specific instructions");
await fs.writeFile(path.join(defaultDir, "developer.md"), "Default instructions");
const result = await loadRoleInstructions(tmpDir, "my-project", "dev");
const result = await loadRoleInstructions(tmpDir, "my-project", "developer");
assert.strictEqual(result, "Project-specific instructions");
await fs.rm(tmpDir, { recursive: true });

View File

@@ -17,8 +17,8 @@ import { getSessionKeyRolePattern } from "./roles/index.js";
*
* Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
* Examples:
* - `agent:devclaw:subagent:my-project-dev-mid` → { projectName: "my-project", role: "dev" }
* - `agent:devclaw:subagent:webapp-qa-mid` → { projectName: "webapp", role: "qa" }
* - `agent:devclaw:subagent:my-project-developer-medior` → { projectName: "my-project", role: "developer" }
* - `agent:devclaw:subagent:webapp-tester-medior` → { projectName: "webapp", role: "tester" }
*
* Note: projectName may contain hyphens, so we match role from the end.
*/

View File

@@ -6,8 +6,7 @@
import type { Command } from "commander";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { runSetup } from "./setup/index.js";
import { DEFAULT_MODELS } from "./tiers.js";
import { getLevelsForRole } from "./roles/index.js";
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "./roles/index.js";
/**
* Register the `devclaw` CLI command group on a Commander program.
@@ -17,39 +16,41 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
.command("devclaw")
.description("DevClaw development pipeline tools");
devclaw
const setupCmd = devclaw
.command("setup")
.description("Set up DevClaw: create agent, configure models, write workspace files")
.option("--new-agent <name>", "Create a new agent with this name")
.option("--agent <id>", "Use an existing agent by ID")
.option("--workspace <path>", "Direct workspace path")
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`)
.option("--mid <model>", `Mid dev model (default: ${DEFAULT_MODELS.dev.mid})`)
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`)
.option("--qa-junior <model>", `QA junior model (default: ${DEFAULT_MODELS.qa.junior})`)
.option("--qa-mid <model>", `QA mid model (default: ${DEFAULT_MODELS.qa.mid})`)
.option("--qa-senior <model>", `QA senior model (default: ${DEFAULT_MODELS.qa.senior})`)
.action(async (opts) => {
const dev: Record<string, string> = {};
const qa: Record<string, string> = {};
if (opts.junior) dev.junior = opts.junior;
if (opts.mid) dev.mid = opts.mid;
if (opts.senior) dev.senior = opts.senior;
if (opts.qaJunior) qa.junior = opts.qaJunior;
if (opts.qaMid) qa.mid = opts.qaMid;
if (opts.qaSenior) qa.senior = opts.qaSenior;
.option("--workspace <path>", "Direct workspace path");
const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0;
const models = hasOverrides
? { ...(Object.keys(dev).length > 0 && { dev }), ...(Object.keys(qa).length > 0 && { qa }) }
: undefined;
// Register dynamic --<role>-<level> options from registry
const defaults = getAllDefaultModels();
for (const role of getAllRoleIds()) {
for (const level of getLevelsForRole(role)) {
const flag = `--${role}-${level}`;
setupCmd.option(`${flag} <model>`, `${role.toUpperCase()} ${level} model (default: ${defaults[role]?.[level] ?? "auto"})`);
}
}
setupCmd.action(async (opts) => {
// Build model overrides from CLI flags dynamically
const models: Record<string, Record<string, string>> = {};
for (const role of getAllRoleIds()) {
const roleModels: Record<string, string> = {};
for (const level of getLevelsForRole(role)) {
// camelCase key: "testerJunior" for --tester-junior, "developerMedior" for --developer-medior
const key = `${role}${level.charAt(0).toUpperCase()}${level.slice(1)}`;
if (opts[key]) roleModels[level] = opts[key];
}
if (Object.keys(roleModels).length > 0) models[role] = roleModels;
}
const result = await runSetup({
api,
newAgentName: opts.newAgent,
agentId: opts.agent,
workspacePath: opts.workspace,
models,
models: Object.keys(models).length > 0 ? models : undefined,
});
if (result.agentCreated) {
@@ -57,9 +58,11 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
}
console.log("Models configured:");
for (const t of getLevelsForRole("dev")) console.log(` dev.${t}: ${result.models.dev[t]}`);
for (const t of getLevelsForRole("qa")) console.log(` qa.${t}: ${result.models.qa[t]}`);
for (const t of getLevelsForRole("architect")) console.log(` architect.${t}: ${result.models.architect[t]}`);
for (const [role, levels] of Object.entries(result.models)) {
for (const [level, model] of Object.entries(levels)) {
console.log(` ${role}.${level}: ${model}`);
}
}
console.log("Files written:");
for (const file of result.filesWritten) {

14
lib/config/index.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* config/ — Unified DevClaw configuration.
*
* Single config.yaml per workspace/project combining roles, models, and workflow.
*/
export type {
DevClawConfig,
RoleOverride,
ResolvedConfig,
ResolvedRoleConfig,
} from "./types.js";
export { loadConfig } from "./loader.js";
export { mergeConfig } from "./merge.js";

170
lib/config/loader.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* config/loader.ts — Three-layer config loading.
*
* Resolution order:
* 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
* 2. Workspace: <workspace>/projects/config.yaml
* 3. Project: <workspace>/projects/<project>/config.yaml
*
* Also supports legacy workflow.yaml files (merged into the workflow section).
*/
import fs from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import { ROLE_REGISTRY } from "../roles/registry.js";
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
import { mergeConfig } from "./merge.js";
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, RoleOverride } from "./types.js";
/**
* Load and resolve the full DevClaw config for a project.
*
* Merges: built-in → workspace config.yaml → project config.yaml.
* Also picks up legacy workflow.yaml files if no workflow section in config.yaml.
*/
export async function loadConfig(
workspaceDir: string,
projectName?: string,
): Promise<ResolvedConfig> {
const projectsDir = path.join(workspaceDir, "projects");
// Layer 1: built-in defaults
const builtIn = buildDefaultConfig();
// Layer 2: workspace config.yaml
let merged = builtIn;
const workspaceConfig = await readConfigFile(projectsDir);
if (workspaceConfig) {
merged = mergeConfig(merged, workspaceConfig);
}
// Legacy: workspace workflow.yaml (only if no workflow in config.yaml)
if (!workspaceConfig?.workflow) {
const legacyWorkflow = await readWorkflowYaml(projectsDir);
if (legacyWorkflow) {
merged = mergeConfig(merged, { workflow: legacyWorkflow });
}
}
// Layer 3: project config.yaml
if (projectName) {
const projectDir = path.join(projectsDir, projectName);
const projectConfig = await readConfigFile(projectDir);
if (projectConfig) {
merged = mergeConfig(merged, projectConfig);
}
// Legacy: project workflow.yaml
if (!projectConfig?.workflow) {
const legacyWorkflow = await readWorkflowYaml(projectDir);
if (legacyWorkflow) {
merged = mergeConfig(merged, { workflow: legacyWorkflow });
}
}
}
return resolve(merged);
}
/**
* Build the default config from the built-in ROLE_REGISTRY and DEFAULT_WORKFLOW.
*/
function buildDefaultConfig(): DevClawConfig {
const roles: Record<string, RoleOverride> = {};
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
roles[id] = {
levels: [...reg.levels],
defaultLevel: reg.defaultLevel,
models: { ...reg.models },
emoji: { ...reg.emoji },
completionResults: [...reg.completionResults],
};
}
return { roles, workflow: DEFAULT_WORKFLOW };
}
/**
* Resolve a merged DevClawConfig into a fully-typed ResolvedConfig.
*/
function resolve(config: DevClawConfig): ResolvedConfig {
const roles: Record<string, ResolvedRoleConfig> = {};
if (config.roles) {
for (const [id, override] of Object.entries(config.roles)) {
if (override === false) {
// Disabled role — include with enabled: false for visibility
const reg = ROLE_REGISTRY[id];
roles[id] = {
levels: reg ? [...reg.levels] : [],
defaultLevel: reg?.defaultLevel ?? "",
models: reg ? { ...reg.models } : {},
emoji: reg ? { ...reg.emoji } : {},
completionResults: reg ? [...reg.completionResults] : [],
enabled: false,
};
continue;
}
const reg = ROLE_REGISTRY[id];
roles[id] = {
levels: override.levels ?? (reg ? [...reg.levels] : []),
defaultLevel: override.defaultLevel ?? reg?.defaultLevel ?? "",
models: { ...(reg?.models ?? {}), ...(override.models ?? {}) },
emoji: { ...(reg?.emoji ?? {}), ...(override.emoji ?? {}) },
completionResults: override.completionResults ?? (reg ? [...reg.completionResults] : []),
enabled: true,
};
}
}
// Ensure all built-in roles exist even if not in config
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
if (!roles[id]) {
roles[id] = {
levels: [...reg.levels],
defaultLevel: reg.defaultLevel,
models: { ...reg.models },
emoji: { ...reg.emoji },
completionResults: [...reg.completionResults],
enabled: true,
};
}
}
const workflow: WorkflowConfig = {
initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial,
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
};
return { roles, workflow };
}
// ---------------------------------------------------------------------------
// File reading helpers
// ---------------------------------------------------------------------------
async function readConfigFile(dir: string): Promise<DevClawConfig | null> {
try {
const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8");
return YAML.parse(content) as DevClawConfig;
} catch { /* not found */ }
return null;
}
async function readWorkflowYaml(dir: string): Promise<Partial<WorkflowConfig> | null> {
try {
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
return YAML.parse(content) as Partial<WorkflowConfig>;
} catch { /* not found */ }
// Legacy JSON fallback
try {
const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8");
const parsed = JSON.parse(content) as
| Partial<WorkflowConfig>
| { workflow?: Partial<WorkflowConfig> };
return (parsed as any).workflow ?? parsed;
} catch { /* not found */ }
return null;
}

82
lib/config/merge.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* config/merge.ts — Deep merge for DevClaw config layers.
*
* Merge semantics:
* - Objects: recursively merge (sparse override)
* - Arrays: replace entirely (no merging array elements)
* - `false` for a role: marks it as disabled
* - Primitives: override
*/
import type { DevClawConfig, RoleOverride } from "./types.js";
/**
* Merge a config overlay on top of a base config.
* Returns a new config — does not mutate inputs.
*/
export function mergeConfig(
base: DevClawConfig,
overlay: DevClawConfig,
): DevClawConfig {
const merged: DevClawConfig = {};
// Merge roles
if (base.roles || overlay.roles) {
merged.roles = { ...base.roles };
if (overlay.roles) {
for (const [roleId, overrideValue] of Object.entries(overlay.roles)) {
if (overrideValue === false) {
// Disable role
merged.roles[roleId] = false;
} else if (merged.roles[roleId] === false) {
// Re-enable with override
merged.roles[roleId] = overrideValue;
} else {
// Merge role override on top of base role
const baseRole = merged.roles[roleId];
merged.roles[roleId] = mergeRoleOverride(
typeof baseRole === "object" ? baseRole : {},
overrideValue,
);
}
}
}
}
// Merge workflow
if (base.workflow || overlay.workflow) {
merged.workflow = {
initial: overlay.workflow?.initial ?? base.workflow?.initial,
states: {
...base.workflow?.states,
...overlay.workflow?.states,
},
};
// Clean up undefined initial
if (merged.workflow.initial === undefined) {
delete merged.workflow.initial;
}
}
return merged;
}
function mergeRoleOverride(
base: RoleOverride,
overlay: RoleOverride,
): RoleOverride {
return {
...base,
...overlay,
// Models: merge (don't replace)
models: base.models || overlay.models
? { ...base.models, ...overlay.models }
: undefined,
// Emoji: merge (don't replace)
emoji: base.emoji || overlay.emoji
? { ...base.emoji, ...overlay.emoji }
: undefined,
// Arrays replace entirely
...(overlay.levels ? { levels: overlay.levels } : {}),
...(overlay.completionResults ? { completionResults: overlay.completionResults } : {}),
};
}

49
lib/config/types.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* config/types.ts — Types for the unified DevClaw configuration.
*
* A single config.yaml combines roles, models, and workflow.
* Three-layer resolution: built-in → workspace → per-project.
*/
import type { WorkflowConfig } from "../workflow.js";
/**
* Role override in config.yaml. All fields optional — only override what you need.
* Set to `false` to disable a role entirely for a project.
*/
export type RoleOverride = {
levels?: string[];
defaultLevel?: string;
models?: Record<string, string>;
emoji?: Record<string, string>;
completionResults?: string[];
};
/**
* The full config.yaml shape.
* All fields optional — missing fields inherit from the layer below.
*/
export type DevClawConfig = {
roles?: Record<string, RoleOverride | false>;
workflow?: Partial<WorkflowConfig>;
};
/**
* Fully resolved config — all fields guaranteed present.
* Built by merging three layers over the built-in defaults.
*/
export type ResolvedConfig = {
roles: Record<string, ResolvedRoleConfig>;
workflow: WorkflowConfig;
};
/**
* Fully resolved role config — all fields present.
*/
export type ResolvedRoleConfig = {
levels: string[];
defaultLevel: string;
models: Record<string, string>;
emoji: Record<string, string>;
completionResults: string[];
enabled: boolean;
};

View File

@@ -13,8 +13,9 @@ import {
getSessionForLevel,
getWorker,
} from "./projects.js";
import { resolveModel, getEmoji, getFallbackEmoji } from "./roles/index.js";
import { resolveModel, getFallbackEmoji } from "./roles/index.js";
import { notify, getNotificationConfig } from "./notify.js";
import { loadConfig, type ResolvedRoleConfig } from "./config/index.js";
export type DispatchOpts = {
workspaceDir: string;
@@ -25,7 +26,7 @@ export type DispatchOpts = {
issueTitle: string;
issueDescription: string;
issueUrl: string;
role: "dev" | "qa" | "architect";
role: string;
/** Developer level (junior, mid, senior) or raw model ID */
level: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
@@ -63,7 +64,7 @@ export type DispatchResult = {
*/
export function buildTaskMessage(opts: {
projectName: string;
role: "dev" | "qa" | "architect";
role: string;
issueId: number;
issueTitle: string;
issueDescription: string;
@@ -72,16 +73,15 @@ export function buildTaskMessage(opts: {
baseBranch: string;
groupId: string;
comments?: Array<{ author: string; body: string; created_at: string }>;
resolvedRole?: ResolvedRoleConfig;
}): string {
const {
projectName, role, issueId, issueTitle,
issueDescription, issueUrl, repo, baseBranch, groupId,
} = opts;
const availableResults =
role === "dev" || role === "architect"
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
: '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)';
const results = opts.resolvedRole?.completionResults ?? [];
const availableResults = results.map((r: string) => `"${r}"`).join(", ");
const parts = [
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
@@ -149,7 +149,9 @@ export async function dispatchTask(
transitionLabel, provider, pluginConfig, runtime,
} = opts;
const model = resolveModel(role, level, pluginConfig);
const resolvedConfig = await loadConfig(workspaceDir, project.name);
const resolvedRole = resolvedConfig.roles[role];
const model = resolveModel(role, level, pluginConfig, resolvedRole);
const worker = getWorker(project, role);
const existingSessionKey = getSessionForLevel(worker, level);
const sessionAction = existingSessionKey ? "send" : "spawn";
@@ -164,7 +166,7 @@ export async function dispatchTask(
projectName: project.name, role, issueId,
issueTitle, issueDescription, issueUrl,
repo: project.repo, baseBranch: project.baseBranch, groupId,
comments,
comments, resolvedRole,
});
// Step 1: Transition label (this is the commitment point)
@@ -225,7 +227,7 @@ export async function dispatchTask(
fromLabel, toLabel,
});
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl);
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl, resolvedRole);
return { sessionAction, sessionKey, level, model, announcement };
}
@@ -267,7 +269,7 @@ function sendToAgent(
}
async function recordWorkerState(
workspaceDir: string, groupId: string, role: "dev" | "qa" | "architect",
workspaceDir: string, groupId: string, role: string,
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
): Promise<void> {
await activateWorker(workspaceDir, groupId, role, {
@@ -301,8 +303,9 @@ async function auditDispatch(
function buildAnnouncement(
level: string, role: string, sessionAction: "spawn" | "send",
issueId: number, issueTitle: string, issueUrl: string,
resolvedRole?: ResolvedRoleConfig,
): string {
const emoji = getEmoji(role, level) ?? getFallbackEmoji(role);
const emoji = resolvedRole?.emoji[level] ?? getFallbackEmoji(role);
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
}

View File

@@ -1,8 +1,13 @@
/**
* Model selection for dev/qa tasks.
* Keyword heuristic fallback — used when the orchestrator doesn't specify a level.
* Returns plain level names (junior, mid, senior).
* Model selection heuristic fallback — used when the orchestrator doesn't specify a level.
* Returns plain level names (junior, medior, senior).
*
* Adapts to any role's level count:
* - 1 level: always returns that level
* - 2 levels: simple binary (complex → last, else first)
* - 3+ levels: full heuristic (simple → first, complex → last, default → middle)
*/
import { getLevelsForRole, getDefaultLevel } from "./roles/index.js";
export type LevelSelection = {
level: string;
@@ -39,60 +44,59 @@ const COMPLEX_KEYWORDS = [
];
/**
* Select appropriate developer level based on task description.
* Select appropriate level based on task description and role.
*
* All roles use consistent levels:
* - junior: simple tasks (typos, single-file fixes, CSS tweaks)
* - mid: standard work (features, bug fixes, multi-file changes)
* - senior: deep/architectural (system-wide refactoring, novel design)
* Adapts to the role's available levels:
* - Roles with 1 level → always that level
* - Roles with 2 levels → binary: complex keywords → highest, else lowest
* - Roles with 3+ levels → full heuristic: simple → lowest, complex → highest, else default
*/
export function selectLevel(
issueTitle: string,
issueDescription: string,
role: "dev" | "qa" | "architect",
role: string,
): LevelSelection {
if (role === "qa") {
return {
level: "mid",
reason: "Default QA level for code inspection and validation",
};
}
const levels = getLevelsForRole(role);
const defaultLvl = getDefaultLevel(role);
if (role === "architect") {
const text = `${issueTitle} ${issueDescription}`.toLowerCase();
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
return {
level: isComplex ? "senior" : "junior",
reason: isComplex
? "Complex design task — using senior for depth"
: "Standard design task — using junior",
};
// Roles with only 1 level — always return it
if (levels.length <= 1) {
const level = levels[0] ?? defaultLvl ?? "medior";
return { level, reason: `Only level for ${role}` };
}
const text = `${issueTitle} ${issueDescription}`.toLowerCase();
const wordCount = text.split(/\s+/).length;
// Check for simple task indicators
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
const lowest = levels[0];
const highest = levels[levels.length - 1];
// Roles with 2 levels — binary decision
if (levels.length === 2) {
if (isComplex) {
return { level: highest, reason: `Complex task — using ${highest}` };
}
return { level: lowest, reason: `Standard task — using ${lowest}` };
}
// Roles with 3+ levels — full heuristic
if (isSimple && wordCount < 100) {
return {
level: "junior",
level: lowest,
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
};
}
// Check for complex task indicators
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
if (isComplex || wordCount > 500) {
return {
level: "senior",
level: highest,
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
};
}
// Default: mid for standard dev work
return {
level: "mid",
reason: "Standard dev task — multi-file changes, features, bug fixes",
};
// Default level for the role
const level = defaultLvl ?? levels[Math.floor(levels.length / 2)];
return { level, reason: `Standard ${role} task` };
}

View File

@@ -21,7 +21,7 @@ export type NotifyEvent =
issueId: number;
issueTitle: string;
issueUrl: string;
role: "dev" | "qa" | "architect";
role: string;
level: string;
sessionAction: "spawn" | "send";
}
@@ -31,7 +31,7 @@ export type NotifyEvent =
groupId: string;
issueId: number;
issueUrl: string;
role: "dev" | "qa" | "architect";
role: string;
result: "done" | "pass" | "fail" | "refine" | "blocked";
summary?: string;
nextState?: string;

View File

@@ -5,7 +5,7 @@
*/
import fs from "node:fs/promises";
import path from "node:path";
import { DEFAULT_MODELS } from "./tiers.js";
import { getAllDefaultModels } from "./roles/index.js";
// ---------------------------------------------------------------------------
// Detection
@@ -38,15 +38,11 @@ export async function hasWorkspaceFiles(
// ---------------------------------------------------------------------------
function buildModelTable(pluginConfig?: Record<string, unknown>): string {
const cfg = (
pluginConfig as {
models?: { dev?: Record<string, string>; qa?: Record<string, string> };
}
)?.models;
const cfg = (pluginConfig as { models?: Record<string, Record<string, string>> })?.models;
const lines: string[] = [];
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
for (const [level, defaultModel] of Object.entries(levels)) {
const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel;
const model = cfg?.[role]?.[level] || defaultModel;
lines.push(
` - **${role} ${level}**: ${model} (default: ${defaultModel})`,
);
@@ -76,14 +72,14 @@ Ask what they want to change, then call the appropriate tool.
}
export function buildOnboardToolContext(): string {
// Build the model table dynamically from DEFAULT_MODELS
// Build the model table dynamically from getAllDefaultModels()
const rows: string[] = [];
const purposes: Record<string, string> = {
junior: "Simple tasks, single-file fixes",
mid: "Features, bug fixes, code review",
medior: "Features, bug fixes, code review",
senior: "Architecture, refactoring, complex tasks",
};
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
for (const [level, model] of Object.entries(levels)) {
rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`);
}
@@ -95,8 +91,8 @@ export function buildOnboardToolContext(): string {
## What is DevClaw?
DevClaw turns each Telegram group into an autonomous development team:
- An **orchestrator** that manages backlogs and delegates work
- **DEV workers** (junior/mid/senior levels) that write code in isolated sessions
- **QA workers** that review code and run tests
- **Developer workers** (junior/medior/senior levels) that write code in isolated sessions
- **Tester workers** that review code and run tests
- Atomic tools for label transitions, session dispatch, state management, and audit logging
## Setup Steps
@@ -141,7 +137,7 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de
**Step 3: Run Setup**
Call \`setup\` with the collected answers:
- Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\`
- Current agent: \`setup({})\` or \`setup({ models: { developer: { ... }, tester: { ... } } })\`
- New agent: \`setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
- \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding

254
lib/projects.test.ts Normal file
View File

@@ -0,0 +1,254 @@
/**
* Tests for projects.ts — worker state, migration, and accessors.
* Run with: npx tsx --test lib/projects.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { readProjects, getWorker, emptyWorkerState, writeProjects, type ProjectsData } from "./projects.js";
describe("readProjects migration", () => {
it("should migrate old format (dev/qa/architect fields) to workers map", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
// Old format: hardcoded dev/qa/architect fields
const oldFormat = {
projects: {
"group-1": {
name: "test-project",
repo: "~/git/test",
groupName: "Test",
deployUrl: "",
baseBranch: "main",
deployBranch: "main",
dev: { active: true, issueId: "42", startTime: null, level: "mid", sessions: { mid: "key-1" } },
qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
architect: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
},
},
};
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8");
const data = await readProjects(tmpDir);
const project = data.projects["group-1"];
// Should have workers map with migrated role keys
assert.ok(project.workers, "should have workers map");
assert.ok(project.workers.developer, "should have developer worker (migrated from dev)");
assert.ok(project.workers.tester, "should have tester worker (migrated from qa)");
assert.ok(project.workers.architect, "should have architect worker");
// Developer worker should be active with migrated level
assert.strictEqual(project.workers.developer.active, true);
assert.strictEqual(project.workers.developer.issueId, "42");
assert.strictEqual(project.workers.developer.level, "medior");
// Old fields should not exist on the object
assert.strictEqual((project as any).dev, undefined);
assert.strictEqual((project as any).qa, undefined);
assert.strictEqual((project as any).architect, undefined);
await fs.rm(tmpDir, { recursive: true });
});
it("should migrate old level names in old format", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
const oldFormat = {
projects: {
"group-1": {
name: "legacy",
repo: "~/git/legacy",
groupName: "Legacy",
deployUrl: "",
baseBranch: "main",
deployBranch: "main",
dev: { active: false, issueId: null, startTime: null, level: "medior", sessions: { medior: "key-1" } },
qa: { active: false, issueId: null, startTime: null, level: "reviewer", sessions: { reviewer: "key-2" } },
architect: { active: false, issueId: null, startTime: null, level: "opus", sessions: { opus: "key-3" } },
},
},
};
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8");
const data = await readProjects(tmpDir);
const project = data.projects["group-1"];
// Level names should be migrated (dev→developer, qa→tester, medior→medior, reviewer→medior)
assert.strictEqual(project.workers.developer.level, "medior");
assert.strictEqual(project.workers.tester.level, "medior");
assert.strictEqual(project.workers.architect.level, "senior");
// Session keys should be migrated
assert.strictEqual(project.workers.developer.sessions.medior, "key-1");
assert.strictEqual(project.workers.tester.sessions.medior, "key-2");
assert.strictEqual(project.workers.architect.sessions.senior, "key-3");
await fs.rm(tmpDir, { recursive: true });
});
it("should read new format (workers map) correctly", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
const newFormat = {
projects: {
"group-1": {
name: "modern",
repo: "~/git/modern",
groupName: "Modern",
deployUrl: "",
baseBranch: "main",
deployBranch: "main",
workers: {
developer: { active: true, issueId: "10", startTime: null, level: "senior", sessions: { senior: "key-s" } },
tester: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
},
},
},
};
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(newFormat), "utf-8");
const data = await readProjects(tmpDir);
const project = data.projects["group-1"];
assert.ok(project.workers.developer);
assert.strictEqual(project.workers.developer.active, true);
assert.strictEqual(project.workers.developer.level, "senior");
assert.ok(project.workers.tester);
await fs.rm(tmpDir, { recursive: true });
});
it("should migrate old worker keys in new format", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
// Workers map but with old role keys
const mixedFormat = {
projects: {
"group-1": {
name: "mixed",
repo: "~/git/mixed",
groupName: "Mixed",
deployUrl: "",
baseBranch: "main",
deployBranch: "main",
workers: {
dev: { active: true, issueId: "10", startTime: null, level: "mid", sessions: { mid: "key-m" } },
qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
},
},
},
};
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8");
const data = await readProjects(tmpDir);
const project = data.projects["group-1"];
// Old keys should be migrated
assert.ok(project.workers.developer, "dev should be migrated to developer");
assert.ok(project.workers.tester, "qa should be migrated to tester");
assert.strictEqual(project.workers.developer.level, "medior");
assert.strictEqual(project.workers.developer.sessions.medior, "key-m");
await fs.rm(tmpDir, { recursive: true });
});
});
describe("getWorker", () => {
it("should return worker from workers map", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
const data: ProjectsData = {
projects: {
"g1": {
name: "test",
repo: "~/git/test",
groupName: "Test",
deployUrl: "",
baseBranch: "main",
deployBranch: "main",
workers: {
developer: { active: true, issueId: "5", startTime: null, level: "medior", sessions: {} },
},
},
},
};
const worker = getWorker(data.projects["g1"], "developer");
assert.strictEqual(worker.active, true);
assert.strictEqual(worker.issueId, "5");
await fs.rm(tmpDir, { recursive: true });
});
it("should return empty worker for unknown role", async () => {
const data: ProjectsData = {
projects: {
"g1": {
name: "test",
repo: "~/git/test",
groupName: "Test",
deployUrl: "",
baseBranch: "main",
deployBranch: "main",
workers: {},
},
},
};
const worker = getWorker(data.projects["g1"], "nonexistent");
assert.strictEqual(worker.active, false);
assert.strictEqual(worker.issueId, null);
});
});
describe("writeProjects round-trip", () => {
it("should preserve workers map through write/read cycle", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
const data: ProjectsData = {
projects: {
"g1": {
name: "roundtrip",
repo: "~/git/rt",
groupName: "RT",
deployUrl: "",
baseBranch: "main",
deployBranch: "main",
workers: {
developer: emptyWorkerState(["junior", "medior", "senior"]),
tester: emptyWorkerState(["junior", "medior", "senior"]),
architect: emptyWorkerState(["junior", "senior"]),
},
},
},
};
await writeProjects(tmpDir, data);
const loaded = await readProjects(tmpDir);
const project = loaded.projects["g1"];
assert.ok(project.workers.developer);
assert.ok(project.workers.tester);
assert.ok(project.workers.architect);
assert.strictEqual(project.workers.developer.sessions.junior, null);
assert.strictEqual(project.workers.developer.sessions.medior, null);
assert.strictEqual(project.workers.developer.sessions.senior, null);
await fs.rm(tmpDir, { recursive: true });
});
});

View File

@@ -5,6 +5,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { homedir } from "node:os";
import { LEVEL_ALIASES, ROLE_ALIASES } from "./roles/index.js";
export type WorkerState = {
active: boolean;
issueId: string | null;
@@ -24,38 +25,28 @@ export type Project = {
channel?: string;
/** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */
provider?: "github" | "gitlab";
/** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */
/** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */
roleExecution?: "parallel" | "sequential";
maxDevWorkers?: number;
maxQaWorkers?: number;
dev: WorkerState;
qa: WorkerState;
architect: WorkerState;
/** Worker state per role (developer, tester, architect, or custom roles). */
workers: Record<string, WorkerState>;
};
export type ProjectsData = {
projects: Record<string, Project>;
};
/**
* Level migration aliases: old name → new canonical name, keyed by role.
*/
const LEVEL_MIGRATION: Record<string, Record<string, string>> = {
dev: { medior: "mid" },
qa: { reviewer: "mid", tester: "junior" },
architect: { opus: "senior", sonnet: "junior" },
};
function migrateLevel(level: string | null, role: string): string | null {
if (!level) return null;
return LEVEL_MIGRATION[role]?.[level] ?? level;
return LEVEL_ALIASES[role]?.[level] ?? level;
}
function migrateSessions(
sessions: Record<string, string | null>,
role: string,
): Record<string, string | null> {
const aliases = LEVEL_MIGRATION[role];
const aliases = LEVEL_ALIASES[role];
if (!aliases) return sessions;
const migrated: Record<string, string | null> = {};
@@ -114,15 +105,33 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
const data = JSON.parse(raw) as ProjectsData;
for (const project of Object.values(data.projects)) {
project.dev = project.dev
? parseWorkerState(project.dev as unknown as Record<string, unknown>, "dev")
: emptyWorkerState([]);
project.qa = project.qa
? parseWorkerState(project.qa as unknown as Record<string, unknown>, "qa")
: emptyWorkerState([]);
project.architect = project.architect
? parseWorkerState(project.architect as unknown as Record<string, unknown>, "architect")
// Migrate old format: hardcoded dev/qa/architect fields → workers map
const raw = project as unknown as Record<string, unknown>;
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<string, unknown>, 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<string, Record<string, unknown>>;
project.workers = {};
for (const [role, worker] of Object.entries(workers)) {
// Migrate old role keys (dev→developer, qa→tester)
const canonical = ROLE_ALIASES[role] ?? role;
project.workers[canonical] = parseWorkerState(worker, role);
}
} else {
project.workers = {};
}
if (!project.channel) {
project.channel = "telegram";
}
@@ -150,9 +159,9 @@ export function getProject(
export function getWorker(
project: Project,
role: "dev" | "qa" | "architect",
role: string,
): WorkerState {
return project[role];
return project.workers[role] ?? emptyWorkerState([]);
}
/**
@@ -162,7 +171,7 @@ export function getWorker(
export async function updateWorker(
workspaceDir: string,
groupId: string,
role: "dev" | "qa" | "architect",
role: string,
updates: Partial<WorkerState>,
): Promise<ProjectsData> {
const data = await readProjects(workspaceDir);
@@ -171,13 +180,13 @@ export async function updateWorker(
throw new Error(`Project not found for groupId: ${groupId}`);
}
const worker = project[role];
const worker = project.workers[role] ?? emptyWorkerState([]);
if (updates.sessions && worker.sessions) {
updates.sessions = { ...worker.sessions, ...updates.sessions };
}
project[role] = { ...worker, ...updates };
project.workers[role] = { ...worker, ...updates };
await writeProjects(workspaceDir, data);
return data;
@@ -190,7 +199,7 @@ export async function updateWorker(
export async function activateWorker(
workspaceDir: string,
groupId: string,
role: "dev" | "qa" | "architect",
role: string,
params: {
issueId: string;
level: string;
@@ -220,7 +229,7 @@ export async function activateWorker(
export async function deactivateWorker(
workspaceDir: string,
groupId: string,
role: "dev" | "qa" | "architect",
role: string,
): Promise<ProjectsData> {
return updateWorker(workspaceDir, groupId, role, {
active: false,

View File

@@ -2,34 +2,13 @@
* IssueProvider — Abstract interface for issue tracker operations.
*
* Implementations: GitHub (gh CLI), GitLab (glab CLI).
*
* Note: STATE_LABELS and LABEL_COLORS are kept for backward compatibility
* but new code should use the workflow config via lib/workflow.ts.
*/
import { DEFAULT_WORKFLOW, getStateLabels, getLabelColors } from "../workflow.js";
// ---------------------------------------------------------------------------
// State labels — derived from default workflow for backward compatibility
// ---------------------------------------------------------------------------
/**
* @deprecated Use workflow.getStateLabels() instead.
* Kept for backward compatibility with existing code.
*/
export const STATE_LABELS = getStateLabels(DEFAULT_WORKFLOW) as readonly string[];
/**
* StateLabel type — union of all valid state labels.
* This remains a string type for flexibility with custom workflows.
* StateLabel type — string for flexibility with custom workflows.
*/
export type StateLabel = string;
/**
* @deprecated Use workflow.getLabelColors() instead.
* Kept for backward compatibility with existing code.
*/
export const LABEL_COLORS: Record<string, string> = getLabelColors(DEFAULT_WORKFLOW);
// ---------------------------------------------------------------------------
// Issue types
// ---------------------------------------------------------------------------
@@ -70,6 +49,3 @@ export interface IssueProvider {
addComment(issueId: number, body: string): Promise<void>;
healthCheck(): Promise<boolean>;
}
/** @deprecated Use IssueProvider */
export type TaskManager = IssueProvider;

View File

@@ -13,7 +13,11 @@ export {
isValidRole,
getRole,
requireRole,
// Role aliases
ROLE_ALIASES,
canonicalRole,
// Level aliases
LEVEL_ALIASES,
canonicalLevel,
// Levels
getLevelsForRole,

View File

@@ -29,23 +29,23 @@ import {
describe("role registry", () => {
it("should have all expected roles", () => {
const ids = getAllRoleIds();
assert.ok(ids.includes("dev"));
assert.ok(ids.includes("qa"));
assert.ok(ids.includes("developer"));
assert.ok(ids.includes("tester"));
assert.ok(ids.includes("architect"));
});
it("should validate role IDs", () => {
assert.strictEqual(isValidRole("dev"), true);
assert.strictEqual(isValidRole("qa"), true);
assert.strictEqual(isValidRole("developer"), true);
assert.strictEqual(isValidRole("tester"), true);
assert.strictEqual(isValidRole("architect"), true);
assert.strictEqual(isValidRole("nonexistent"), false);
});
it("should get role config", () => {
const dev = getRole("dev");
const dev = getRole("developer");
assert.ok(dev);
assert.strictEqual(dev.id, "dev");
assert.strictEqual(dev.displayName, "DEV");
assert.strictEqual(dev.id, "developer");
assert.strictEqual(dev.displayName, "DEVELOPER");
});
it("should throw for unknown role in requireRole", () => {
@@ -55,8 +55,8 @@ describe("role registry", () => {
describe("levels", () => {
it("should return levels for each role", () => {
assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "mid", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("qa")], ["junior", "mid", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]);
});
@@ -67,42 +67,49 @@ describe("levels", () => {
it("should return all levels", () => {
const all = getAllLevels();
assert.ok(all.includes("junior"));
assert.ok(all.includes("mid"));
assert.ok(all.includes("medior"));
assert.ok(all.includes("senior"));
});
it("should check level membership", () => {
assert.strictEqual(isLevelForRole("junior", "dev"), true);
assert.strictEqual(isLevelForRole("junior", "qa"), true);
assert.strictEqual(isLevelForRole("junior", "developer"), true);
assert.strictEqual(isLevelForRole("junior", "tester"), true);
assert.strictEqual(isLevelForRole("junior", "architect"), true);
assert.strictEqual(isLevelForRole("mid", "dev"), true);
assert.strictEqual(isLevelForRole("mid", "architect"), false);
assert.strictEqual(isLevelForRole("medior", "developer"), true);
assert.strictEqual(isLevelForRole("medior", "architect"), false);
});
it("should find role for level", () => {
// "junior" appears in dev first (registry order)
assert.strictEqual(roleForLevel("junior"), "dev");
assert.strictEqual(roleForLevel("mid"), "dev");
assert.strictEqual(roleForLevel("senior"), "dev");
// "junior" appears in developer first (registry order)
assert.strictEqual(roleForLevel("junior"), "developer");
assert.strictEqual(roleForLevel("medior"), "developer");
assert.strictEqual(roleForLevel("senior"), "developer");
assert.strictEqual(roleForLevel("nonexistent"), undefined);
});
it("should return default level", () => {
assert.strictEqual(getDefaultLevel("dev"), "mid");
assert.strictEqual(getDefaultLevel("qa"), "mid");
assert.strictEqual(getDefaultLevel("developer"), "medior");
assert.strictEqual(getDefaultLevel("tester"), "medior");
assert.strictEqual(getDefaultLevel("architect"), "junior");
});
});
describe("level aliases", () => {
it("should map old dev level names", () => {
assert.strictEqual(canonicalLevel("dev", "medior"), "mid");
assert.strictEqual(canonicalLevel("dev", "junior"), "junior");
assert.strictEqual(canonicalLevel("dev", "senior"), "senior");
it("should map old developer level names", () => {
assert.strictEqual(canonicalLevel("developer", "mid"), "medior");
assert.strictEqual(canonicalLevel("developer", "junior"), "junior");
assert.strictEqual(canonicalLevel("developer", "senior"), "senior");
});
it("should map old qa level names", () => {
assert.strictEqual(canonicalLevel("qa", "reviewer"), "mid");
it("should map old dev role level names", () => {
assert.strictEqual(canonicalLevel("dev", "mid"), "medior");
assert.strictEqual(canonicalLevel("dev", "medior"), "medior");
});
it("should map old qa/tester level names", () => {
assert.strictEqual(canonicalLevel("tester", "mid"), "medior");
assert.strictEqual(canonicalLevel("tester", "reviewer"), "medior");
assert.strictEqual(canonicalLevel("qa", "reviewer"), "medior");
assert.strictEqual(canonicalLevel("qa", "tester"), "junior");
});
@@ -112,63 +119,69 @@ describe("level aliases", () => {
});
it("should pass through unknown levels", () => {
assert.strictEqual(canonicalLevel("dev", "custom"), "custom");
assert.strictEqual(canonicalLevel("developer", "custom"), "custom");
assert.strictEqual(canonicalLevel("unknown", "whatever"), "whatever");
});
});
describe("models", () => {
it("should return default models", () => {
assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5");
assert.strictEqual(getDefaultModel("dev", "mid"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("qa", "mid"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5");
assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
});
it("should return all default models", () => {
const models = getAllDefaultModels();
assert.ok(models.dev);
assert.ok(models.qa);
assert.ok(models.developer);
assert.ok(models.tester);
assert.ok(models.architect);
assert.strictEqual(models.dev.junior, "anthropic/claude-haiku-4-5");
assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5");
});
it("should resolve from config override", () => {
const config = { models: { dev: { junior: "custom/model" } } };
assert.strictEqual(resolveModel("dev", "junior", config), "custom/model");
const config = { models: { developer: { junior: "custom/model" } } };
assert.strictEqual(resolveModel("developer", "junior", config), "custom/model");
});
it("should fall back to default", () => {
assert.strictEqual(resolveModel("dev", "junior"), "anthropic/claude-haiku-4-5");
assert.strictEqual(resolveModel("developer", "junior"), "anthropic/claude-haiku-4-5");
});
it("should pass through unknown level as model ID", () => {
assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5");
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5");
});
it("should resolve old config keys via aliases", () => {
// Old config uses "medior" key — should still resolve
const config = { models: { dev: { medior: "custom/old-config-model" } } };
assert.strictEqual(resolveModel("dev", "medior", config), "custom/old-config-model");
// Old config uses "mid" key — should still resolve via alias
const config = { models: { developer: { mid: "custom/old-config-model" } } };
assert.strictEqual(resolveModel("developer", "mid", config), "custom/old-config-model");
// Also works when requesting the canonical name
assert.strictEqual(resolveModel("dev", "mid", {}), "anthropic/claude-sonnet-4-5");
assert.strictEqual(resolveModel("developer", "medior", {}), "anthropic/claude-sonnet-4-5");
});
it("should resolve old role name config keys", () => {
// Old config uses "dev" role key — should still resolve via role alias
const config = { models: { dev: { junior: "custom/model" } } };
assert.strictEqual(resolveModel("developer", "junior", config), "custom/model");
});
it("should resolve old qa config keys", () => {
const config = { models: { qa: { reviewer: "custom/qa-model" } } };
assert.strictEqual(resolveModel("qa", "reviewer", config), "custom/qa-model");
assert.strictEqual(resolveModel("tester", "reviewer", config), "custom/qa-model");
});
});
describe("emoji", () => {
it("should return level emoji", () => {
assert.strictEqual(getEmoji("dev", "junior"), "⚡");
assert.strictEqual(getEmoji("developer", "junior"), "⚡");
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
});
it("should return fallback emoji", () => {
assert.strictEqual(getFallbackEmoji("dev"), "🔧");
assert.strictEqual(getFallbackEmoji("qa"), "🔍");
assert.strictEqual(getFallbackEmoji("developer"), "🔧");
assert.strictEqual(getFallbackEmoji("tester"), "🔍");
assert.strictEqual(getFallbackEmoji("architect"), "🏗️");
assert.strictEqual(getFallbackEmoji("nonexistent"), "📋");
});
@@ -176,32 +189,32 @@ describe("emoji", () => {
describe("completion results", () => {
it("should return valid results per role", () => {
assert.deepStrictEqual([...getCompletionResults("dev")], ["done", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("qa")], ["pass", "fail", "refine", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
});
it("should validate results", () => {
assert.strictEqual(isValidResult("dev", "done"), true);
assert.strictEqual(isValidResult("dev", "pass"), false);
assert.strictEqual(isValidResult("qa", "pass"), true);
assert.strictEqual(isValidResult("qa", "done"), false);
assert.strictEqual(isValidResult("developer", "done"), true);
assert.strictEqual(isValidResult("developer", "pass"), false);
assert.strictEqual(isValidResult("tester", "pass"), true);
assert.strictEqual(isValidResult("tester", "done"), false);
});
});
describe("session key pattern", () => {
it("should generate pattern matching all roles", () => {
const pattern = getSessionKeyRolePattern();
assert.ok(pattern.includes("dev"));
assert.ok(pattern.includes("qa"));
assert.ok(pattern.includes("developer"));
assert.ok(pattern.includes("tester"));
assert.ok(pattern.includes("architect"));
});
it("should work as regex", () => {
const pattern = getSessionKeyRolePattern();
const regex = new RegExp(`(${pattern})`);
assert.ok(regex.test("dev"));
assert.ok(regex.test("qa"));
assert.ok(regex.test("developer"));
assert.ok(regex.test("tester"));
assert.ok(regex.test("architect"));
assert.ok(!regex.test("nonexistent"));
});

View File

@@ -14,45 +14,45 @@
import type { RoleConfig } from "./types.js";
export const ROLE_REGISTRY: Record<string, RoleConfig> = {
dev: {
id: "dev",
displayName: "DEV",
levels: ["junior", "mid", "senior"],
defaultLevel: "mid",
developer: {
id: "developer",
displayName: "DEVELOPER",
levels: ["junior", "medior", "senior"],
defaultLevel: "medior",
models: {
junior: "anthropic/claude-haiku-4-5",
mid: "anthropic/claude-sonnet-4-5",
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
},
emoji: {
junior: "⚡",
mid: "🔧",
medior: "🔧",
senior: "🧠",
},
fallbackEmoji: "🔧",
completionResults: ["done", "blocked"],
sessionKeyPattern: "dev",
sessionKeyPattern: "developer",
notifications: { onStart: true, onComplete: true },
},
qa: {
id: "qa",
displayName: "QA",
levels: ["junior", "mid", "senior"],
defaultLevel: "mid",
tester: {
id: "tester",
displayName: "TESTER",
levels: ["junior", "medior", "senior"],
defaultLevel: "medior",
models: {
junior: "anthropic/claude-haiku-4-5",
mid: "anthropic/claude-sonnet-4-5",
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
},
emoji: {
junior: "⚡",
mid: "🔍",
medior: "🔍",
senior: "🧠",
},
fallbackEmoji: "🔍",
completionResults: ["pass", "fail", "refine", "blocked"],
sessionKeyPattern: "qa",
sessionKeyPattern: "tester",
notifications: { onStart: true, onComplete: true },
},

View File

@@ -6,6 +6,7 @@
*/
import { ROLE_REGISTRY } from "./registry.js";
import type { RoleConfig } from "./types.js";
import type { ResolvedRoleConfig } from "../config/types.js";
// ---------------------------------------------------------------------------
// Role IDs
@@ -36,13 +37,31 @@ export function requireRole(role: string): RoleConfig {
return config;
}
// ---------------------------------------------------------------------------
// Role aliases — maps old role IDs to new canonical IDs
// ---------------------------------------------------------------------------
/** Maps old role IDs to canonical IDs. Used for backward compatibility. */
export const ROLE_ALIASES: Record<string, string> = {
dev: "developer",
qa: "tester",
};
/** Resolve a role ID, applying aliases for backward compatibility. */
export function canonicalRole(role: string): string {
return ROLE_ALIASES[role] ?? role;
}
// ---------------------------------------------------------------------------
// Level aliases — maps old level names to new canonical names
// ---------------------------------------------------------------------------
const LEVEL_ALIASES: Record<string, Record<string, string>> = {
dev: { medior: "mid" },
qa: { reviewer: "mid", tester: "junior" },
/** Maps old level names to canonical names, per role. Used for backward compatibility. */
export const LEVEL_ALIASES: Record<string, Record<string, string>> = {
developer: { mid: "medior", medior: "medior" },
dev: { mid: "medior", medior: "medior" },
tester: { mid: "medior", reviewer: "medior", tester: "junior" },
qa: { mid: "medior", reviewer: "medior", tester: "junior" },
architect: { opus: "senior", sonnet: "junior" },
};
@@ -105,23 +124,32 @@ export function getAllDefaultModels(): Record<string, Record<string, string>> {
* Resolve a level to a full model ID.
*
* Resolution order:
* 1. Plugin config `models.<role>.<level>` (tries canonical name, then original)
* 2. Registry default model
* 3. Passthrough (treat level as raw model ID)
* 1. Plugin config `models.<role>.<level>` in openclaw.json (highest precedence)
* 2. Resolved config from config.yaml (if provided)
* 3. Registry default model
* 4. Passthrough (treat level as raw model ID)
*/
export function resolveModel(
role: string,
level: string,
pluginConfig?: Record<string, unknown>,
resolvedRole?: ResolvedRoleConfig,
): string {
const canonical = canonicalLevel(role, level);
// 1. Plugin config override (openclaw.json) — check canonical role + old aliases
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
if (models && typeof models === "object") {
const roleModels = models[role] as Record<string, string> | undefined;
// Try canonical name first, then original (for old configs)
// Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer")
const roleModels = (models[role] ?? models[Object.entries(ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record<string, string> | undefined;
if (roleModels?.[canonical]) return roleModels[canonical];
if (roleModels?.[level]) return roleModels[level];
}
// 2. Resolved config (config.yaml)
if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical];
// 3. Built-in registry default
return getDefaultModel(role, canonical) ?? canonical;
}

View File

@@ -7,7 +7,7 @@
/** Configuration for a single worker role. */
export type RoleConfig = {
/** Unique role identifier (e.g., "dev", "qa", "architect"). */
/** Unique role identifier (e.g., "developer", "tester", "architect"). */
id: string;
/** Human-readable display name. */
displayName: string;
@@ -23,7 +23,7 @@ export type RoleConfig = {
fallbackEmoji: string;
/** Valid completion results for this role. */
completionResults: readonly string[];
/** Regex pattern fragment for session key matching (e.g., "dev|qa|architect"). */
/** Regex pattern fragment for session key matching (e.g., "developer|tester|architect"). */
sessionKeyPattern: string;
/** Notification config per event type. */
notifications: {

View File

@@ -18,7 +18,6 @@ import { log as auditLog } from "../audit.js";
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
import { projectTick } from "./tick.js";
import { createProvider } from "../providers/index.js";
import { getAllRoleIds } from "../roles/index.js";
// ---------------------------------------------------------------------------
// Types
@@ -307,13 +306,13 @@ async function performHealthPass(
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
let fixedCount = 0;
for (const role of getAllRoleIds()) {
for (const role of Object.keys(project.workers)) {
// Check worker health (session liveness, label consistency, etc)
const healthFixes = await checkWorkerHealth({
workspaceDir,
groupId,
project,
role: role as any,
role,
sessions,
autoFix: true,
provider,
@@ -325,7 +324,7 @@ async function performHealthPass(
workspaceDir,
groupId,
project,
role: role as any,
role,
autoFix: true,
provider,
});
@@ -336,10 +335,10 @@ async function performHealthPass(
}
/**
* Check if a project has active work (dev or qa).
* Check if a project has any active worker.
*/
async function checkProjectActive(workspaceDir: string, groupId: string): Promise<boolean> {
const fresh = (await readProjects(workspaceDir)).projects[groupId];
if (!fresh) return false;
return fresh.dev.active || fresh.qa.active;
return Object.values(fresh.workers).some(w => w.active);
}

View File

@@ -17,40 +17,6 @@ import {
type WorkflowConfig,
} from "../workflow.js";
// ---------------------------------------------------------------------------
// Backward compatibility exports
// ---------------------------------------------------------------------------
/**
* @deprecated Use getCompletionRule() from workflow.ts instead.
* Kept for backward compatibility.
*/
export const COMPLETION_RULES: Record<string, CompletionRule> = {
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
"qa:pass": { from: "Testing", to: "Done", closeIssue: true },
"qa:fail": { from: "Testing", to: "To Improve", reopenIssue: true },
"qa:refine": { from: "Testing", to: "Refining" },
"dev:blocked": { from: "Doing", to: "Refining" },
"qa:blocked": { from: "Testing", to: "Refining" },
"architect:done": { from: "Designing", to: "Planning" },
"architect:blocked": { from: "Designing", to: "Refining" },
};
/**
* @deprecated Use getNextStateDescription() from workflow.ts instead.
*/
export const NEXT_STATE: Record<string, string> = {
"dev:done": "QA queue",
"dev:blocked": "moved to Refining - needs human input",
"qa:pass": "Done!",
"qa:fail": "back to DEV",
"qa:refine": "awaiting human decision",
"qa:blocked": "moved to Refining - needs human input",
"architect:done": "Planning — ready for review",
"architect:blocked": "moved to Refining - needs clarification",
};
// Re-export CompletionRule type for backward compatibility
export type { CompletionRule };
export type CompletionOutput = {
@@ -72,7 +38,7 @@ export function getRule(
result: string,
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
): CompletionRule | undefined {
return getCompletionRule(workflow, role as "dev" | "qa", result) ?? undefined;
return getCompletionRule(workflow, role, result) ?? undefined;
}
/**
@@ -81,7 +47,7 @@ export function getRule(
export async function executeCompletion(opts: {
workspaceDir: string;
groupId: string;
role: "dev" | "qa" | "architect";
role: string;
result: string;
issueId: number;
summary?: string;

View File

@@ -13,26 +13,6 @@ import {
type Role,
} from "../workflow.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* @deprecated Use string labels from workflow config instead.
* Kept for backward compatibility.
*/
export type QueueLabel = "To Improve" | "To Test" | "To Do";
/**
* @deprecated Use getQueuePriority() instead.
* Kept for backward compatibility.
*/
export const QUEUE_PRIORITY: Record<string, number> = {
"To Improve": 3,
"To Test": 2,
"To Do": 1,
};
// ---------------------------------------------------------------------------
// Workflow-driven helpers
// ---------------------------------------------------------------------------

View File

@@ -11,9 +11,9 @@ import { createProvider } from "../providers/index.js";
import { selectLevel } from "../model-selector.js";
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { getAllRoleIds, getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js";
import { getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js";
import { loadConfig } from "../config/index.js";
import {
DEFAULT_WORKFLOW,
getQueueLabels,
getAllQueueLabels,
getActiveLabel,
@@ -22,25 +22,6 @@ import {
type Role,
} from "../workflow.js";
// ---------------------------------------------------------------------------
// Backward compatibility exports (deprecated)
// ---------------------------------------------------------------------------
/**
* @deprecated Use getQueueLabels(workflow, "dev") instead.
*/
export const DEV_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "dev");
/**
* @deprecated Use getQueueLabels(workflow, "qa") instead.
*/
export const QA_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "qa");
/**
* @deprecated Use getAllQueueLabels(workflow) instead.
*/
export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW);
// ---------------------------------------------------------------------------
// Shared helpers (used by tick, work-start, auto-pickup)
// ---------------------------------------------------------------------------
@@ -68,7 +49,7 @@ export function detectLevelFromLabels(labels: string[]): string | null {
*/
export function detectRoleFromLabel(
label: StateLabel,
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
workflow: WorkflowConfig,
): Role | null {
return workflowDetectRole(workflow, label);
}
@@ -76,7 +57,7 @@ export function detectRoleFromLabel(
export async function findNextIssueForRole(
provider: Pick<IssueProvider, "listIssuesByLabel">,
role: Role,
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
workflow: WorkflowConfig,
): Promise<{ issue: Issue; label: StateLabel } | null> {
const labels = getQueueLabels(workflow, role);
for (const label of labels) {
@@ -93,8 +74,8 @@ export async function findNextIssueForRole(
*/
export async function findNextIssue(
provider: Pick<IssueProvider, "listIssuesByLabel">,
role?: Role,
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
role: Role | undefined,
workflow: WorkflowConfig,
): Promise<{ issue: Issue; label: StateLabel } | null> {
const labels = role
? getQueueLabels(workflow, role)
@@ -156,15 +137,20 @@ export async function projectTick(opts: {
const {
workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun,
maxPickups, targetRole, runtime,
workflow = DEFAULT_WORKFLOW,
} = opts;
const project = (await readProjects(workspaceDir)).projects[groupId];
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
const resolvedConfig = await loadConfig(workspaceDir, project.name);
const workflow = opts.workflow ?? resolvedConfig.workflow;
const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider;
const roleExecution = project.roleExecution ?? "parallel";
const roles: Role[] = targetRole ? [targetRole] : getAllRoleIds() as Role[];
const enabledRoles = Object.entries(resolvedConfig.roles)
.filter(([, r]) => r.enabled)
.map(([id]) => id);
const roles: Role[] = targetRole ? [targetRole] : enabledRoles;
const pickups: TickAction[] = [];
const skipped: TickResult["skipped"] = [];
@@ -186,8 +172,8 @@ export async function projectTick(opts: {
continue;
}
// Check sequential role execution: any other role must be inactive
const otherRoles = getAllRoleIds().filter(r => r !== role);
if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r as any).active)) {
const otherRoles = enabledRoles.filter((r: string) => r !== role);
if (roleExecution === "sequential" && otherRoles.some((r: string) => getWorker(fresh, r).active)) {
skipped.push({ role, reason: "Sequential: other role active" });
continue;
}

View File

@@ -6,7 +6,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
type ModelConfig = { dev: Record<string, string>; qa: Record<string, string>; architect: Record<string, string> };
type ModelConfig = Record<string, Record<string, string>>;
/**
* Write DevClaw model level config to openclaw.json plugins section.

View File

@@ -5,13 +5,13 @@
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { DEFAULT_MODELS } from "../tiers.js";
import { getAllDefaultModels } from "../roles/index.js";
import { migrateChannelBinding } from "../binding-manager.js";
import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js";
import { scaffoldWorkspace } from "./workspace.js";
export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string>; architect: Record<string, string> };
export type ModelConfig = Record<string, Record<string, string>>;
export type SetupOpts = {
/** OpenClaw plugin API for config access. */
@@ -27,7 +27,7 @@ export type SetupOpts = {
/** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string;
/** Model overrides per role.level. Missing levels use defaults. */
models?: { dev?: Partial<Record<string, string>>; qa?: Partial<Record<string, string>>; architect?: Partial<Record<string, string>> };
models?: Record<string, Partial<Record<string, string>>>;
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential";
};
@@ -113,25 +113,21 @@ async function tryMigrateBinding(
}
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
const dev: Record<string, string> = { ...DEFAULT_MODELS.dev };
const qa: Record<string, string> = { ...DEFAULT_MODELS.qa };
const architect: Record<string, string> = { ...DEFAULT_MODELS.architect };
const defaults = getAllDefaultModels();
const result: ModelConfig = {};
if (overrides?.dev) {
for (const [level, model] of Object.entries(overrides.dev)) {
if (model) dev[level] = model;
for (const [role, levels] of Object.entries(defaults)) {
result[role] = { ...levels };
}
if (overrides) {
for (const [role, roleOverrides] of Object.entries(overrides)) {
if (!result[role]) result[role] = {};
for (const [level, model] of Object.entries(roleOverrides)) {
if (model) result[role][level] = model;
}
if (overrides?.qa) {
for (const [level, model] of Object.entries(overrides.qa)) {
if (model) qa[level] = model;
}
}
if (overrides?.architect) {
for (const [level, model] of Object.entries(overrides.architect)) {
if (model) architect[level] = model;
}
}
return { dev, qa, architect };
return result;
}

View File

@@ -6,14 +6,14 @@
import { runCommand } from "../run-command.js";
export type ModelAssignment = {
dev: {
developer: {
junior: string;
mid: string;
medior: string;
senior: string;
};
qa: {
tester: {
junior: string;
mid: string;
medior: string;
senior: string;
};
architect: {
@@ -37,8 +37,8 @@ export async function selectModelsWithLLM(
if (availableModels.length === 1) {
const model = availableModels[0].model;
return {
dev: { junior: model, mid: model, senior: model },
qa: { junior: model, mid: model, senior: model },
developer: { junior: model, medior: model, senior: model },
tester: { junior: model, medior: model, senior: model },
architect: { junior: model, senior: model },
};
}
@@ -53,27 +53,27 @@ ${modelList}
All roles use the same level scheme based on task complexity:
- **senior** (most capable): Complex architecture, refactoring, critical decisions
- **mid** (balanced): Features, bug fixes, code review, standard tasks
- **medior** (balanced): Features, bug fixes, code review, standard tasks
- **junior** (fast/efficient): Simple fixes, routine tasks
Rules:
1. Prefer same provider for consistency
2. Assign most capable model to senior
3. Assign mid-tier model to mid
3. Assign mid-tier model to medior
4. Assign fastest/cheapest model to junior
5. Consider model version numbers (higher = newer/better)
6. Stable versions (no date) > snapshot versions (with date like 20250514)
Return ONLY a JSON object in this exact format (no markdown, no explanation):
{
"dev": {
"developer": {
"junior": "provider/model-name",
"mid": "provider/model-name",
"medior": "provider/model-name",
"senior": "provider/model-name"
},
"qa": {
"tester": {
"junior": "provider/model-name",
"mid": "provider/model-name",
"medior": "provider/model-name",
"senior": "provider/model-name"
},
"architect": {
@@ -131,18 +131,18 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
// Backfill architect if LLM didn't return it (graceful upgrade)
if (!assignment.architect) {
assignment.architect = {
senior: assignment.dev?.senior ?? availableModels[0].model,
junior: assignment.dev?.mid ?? availableModels[0].model,
senior: assignment.developer?.senior ?? availableModels[0].model,
junior: assignment.developer?.medior ?? availableModels[0].model,
};
}
if (
!assignment.dev?.junior ||
!assignment.dev?.mid ||
!assignment.dev?.senior ||
!assignment.qa?.junior ||
!assignment.qa?.mid ||
!assignment.qa?.senior
!assignment.developer?.junior ||
!assignment.developer?.medior ||
!assignment.developer?.senior ||
!assignment.tester?.junior ||
!assignment.tester?.medior ||
!assignment.tester?.senior
) {
console.error("Invalid assignment structure. Got:", assignment);
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);

View File

@@ -5,14 +5,14 @@
*/
export type ModelAssignment = {
dev: {
developer: {
junior: string;
mid: string;
medior: string;
senior: string;
};
qa: {
tester: {
junior: string;
mid: string;
medior: string;
senior: string;
};
architect: {
@@ -44,8 +44,8 @@ export async function assignModels(
if (authenticated.length === 1) {
const model = authenticated[0].model;
return {
dev: { junior: model, mid: model, senior: model },
qa: { junior: model, mid: model, senior: model },
developer: { junior: model, medior: model, senior: model },
tester: { junior: model, medior: model, senior: model },
architect: { junior: model, senior: model },
};
}
@@ -67,15 +67,15 @@ export async function assignModels(
export function formatAssignment(assignment: ModelAssignment): string {
const lines = [
"| Role | Level | Model |",
"|------|----------|--------------------------|",
`| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`,
`| DEV | mid | ${assignment.dev.mid.padEnd(24)} |`,
`| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`,
`| QA | senior | ${assignment.qa.senior.padEnd(24)} |`,
`| QA | mid | ${assignment.qa.mid.padEnd(24)} |`,
`| QA | junior | ${assignment.qa.junior.padEnd(24)} |`,
`| ARCH | senior | ${assignment.architect.senior.padEnd(24)} |`,
`| ARCH | junior | ${assignment.architect.junior.padEnd(24)} |`,
"|-----------|----------|--------------------------|",
`| DEVELOPER | senior | ${assignment.developer.senior.padEnd(24)} |`,
`| DEVELOPER | medior | ${assignment.developer.medior.padEnd(24)} |`,
`| DEVELOPER | junior | ${assignment.developer.junior.padEnd(24)} |`,
`| TESTER | senior | ${assignment.tester.senior.padEnd(24)} |`,
`| TESTER | medior | ${assignment.tester.medior.padEnd(24)} |`,
`| TESTER | junior | ${assignment.tester.junior.padEnd(24)} |`,
`| ARCHITECT | senior | ${assignment.architect.senior.padEnd(24)} |`,
`| ARCHITECT | junior | ${assignment.architect.junior.padEnd(24)} |`,
];
return lines.join("\n");
}

View File

@@ -3,7 +3,7 @@
* Used by setup and project_register.
*/
export const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions
## Context You Receive
@@ -24,19 +24,19 @@ Read the comments carefully — they often contain clarifications, decisions, or
- Create an MR/PR to the base branch and merge it
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
- Clean up the worktree after merging
- When done, call work_finish with role "dev", result "done", and a brief summary
- When done, call work_finish with role "developer", result "done", and a brief summary
- If you discover unrelated bugs, call task_create to file them
- Do NOT call work_start, status, health, or project_register
`;
export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
export const DEFAULT_QA_INSTRUCTIONS = `# TESTER Worker Instructions
- Pull latest from the base branch
- Run tests and linting
- Verify the changes address the issue requirements
- Check for regressions in related functionality
- **Always** call task_comment with your review findings — even if everything looks good, leave a brief summary of what you checked
- When done, call work_finish with role "qa" and one of:
- When done, call work_finish with role "tester" and one of:
- result "pass" if everything looks good
- result "fail" with specific issues if problems found
- result "refine" if you need human input to decide
@@ -55,7 +55,7 @@ Investigate the design problem thoroughly:
2. **Research alternatives** — Explore >= 3 viable approaches
3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit
4. **Recommend** — Pick the best option with clear reasoning
5. **Outline implementation** — Break down into dev tasks
5. **Outline implementation** — Break down into developer tasks
## Output Format
@@ -115,9 +115,16 @@ Your session is persistent — you may be called back for refinements.
Do NOT call work_start, status, health, or project_register.
`;
/** Default role instructions indexed by role ID. Used by project scaffolding. */
export const DEFAULT_ROLE_INSTRUCTIONS: Record<string, string> = {
developer: DEFAULT_DEV_INSTRUCTIONS,
tester: DEFAULT_QA_INSTRUCTIONS,
architect: DEFAULT_ARCHITECT_INSTRUCTIONS,
};
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
## If You Are a Sub-Agent (DEV/QA Worker)
## If You Are a Sub-Agent (DEVELOPER/TESTER Worker)
Skip the orchestrator section. Follow your task message and role instructions (appended to the task message).
@@ -126,21 +133,21 @@ Skip the orchestrator section. Follow your task message and role instructions (a
- Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\`
- Include issue number: \`feat: add user authentication (#12)\`
- Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\`
- **DEV always works in a git worktree** (never switch branches in the main repo)
- **DEV must merge to base branch** before announcing completion
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses QA.
- **QA tests on the deployed version** and inspects code on the base branch
- **QA always calls task_comment** with review findings before completing
- **DEVELOPER always works in a git worktree** (never switch branches in the main repo)
- **DEVELOPER must merge to base branch** before announcing completion
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses testing.
- **TESTER tests on the deployed version** and inspects code on the base branch
- **TESTER always calls task_comment** with review findings before completing
- Always run tests before completing
### Completing Your Task
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
- **DEV done:** \`work_finish({ role: "dev", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA pass:** \`work_finish({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **QA refine:** \`work_finish({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
- **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation summary>" })\`
The \`projectGroupId\` is included in your task message.
@@ -167,14 +174,14 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
**Never write code yourself.** All implementation work MUST go through the issue → worker pipeline:
1. Create an issue via \`task_create\`
2. Dispatch a DEV worker via \`work_start\`
2. Dispatch a DEVELOPER worker via \`work_start\`
3. Let the worker handle implementation, git, and PRs
**Why this matters:**
- **Audit trail** — Every code change is tracked to an issue
- **Level selection** — Junior/mid/senior models match task complexity
- **Level selection** — Junior/medior/senior models match task complexity
- **Parallelization** — Workers run in parallel, you stay free to plan
- **QA pipeline** — Code goes through review before closing
- **Testing pipeline** — Code goes through review before closing
**What you CAN do directly:**
- Planning, analysis, architecture discussions
@@ -195,7 +202,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
Examples:
- ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42"
- ✅ "Picked up #42 for DEV (mid) 🔗 https://github.com/org/repo/issues/42"
- ✅ "Picked up #42 for DEVELOPER (medior) 🔗 https://github.com/org/repo/issues/42"
- ❌ "Created issue #42 about the login bug" (missing URL)
### DevClaw Tools
@@ -232,10 +239,10 @@ Issue labels are the single source of truth for task state.
Evaluate each task and pass the appropriate developer level to \`work_start\`:
- **junior** — trivial: typos, single-file fix, quick change
- **mid** — standard: features, bug fixes, multi-file changes
- **medior** — standard: features, bug fixes, multi-file changes
- **senior** — complex: architecture, system-wide refactoring, 5+ services
All roles (DEV, QA, Architect) use the same level scheme. Levels describe task complexity, not the model.
All roles (Developer, Tester, Architect) use the same level scheme. Levels describe task complexity, not the model.
### Picking Up Work
@@ -249,10 +256,10 @@ All roles (DEV, QA, Architect) use the same level scheme. Levels describe task c
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
- DEV "done" → issue moves to "To Test" → scheduler dispatches QA
- QA "fail" → issue moves to "To Improve" → scheduler dispatches DEV
- QA "pass" → Done, no further dispatch
- QA "refine" / blocked → needs human input
- Developer "done" → issue moves to "To Test" → scheduler dispatches Tester
- Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer
- Tester "pass" → Done, no further dispatch
- Tester "refine" / blocked → needs human input
- Architect "done" → issue moves to "Planning" → ready for tech lead review
**Always include issue URLs** in your response — these are in the \`announcement\` fields.
@@ -267,10 +274,10 @@ Workers receive role-specific instructions appended to their task message. These
### Safety
- **Never write code yourself** — always dispatch a DEV worker
- **Never write code yourself** — always dispatch a Developer worker
- Don't push to main directly
- Don't force-push
- Don't close issues without QA pass
- Don't close issues without Tester pass
- Ask before architectural decisions affecting multiple projects
`;

View File

@@ -1,90 +0,0 @@
/**
* tiers.ts — Developer level definitions and model resolution.
*
* This module now delegates to the centralized role registry (lib/roles/).
* Kept for backward compatibility — new code should import from lib/roles/ directly.
*
* Level names are plain: "junior", "mid", "senior".
* Role context (dev/qa/architect) is always provided by the caller.
*/
import {
type WorkerRole,
ROLE_REGISTRY,
getLevelsForRole,
getAllDefaultModels,
roleForLevel,
getDefaultModel,
getEmoji,
resolveModel as registryResolveModel,
} from "./roles/index.js";
// Re-export WorkerRole from the registry
export type { WorkerRole };
// ---------------------------------------------------------------------------
// Level constants — derived from registry
// ---------------------------------------------------------------------------
/** @deprecated Use roles/selectors.getAllDefaultModels() */
export const DEFAULT_MODELS = getAllDefaultModels();
/** @deprecated Use roles/selectors.getEmoji() */
export const LEVEL_EMOJI: Record<string, Record<string, string>> = Object.fromEntries(
Object.entries(ROLE_REGISTRY).map(([id, config]) => [id, { ...config.emoji }]),
);
export const DEV_LEVELS = getLevelsForRole("dev") as readonly string[];
export const QA_LEVELS = getLevelsForRole("qa") as readonly string[];
export const ARCHITECT_LEVELS = getLevelsForRole("architect") as readonly string[];
export type DevLevel = string;
export type QaLevel = string;
export type ArchitectLevel = string;
export type Level = string;
// ---------------------------------------------------------------------------
// Level checks — delegate to registry
// ---------------------------------------------------------------------------
/** Check if a level belongs to the dev role. */
export function isDevLevel(value: string): boolean {
return DEV_LEVELS.includes(value);
}
/** Check if a level belongs to the qa role. */
export function isQaLevel(value: string): boolean {
return QA_LEVELS.includes(value);
}
/** Check if a level belongs to the architect role. */
export function isArchitectLevel(value: string): boolean {
return ARCHITECT_LEVELS.includes(value);
}
/** Determine the role a level belongs to. */
export function levelRole(level: string): WorkerRole | undefined {
return roleForLevel(level) as WorkerRole | undefined;
}
// ---------------------------------------------------------------------------
// Model + emoji — delegate to registry
// ---------------------------------------------------------------------------
/** @deprecated Use roles/selectors.getDefaultModel() */
export function defaultModel(role: WorkerRole, level: string): string | undefined {
return getDefaultModel(role, level);
}
/** @deprecated Use roles/selectors.getEmoji() */
export function levelEmoji(role: WorkerRole, level: string): string | undefined {
return getEmoji(role, level);
}
/** @deprecated Use roles/selectors.resolveModel() */
export function resolveModel(
role: WorkerRole,
level: string,
pluginConfig?: Record<string, unknown>,
): string {
return registryResolveModel(role, level, pluginConfig);
}

View File

@@ -5,7 +5,7 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parseDevClawSessionKey } from "../bootstrap-hook.js";
import { isArchitectLevel, levelRole, resolveModel, defaultModel, levelEmoji } from "../tiers.js";
import { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js";
import { selectLevel } from "../model-selector.js";
import {
DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule,
@@ -14,21 +14,21 @@ import {
describe("architect tiers", () => {
it("should recognize architect levels", () => {
assert.strictEqual(isArchitectLevel("junior"), true);
assert.strictEqual(isArchitectLevel("senior"), true);
assert.strictEqual(isArchitectLevel("mid"), false);
assert.strictEqual(isLevelForRole("junior", "architect"), true);
assert.strictEqual(isLevelForRole("senior", "architect"), true);
assert.strictEqual(isLevelForRole("medior", "architect"), false);
});
it("should map architect levels to role", () => {
// "junior" and "senior" appear in dev first (registry order), so roleForLevel returns "dev"
// This is expected — use isArchitectLevel for architect-specific checks
assert.strictEqual(levelRole("junior"), "dev");
assert.strictEqual(levelRole("senior"), "dev");
// "junior" and "senior" appear in developer first (registry order), so roleForLevel returns "developer"
// This is expected — use isLevelForRole for role-specific checks
assert.strictEqual(roleForLevel("junior"), "developer");
assert.strictEqual(roleForLevel("senior"), "developer");
});
it("should resolve default architect models", () => {
assert.strictEqual(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
});
it("should resolve architect model from config", () => {
@@ -37,8 +37,8 @@ describe("architect tiers", () => {
});
it("should have architect emoji", () => {
assert.strictEqual(levelEmoji("architect", "senior"), "🏗️");
assert.strictEqual(levelEmoji("architect", "junior"), "📐");
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
assert.strictEqual(getEmoji("architect", "junior"), "📐");
});
});
@@ -76,8 +76,9 @@ describe("architect workflow states", () => {
assert.strictEqual(rule!.to, "Refining");
});
it("should have architect completion emoji", () => {
assert.strictEqual(getCompletionEmoji("architect", "done"), "🏗️");
it("should have completion emoji by result type", () => {
// Emoji is now keyed by result, not role:result
assert.strictEqual(getCompletionEmoji("architect", "done"), "✅");
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
});
});

View File

@@ -13,7 +13,9 @@ import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js";
import { selectLevel } from "../model-selector.js";
import { resolveModel } from "../roles/index.js";
export function createDesignTaskTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -81,6 +83,14 @@ Example:
const { project } = await resolveProject(workspaceDir, groupId);
const { provider } = await resolveProvider(project);
const pluginConfig = getPluginConfig(api);
// Derive labels from workflow config
const workflow = await loadWorkflow(workspaceDir, project.name);
const role = "architect";
const queueLabels = getQueueLabels(workflow, role);
const queueLabel = queueLabels[0];
if (!queueLabel) throw new Error(`No queue state found for role "${role}" in workflow`);
// Build issue body with focus areas
const bodyParts = [description];
@@ -101,51 +111,48 @@ Example:
);
const issueBody = bodyParts.join("\n");
// Create issue in To Design state
const issue = await provider.createIssue(title, issueBody, "To Design" as StateLabel);
// Create issue in queue state
const issue = await provider.createIssue(title, issueBody, queueLabel as StateLabel);
await auditLog(workspaceDir, "design_task", {
project: project.name, groupId, issueId: issue.iid,
title, complexity, focusAreas, dryRun,
});
// Select level based on complexity
const level = complexity === "complex" ? "senior" : "junior";
// Select level: use complexity hint to guide the heuristic
const level = complexity === "complex"
? selectLevel(title, "system-wide " + description, role).level
: selectLevel(title, description, role).level;
const model = resolveModel(role, level, pluginConfig);
if (dryRun) {
return jsonResult({
success: true,
dryRun: true,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" },
design: {
level,
model: complexity === "complex" ? "anthropic/claude-opus-4-5" : "anthropic/claude-sonnet-4-5",
status: "dry_run",
},
announcement: `📐 [DRY RUN] Would spawn architect (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel },
design: { level, model, status: "dry_run" },
announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
});
}
// Check architect availability
const worker = getWorker(project, "architect");
// Check worker availability
const worker = getWorker(project, role);
if (worker.active) {
// Issue created but can't dispatch yet — will be picked up by heartbeat
return jsonResult({
success: true,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" },
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel },
design: {
level,
status: "queued",
reason: `Architect already active on #${worker.issueId}. Issue queued for pickup.`,
reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue queued for pickup.`,
},
announcement: `📐 Created design task #${issue.iid}: ${title} (queued — architect busy)\n🔗 ${issue.web_url}`,
announcement: `📐 Created design task #${issue.iid}: ${title} (queued — ${role} busy)\n🔗 ${issue.web_url}`,
});
}
// Dispatch architect
const workflow = DEFAULT_WORKFLOW;
const targetLabel = getActiveLabel(workflow, "architect");
const pluginConfig = getPluginConfig(api);
// Dispatch worker
const targetLabel = getActiveLabel(workflow, role);
const dr = await dispatchTask({
workspaceDir,
@@ -156,9 +163,9 @@ Example:
issueTitle: issue.title,
issueDescription: issueBody,
issueUrl: issue.web_url,
role: "architect",
role,
level,
fromLabel: "To Design",
fromLabel: queueLabel,
toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
provider,

View File

@@ -18,7 +18,6 @@ import { readProjects, getProject } from "../projects.js";
import { log as auditLog } from "../audit.js";
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js";
import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js";
import { getAllRoleIds } from "../roles/index.js";
export function createHealthTool() {
return (ctx: ToolContext) => ({
@@ -52,13 +51,13 @@ export function createHealthTool() {
if (!project) continue;
const { provider } = await resolveProvider(project);
for (const role of getAllRoleIds()) {
for (const role of Object.keys(project.workers)) {
// Worker health check (session liveness, label consistency, etc)
const healthFixes = await checkWorkerHealth({
workspaceDir,
groupId: pid,
project,
role: role as any,
role,
sessions,
autoFix: fix,
provider,
@@ -70,7 +69,7 @@ export function createHealthTool() {
workspaceDir,
groupId: pid,
project,
role: role as any,
role,
autoFix: fix,
provider,
});

View File

@@ -15,40 +15,26 @@ import { resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js";
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js";
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
/**
* Scaffold project-specific prompt files.
* Scaffold project-specific prompt files for all registered roles.
* Returns true if files were created, false if they already existed.
*/
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
await fs.mkdir(projectDir, { recursive: true });
const projectDev = path.join(projectDir, "dev.md");
const projectQa = path.join(projectDir, "qa.md");
let created = false;
for (const role of getAllRoleIds()) {
const filePath = path.join(projectDir, `${role}.md`);
try {
await fs.access(projectDev);
await fs.access(filePath);
} catch {
await fs.writeFile(projectDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`;
await fs.writeFile(filePath, content, "utf-8");
created = true;
}
try {
await fs.access(projectQa);
} catch {
await fs.writeFile(projectQa, DEFAULT_QA_INSTRUCTIONS, "utf-8");
created = true;
}
const projectArchitect = path.join(projectDir, "architect.md");
try {
await fs.access(projectArchitect);
} catch {
await fs.writeFile(projectArchitect, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8");
created = true;
}
return created;
@@ -122,7 +108,8 @@ export function createProjectRegisterTool() {
// 1. Check project not already registered (allow re-register if incomplete)
const data = await readProjects(workspaceDir);
const existing = data.projects[groupId];
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
const existingWorkers = existing?.workers ?? {};
if (existing && Object.values(existingWorkers).some(w => w.sessions && Object.keys(w.sessions).length > 0)) {
throw new Error(
`Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`,
);
@@ -153,6 +140,12 @@ export function createProjectRegisterTool() {
await provider.ensureAllStateLabels();
// 5. Add project to projects.json
// Build workers map from all registered roles
const workers: Record<string, import("../projects.js").WorkerState> = {};
for (const role of getAllRoleIds()) {
workers[role] = emptyWorkerState([...getLevelsForRole(role)]);
}
data.projects[groupId] = {
name,
repo,
@@ -163,9 +156,7 @@ export function createProjectRegisterTool() {
channel,
provider: providerType,
roleExecution,
dev: emptyWorkerState([...getLevelsForRole("dev")]),
qa: emptyWorkerState([...getLevelsForRole("qa")]),
architect: emptyWorkerState([...getLevelsForRole("architect")]),
workers,
};
await writeProjects(workspaceDir, data);

View File

@@ -15,18 +15,18 @@ describe("status execution-aware sequencing", () => {
});
describe("role assignment", () => {
it("should assign To Improve to dev", () => {
// To Improve = dev work
it("should assign To Improve to developer", () => {
// To Improve = developer work
assert.ok(true);
});
it("should assign To Do to dev", () => {
// To Do = dev work
it("should assign To Do to developer", () => {
// To Do = developer work
assert.ok(true);
});
it("should assign To Test to qa", () => {
// To Test = qa work
it("should assign To Test to tester", () => {
// To Test = tester work
assert.ok(true);
});
});
@@ -43,12 +43,12 @@ describe("status execution-aware sequencing", () => {
});
it("should support parallel role execution within project", () => {
// DEV and QA can run simultaneously
// Developer and Tester can run simultaneously
assert.ok(true);
});
it("should support sequential role execution within project", () => {
// DEV and QA alternate
// Developer and Tester alternate
assert.ok(true);
});
});

View File

@@ -8,8 +8,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { runSetup, type SetupOpts } from "../setup/index.js";
import { DEFAULT_MODELS } from "../tiers.js";
import { getLevelsForRole } from "../roles/index.js";
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -37,44 +36,18 @@ export function createSetupTool(api: OpenClawPluginApi) {
models: {
type: "object",
description: "Model overrides per role and level.",
properties: {
dev: {
properties: Object.fromEntries(
getAllRoleIds().map((role) => [role, {
type: "object",
description: "Developer level models",
properties: {
junior: {
description: `${role.toUpperCase()} level models`,
properties: Object.fromEntries(
getLevelsForRole(role).map((level) => [level, {
type: "string",
description: `Default: ${DEFAULT_MODELS.dev.junior}`,
},
mid: {
type: "string",
description: `Default: ${DEFAULT_MODELS.dev.mid}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.dev.senior}`,
},
},
},
qa: {
type: "object",
description: "QA level models",
properties: {
junior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.qa.junior}`,
},
mid: {
type: "string",
description: `Default: ${DEFAULT_MODELS.qa.mid}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.qa.senior}`,
},
},
},
},
description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`,
}]),
),
}]),
),
},
projectExecution: {
type: "string",
@@ -112,13 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) {
"",
);
}
lines.push(
"Models:",
...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`),
...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`),
...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[t]}`),
"",
);
lines.push("Models:");
for (const [role, levels] of Object.entries(result.models)) {
for (const [level, model] of Object.entries(levels)) {
lines.push(` ${role}.${level}: ${model}`);
}
}
lines.push("");
lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`));

View File

@@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js";
import { log as auditLog } from "../audit.js";
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
import { DEFAULT_WORKFLOW } from "../workflow.js";
import { loadWorkflow } from "../workflow.js";
export function createStatusTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -32,8 +32,8 @@ export function createStatusTool(api: OpenClawPluginApi) {
const pluginConfig = getPluginConfig(api);
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
// TODO: Load per-project workflow when supported
const workflow = DEFAULT_WORKFLOW;
// Load workspace-level workflow (per-project loaded inside map)
const workflow = await loadWorkflow(workspaceDir);
const data = await readProjects(workspaceDir);
const projectIds = groupId ? [groupId] : Object.keys(data.projects);
@@ -52,28 +52,22 @@ export function createStatusTool(api: OpenClawPluginApi) {
queueCounts[label] = issues.length;
}
// Build dynamic workers summary
const workers: Record<string, { active: boolean; issueId: string | null; level: string | null; startTime: string | null }> = {};
for (const [role, worker] of Object.entries(project.workers)) {
workers[role] = {
active: worker.active,
issueId: worker.issueId,
level: worker.level,
startTime: worker.startTime,
};
}
return {
name: project.name,
groupId: pid,
roleExecution: project.roleExecution ?? "parallel",
dev: {
active: project.dev.active,
issueId: project.dev.issueId,
level: project.dev.level,
startTime: project.dev.startTime,
},
qa: {
active: project.qa.active,
issueId: project.qa.issueId,
level: project.qa.level,
startTime: project.qa.startTime,
},
architect: {
active: project.architect.active,
issueId: project.architect.issueId,
level: project.architect.level,
startTime: project.architect.startTime,
},
workers,
queue: queueCounts,
};
}),

View File

@@ -2,8 +2,8 @@
* task_comment — Add review comments or notes to an issue.
*
* Use cases:
* - QA worker adds review feedback without blocking pass/fail
* - DEV worker posts implementation notes
* - Tester worker adds review feedback without blocking pass/fail
* - Developer worker posts implementation notes
* - Orchestrator adds summary comments
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
@@ -13,7 +13,7 @@ import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
/** Valid author roles for attribution */
const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const;
const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const;
type AuthorRole = (typeof AUTHOR_ROLES)[number];
export function createTaskCommentTool(api: OpenClawPluginApi) {
@@ -23,15 +23,15 @@ export function createTaskCommentTool(api: OpenClawPluginApi) {
description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change.
Use cases:
- QA adds review feedback without blocking pass/fail
- DEV posts implementation notes or progress updates
- Tester adds review feedback without blocking pass/fail
- Developer posts implementation notes or progress updates
- Orchestrator adds summary comments
- Cross-referencing related issues or PRs
Examples:
- Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" }
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "qa" }
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "dev" }`,
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "tester" }
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "developer" }`,
parameters: {
type: "object",
required: ["projectGroupId", "issueId", "body"],
@@ -100,7 +100,7 @@ Examples:
// ---------------------------------------------------------------------------
const ROLE_EMOJI: Record<AuthorRole, string> = {
dev: "👨‍💻",
qa: "🔍",
developer: "👨‍💻",
tester: "🔍",
orchestrator: "🎛️",
};

View File

@@ -13,7 +13,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { log as auditLog } from "../audit.js";
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
import type { StateLabel } from "../providers/provider.js";
import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
export function createTaskCreateTool(api: OpenClawPluginApi) {
@@ -46,7 +47,7 @@ Examples:
label: {
type: "string",
description: `State label. Defaults to "Planning" — only use "To Do" when the user explicitly asks to start work immediately.`,
enum: STATE_LABELS,
enum: getStateLabels(DEFAULT_WORKFLOW),
},
assignees: {
type: "array",

View File

@@ -10,7 +10,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { log as auditLog } from "../audit.js";
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
import type { StateLabel } from "../providers/provider.js";
import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
export function createTaskUpdateTool(api: OpenClawPluginApi) {
@@ -42,8 +43,8 @@ Examples:
},
state: {
type: "string",
enum: STATE_LABELS,
description: `New state for the issue. One of: ${STATE_LABELS.join(", ")}`,
enum: getStateLabels(DEFAULT_WORKFLOW),
description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`,
},
reason: {
type: "string",

View File

@@ -8,16 +8,17 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { getWorker, resolveRepoPath } from "../projects.js";
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
import { executeCompletion, getRule } from "../services/pipeline.js";
import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
import { loadWorkflow } from "../workflow.js";
export function createWorkFinishTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
name: "work_finish",
label: "Work Finish",
description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
parameters: {
type: "object",
required: ["role", "result", "projectGroupId"],
@@ -31,7 +32,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
},
async execute(_id: string, params: Record<string, unknown>) {
const role = params.role as "dev" | "qa" | "architect";
const role = params.role as string;
const result = params.result as string;
const groupId = params.projectGroupId as string;
const summary = params.summary as string | undefined;
@@ -59,6 +60,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
const issue = await provider.getIssue(issueId);
const pluginConfig = getPluginConfig(api);
const workflow = await loadWorkflow(workspaceDir, project.name);
// Execute completion (pipeline service handles notification with runtime)
const completion = await executeCompletion({
@@ -67,6 +69,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
channel: project.channel,
pluginConfig,
runtime: api.runtime,
workflow,
});
const output: Record<string, unknown> = {

View File

@@ -13,10 +13,9 @@ import { selectLevel } from "../model-selector.js";
import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
import { isDevLevel } from "../tiers.js";
import { getAllRoleIds } from "../roles/index.js";
import { getAllRoleIds, isLevelForRole } from "../roles/index.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
import { loadWorkflow, getActiveLabel } from "../workflow.js";
export function createWorkStartTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -36,7 +35,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
async execute(_id: string, params: Record<string, unknown>) {
const issueIdParam = params.issueId as number | undefined;
const roleParam = params.role as "dev" | "qa" | "architect" | undefined;
const roleParam = params.role as string | undefined;
const groupId = params.projectGroupId as string;
const levelParam = (params.level ?? params.tier) as string | undefined;
const workspaceDir = requireWorkspaceDir(ctx);
@@ -45,8 +44,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const { project } = await resolveProject(workspaceDir, groupId);
const { provider } = await resolveProvider(project);
// TODO: Load per-project workflow when supported
const workflow = DEFAULT_WORKFLOW;
const workflow = await loadWorkflow(workspaceDir, project.name);
// Find issue
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
@@ -73,8 +71,11 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const worker = getWorker(project, role);
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
if ((project.roleExecution ?? "parallel") === "sequential") {
const other = role === "dev" ? "qa" : "dev";
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
if (otherRole !== role && otherWorker.active) {
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);
}
}
}
// Get target label from workflow
@@ -87,9 +88,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
} else {
const labelLevel = detectLevelFromLabels(issue.labels);
if (labelLevel) {
if (role === "qa" && isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = `QA overrides dev level "${labelLevel}"`; levelSource = "role-override"; }
else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; }
else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; }
if (!isLevelForRole(labelLevel, role)) {
// Label level belongs to a different role — use heuristic for this role
const s = selectLevel(issue.title, issue.description ?? "", role);
selectedLevel = s.level; levelReason = `${role} overrides other role's level "${labelLevel}"`; levelSource = "role-override";
} else {
selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label";
}
} else {
const s = selectLevel(issue.title, issue.description ?? "", role);
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";

View File

@@ -9,16 +9,13 @@
*
* All workflow behavior is derived from this config — no hardcoded state names.
*/
import fs from "node:fs/promises";
import path from "node:path";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type StateType = "queue" | "active" | "hold" | "terminal";
/** @deprecated Use WorkerRole from lib/roles/ */
export type Role = "dev" | "qa" | "architect";
/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
export type Role = string;
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
export type TransitionTarget = string | {
@@ -64,7 +61,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
},
todo: {
type: "queue",
role: "dev",
role: "developer",
label: "To Do",
color: "#428bca",
priority: 1,
@@ -72,7 +69,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
},
doing: {
type: "active",
role: "dev",
role: "developer",
label: "Doing",
color: "#f0ad4e",
on: {
@@ -82,7 +79,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
},
toTest: {
type: "queue",
role: "qa",
role: "tester",
label: "To Test",
color: "#5bc0de",
priority: 2,
@@ -90,7 +87,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
},
testing: {
type: "active",
role: "qa",
role: "tester",
label: "Testing",
color: "#9b59b6",
on: {
@@ -102,7 +99,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
},
toImprove: {
type: "queue",
role: "dev",
role: "developer",
label: "To Improve",
color: "#d9534f",
priority: 3,
@@ -146,38 +143,15 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
/**
* Load workflow config for a project.
* Priority: project-specific → workspace default → built-in default
* Delegates to loadConfig() which handles the three-layer merge.
*/
export async function loadWorkflow(
workspaceDir: string,
_groupId?: string,
projectName?: string,
): Promise<WorkflowConfig> {
// TODO: Support per-project overrides from projects.json when needed
// For now, try workspace-level config, fall back to default
const workflowPath = path.join(workspaceDir, "projects", "workflow.json");
try {
const content = await fs.readFile(workflowPath, "utf-8");
const parsed = JSON.parse(content) as { workflow?: WorkflowConfig };
if (parsed.workflow) {
return mergeWorkflow(DEFAULT_WORKFLOW, parsed.workflow);
}
} catch {
// No custom workflow, use default
}
return DEFAULT_WORKFLOW;
}
/**
* Merge custom workflow config over defaults.
* Custom states are merged, not replaced entirely.
*/
function mergeWorkflow(base: WorkflowConfig, custom: Partial<WorkflowConfig>): WorkflowConfig {
return {
initial: custom.initial ?? base.initial,
states: { ...base.states, ...custom.states },
};
const { loadConfig } = await import("./config/loader.js");
const config = await loadConfig(workspaceDir, projectName);
return config.workflow;
}
// ---------------------------------------------------------------------------
@@ -305,31 +279,30 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st
// ---------------------------------------------------------------------------
/**
* Map role:result to completion event name.
* Map completion result to workflow transition event name.
* Convention: "done" → COMPLETE, others → uppercase.
*/
const RESULT_TO_EVENT: Record<string, string> = {
"dev:done": "COMPLETE",
"dev:blocked": "BLOCKED",
"qa:pass": "PASS",
"qa:fail": "FAIL",
"qa:refine": "REFINE",
"qa:blocked": "BLOCKED",
"architect:done": "COMPLETE",
"architect:blocked": "BLOCKED",
};
function resultToEvent(result: string): string {
if (result === "done") return "COMPLETE";
return result.toUpperCase();
}
/**
* Get completion rule for a role:result pair.
* Derives entirely from workflow transitions — no hardcoded role:result mapping.
*/
export function getCompletionRule(
workflow: WorkflowConfig,
role: Role,
result: string,
): CompletionRule | null {
const event = RESULT_TO_EVENT[`${role}:${result}`];
if (!event) return null;
const event = resultToEvent(result);
let activeLabel: string;
try {
activeLabel = getActiveLabel(workflow, role);
} catch { return null; }
const activeLabel = getActiveLabel(workflow, role);
const activeKey = findStateKeyByLabel(workflow, activeLabel);
if (!activeKey) return null;
@@ -356,6 +329,7 @@ export function getCompletionRule(
/**
* Get human-readable next state description.
* Derives from target state type — no hardcoded role names.
*/
export function getNextStateDescription(
workflow: WorkflowConfig,
@@ -365,15 +339,13 @@ export function getNextStateDescription(
const rule = getCompletionRule(workflow, role, result);
if (!rule) return "";
// Find the target state to determine the description
const targetState = findStateByLabel(workflow, rule.to);
if (!targetState) return "";
if (targetState.type === "terminal") return "Done!";
if (targetState.type === "hold") return "awaiting human decision";
if (targetState.type === "queue") {
if (targetState.role === "qa") return "QA queue";
if (targetState.role === "dev") return "back to DEV";
if (targetState.type === "queue" && targetState.role) {
return `${targetState.role.toUpperCase()} queue`;
}
return rule.to;
@@ -381,19 +353,18 @@ export function getNextStateDescription(
/**
* Get emoji for a completion result.
* Keyed by result name — role-independent.
*/
export function getCompletionEmoji(role: Role, result: string): string {
const map: Record<string, string> = {
"dev:done": "",
"qa:pass": "🎉",
"qa:fail": "",
"qa:refine": "🤔",
"dev:blocked": "🚫",
"qa:blocked": "🚫",
"architect:done": "🏗️",
"architect:blocked": "🚫",
};
return map[`${role}:${result}`] ?? "📋";
const RESULT_EMOJI: Record<string, string> = {
done: "✅",
pass: "🎉",
fail: "",
refine: "🤔",
blocked: "🚫",
};
export function getCompletionEmoji(_role: Role, result: string): string {
return RESULT_EMOJI[result] ?? "📋";
}
// ---------------------------------------------------------------------------

8
package-lock.json generated
View File

@@ -1,13 +1,16 @@
{
"name": "@laurentenhoor/devclaw",
"version": "1.1.0",
"version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@laurentenhoor/devclaw",
"version": "1.1.0",
"version": "1.2.2",
"license": "MIT",
"dependencies": {
"yaml": "^2.8.2"
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.9.3"
@@ -8729,7 +8732,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},

View File

@@ -53,5 +53,8 @@
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.9.3"
},
"dependencies": {
"yaml": "^2.8.2"
}
}