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"; import os from "node:os";
describe("parseDevClawSessionKey", () => { describe("parseDevClawSessionKey", () => {
it("should parse a standard dev session key", () => { it("should parse a standard developer session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-mid"); const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-developer-medior");
assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" }); assert.deepStrictEqual(result, { projectName: "my-project", role: "developer" });
}); });
it("should parse a qa session key", () => { it("should parse a tester session key", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-mid"); const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-tester-medior");
assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" }); assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" });
}); });
it("should handle project names with hyphens", () => { it("should handle project names with hyphens", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior"); const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior");
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" }); assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" });
}); });
it("should handle project names with multiple hyphens and qa role", () => { it("should handle project names with multiple hyphens and tester role", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-junior"); const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-tester-junior");
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" }); assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "tester" });
}); });
it("should return null for non-subagent session keys", () => { it("should return null for non-subagent session keys", () => {
@@ -45,14 +45,14 @@ describe("parseDevClawSessionKey", () => {
assert.strictEqual(result, null); assert.strictEqual(result, null);
}); });
it("should parse senior dev level", () => { it("should parse senior developer level", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior"); const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-developer-senior");
assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" }); assert.deepStrictEqual(result, { projectName: "devclaw", role: "developer" });
}); });
it("should parse simple project name", () => { it("should parse simple project name", () => {
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior"); const result = parseDevClawSessionKey("agent:devclaw:subagent:api-developer-junior");
assert.deepStrictEqual(result, { projectName: "api", role: "dev" }); 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 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const projectDir = path.join(tmpDir, "projects", "roles", "test-project"); const projectDir = path.join(tmpDir, "projects", "roles", "test-project");
await fs.mkdir(projectDir, { recursive: true }); 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"); const result = await loadRoleInstructions(tmpDir, "test-project", "developer");
assert.strictEqual(result, "# Dev Instructions\nDo the thing."); assert.strictEqual(result, "# Developer Instructions\nDo the thing.");
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}); });
@@ -73,10 +73,10 @@ describe("loadRoleInstructions", () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const defaultDir = path.join(tmpDir, "projects", "roles", "default"); const defaultDir = path.join(tmpDir, "projects", "roles", "default");
await fs.mkdir(defaultDir, { recursive: true }); 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"); const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester");
assert.strictEqual(result, "# QA Default\nReview carefully."); assert.strictEqual(result, "# Tester Default\nReview carefully.");
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}); });
@@ -84,7 +84,7 @@ describe("loadRoleInstructions", () => {
it("should return empty string when no instructions exist", async () => { it("should return empty string when no instructions exist", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); 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, ""); assert.strictEqual(result, "");
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
@@ -96,10 +96,10 @@ describe("loadRoleInstructions", () => {
const defaultDir = path.join(tmpDir, "projects", "roles", "default"); const defaultDir = path.join(tmpDir, "projects", "roles", "default");
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(defaultDir, { recursive: true }); await fs.mkdir(defaultDir, { recursive: true });
await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions"); await fs.writeFile(path.join(projectDir, "developer.md"), "Project-specific instructions");
await fs.writeFile(path.join(defaultDir, "dev.md"), "Default 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"); assert.strictEqual(result, "Project-specific instructions");
await fs.rm(tmpDir, { recursive: true }); 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}` * Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
* Examples: * Examples:
* - `agent:devclaw:subagent:my-project-dev-mid` → { projectName: "my-project", role: "dev" } * - `agent:devclaw:subagent:my-project-developer-medior` → { projectName: "my-project", role: "developer" }
* - `agent:devclaw:subagent:webapp-qa-mid` → { projectName: "webapp", role: "qa" } * - `agent:devclaw:subagent:webapp-tester-medior` → { projectName: "webapp", role: "tester" }
* *
* Note: projectName may contain hyphens, so we match role from the end. * 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 { Command } from "commander";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { runSetup } from "./setup/index.js"; import { runSetup } from "./setup/index.js";
import { DEFAULT_MODELS } from "./tiers.js"; import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "./roles/index.js";
import { getLevelsForRole } from "./roles/index.js";
/** /**
* Register the `devclaw` CLI command group on a Commander program. * Register the `devclaw` CLI command group on a Commander program.
@@ -17,39 +16,41 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
.command("devclaw") .command("devclaw")
.description("DevClaw development pipeline tools"); .description("DevClaw development pipeline tools");
devclaw const setupCmd = devclaw
.command("setup") .command("setup")
.description("Set up DevClaw: create agent, configure models, write workspace files") .description("Set up DevClaw: create agent, configure models, write workspace files")
.option("--new-agent <name>", "Create a new agent with this name") .option("--new-agent <name>", "Create a new agent with this name")
.option("--agent <id>", "Use an existing agent by ID") .option("--agent <id>", "Use an existing agent by ID")
.option("--workspace <path>", "Direct workspace path") .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;
const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0; // Register dynamic --<role>-<level> options from registry
const models = hasOverrides const defaults = getAllDefaultModels();
? { ...(Object.keys(dev).length > 0 && { dev }), ...(Object.keys(qa).length > 0 && { qa }) } for (const role of getAllRoleIds()) {
: undefined; 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({ const result = await runSetup({
api, api,
newAgentName: opts.newAgent, newAgentName: opts.newAgent,
agentId: opts.agent, agentId: opts.agent,
workspacePath: opts.workspace, workspacePath: opts.workspace,
models, models: Object.keys(models).length > 0 ? models : undefined,
}); });
if (result.agentCreated) { if (result.agentCreated) {
@@ -57,9 +58,11 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
} }
console.log("Models configured:"); console.log("Models configured:");
for (const t of getLevelsForRole("dev")) console.log(` dev.${t}: ${result.models.dev[t]}`); for (const [role, levels] of Object.entries(result.models)) {
for (const t of getLevelsForRole("qa")) console.log(` qa.${t}: ${result.models.qa[t]}`); for (const [level, model] of Object.entries(levels)) {
for (const t of getLevelsForRole("architect")) console.log(` architect.${t}: ${result.models.architect[t]}`); console.log(` ${role}.${level}: ${model}`);
}
}
console.log("Files written:"); console.log("Files written:");
for (const file of result.filesWritten) { 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, getSessionForLevel,
getWorker, getWorker,
} from "./projects.js"; } 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 { notify, getNotificationConfig } from "./notify.js";
import { loadConfig, type ResolvedRoleConfig } from "./config/index.js";
export type DispatchOpts = { export type DispatchOpts = {
workspaceDir: string; workspaceDir: string;
@@ -25,7 +26,7 @@ export type DispatchOpts = {
issueTitle: string; issueTitle: string;
issueDescription: string; issueDescription: string;
issueUrl: string; issueUrl: string;
role: "dev" | "qa" | "architect"; role: string;
/** Developer level (junior, mid, senior) or raw model ID */ /** Developer level (junior, mid, senior) or raw model ID */
level: string; level: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
@@ -63,7 +64,7 @@ export type DispatchResult = {
*/ */
export function buildTaskMessage(opts: { export function buildTaskMessage(opts: {
projectName: string; projectName: string;
role: "dev" | "qa" | "architect"; role: string;
issueId: number; issueId: number;
issueTitle: string; issueTitle: string;
issueDescription: string; issueDescription: string;
@@ -72,16 +73,15 @@ export function buildTaskMessage(opts: {
baseBranch: string; baseBranch: string;
groupId: string; groupId: string;
comments?: Array<{ author: string; body: string; created_at: string }>; comments?: Array<{ author: string; body: string; created_at: string }>;
resolvedRole?: ResolvedRoleConfig;
}): string { }): string {
const { const {
projectName, role, issueId, issueTitle, projectName, role, issueId, issueTitle,
issueDescription, issueUrl, repo, baseBranch, groupId, issueDescription, issueUrl, repo, baseBranch, groupId,
} = opts; } = opts;
const availableResults = const results = opts.resolvedRole?.completionResults ?? [];
role === "dev" || role === "architect" const availableResults = results.map((r: string) => `"${r}"`).join(", ");
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
: '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)';
const parts = [ const parts = [
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`, `${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
@@ -149,7 +149,9 @@ export async function dispatchTask(
transitionLabel, provider, pluginConfig, runtime, transitionLabel, provider, pluginConfig, runtime,
} = opts; } = 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 worker = getWorker(project, role);
const existingSessionKey = getSessionForLevel(worker, level); const existingSessionKey = getSessionForLevel(worker, level);
const sessionAction = existingSessionKey ? "send" : "spawn"; const sessionAction = existingSessionKey ? "send" : "spawn";
@@ -164,7 +166,7 @@ export async function dispatchTask(
projectName: project.name, role, issueId, projectName: project.name, role, issueId,
issueTitle, issueDescription, issueUrl, issueTitle, issueDescription, issueUrl,
repo: project.repo, baseBranch: project.baseBranch, groupId, repo: project.repo, baseBranch: project.baseBranch, groupId,
comments, comments, resolvedRole,
}); });
// Step 1: Transition label (this is the commitment point) // Step 1: Transition label (this is the commitment point)
@@ -225,7 +227,7 @@ export async function dispatchTask(
fromLabel, toLabel, 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 }; return { sessionAction, sessionKey, level, model, announcement };
} }
@@ -267,7 +269,7 @@ function sendToAgent(
} }
async function recordWorkerState( 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" }, opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
): Promise<void> { ): Promise<void> {
await activateWorker(workspaceDir, groupId, role, { await activateWorker(workspaceDir, groupId, role, {
@@ -301,8 +303,9 @@ async function auditDispatch(
function buildAnnouncement( function buildAnnouncement(
level: string, role: string, sessionAction: "spawn" | "send", level: string, role: string, sessionAction: "spawn" | "send",
issueId: number, issueTitle: string, issueUrl: string, issueId: number, issueTitle: string, issueUrl: string,
resolvedRole?: ResolvedRoleConfig,
): string { ): string {
const emoji = getEmoji(role, level) ?? getFallbackEmoji(role); const emoji = resolvedRole?.emoji[level] ?? getFallbackEmoji(role);
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
} }

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { DEFAULT_MODELS } from "./tiers.js"; import { getAllDefaultModels } from "./roles/index.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Detection // Detection
@@ -38,15 +38,11 @@ export async function hasWorkspaceFiles(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildModelTable(pluginConfig?: Record<string, unknown>): string { function buildModelTable(pluginConfig?: Record<string, unknown>): string {
const cfg = ( const cfg = (pluginConfig as { models?: Record<string, Record<string, string>> })?.models;
pluginConfig as {
models?: { dev?: Record<string, string>; qa?: Record<string, string> };
}
)?.models;
const lines: string[] = []; 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)) { 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( lines.push(
` - **${role} ${level}**: ${model} (default: ${defaultModel})`, ` - **${role} ${level}**: ${model} (default: ${defaultModel})`,
); );
@@ -76,14 +72,14 @@ Ask what they want to change, then call the appropriate tool.
} }
export function buildOnboardToolContext(): string { export function buildOnboardToolContext(): string {
// Build the model table dynamically from DEFAULT_MODELS // Build the model table dynamically from getAllDefaultModels()
const rows: string[] = []; const rows: string[] = [];
const purposes: Record<string, string> = { const purposes: Record<string, string> = {
junior: "Simple tasks, single-file fixes", junior: "Simple tasks, single-file fixes",
mid: "Features, bug fixes, code review", medior: "Features, bug fixes, code review",
senior: "Architecture, refactoring, complex tasks", 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)) { for (const [level, model] of Object.entries(levels)) {
rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`); rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`);
} }
@@ -95,8 +91,8 @@ export function buildOnboardToolContext(): string {
## What is DevClaw? ## What is DevClaw?
DevClaw turns each Telegram group into an autonomous development team: DevClaw turns each Telegram group into an autonomous development team:
- An **orchestrator** that manages backlogs and delegates work - An **orchestrator** that manages backlogs and delegates work
- **DEV workers** (junior/mid/senior levels) that write code in isolated sessions - **Developer workers** (junior/medior/senior levels) that write code in isolated sessions
- **QA workers** that review code and run tests - **Tester workers** that review code and run tests
- Atomic tools for label transitions, session dispatch, state management, and audit logging - Atomic tools for label transitions, session dispatch, state management, and audit logging
## Setup Steps ## 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** **Step 3: Run Setup**
Call \`setup\` with the collected answers: 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: { ... } })\` - 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 - \`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 fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { LEVEL_ALIASES, ROLE_ALIASES } from "./roles/index.js";
export type WorkerState = { export type WorkerState = {
active: boolean; active: boolean;
issueId: string | null; issueId: string | null;
@@ -24,38 +25,28 @@ export type Project = {
channel?: string; channel?: string;
/** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */ /** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */
provider?: "github" | "gitlab"; 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"; roleExecution?: "parallel" | "sequential";
maxDevWorkers?: number; maxDevWorkers?: number;
maxQaWorkers?: number; maxQaWorkers?: number;
dev: WorkerState; /** Worker state per role (developer, tester, architect, or custom roles). */
qa: WorkerState; workers: Record<string, WorkerState>;
architect: WorkerState;
}; };
export type ProjectsData = { export type ProjectsData = {
projects: Record<string, Project>; 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 { function migrateLevel(level: string | null, role: string): string | null {
if (!level) return null; if (!level) return null;
return LEVEL_MIGRATION[role]?.[level] ?? level; return LEVEL_ALIASES[role]?.[level] ?? level;
} }
function migrateSessions( function migrateSessions(
sessions: Record<string, string | null>, sessions: Record<string, string | null>,
role: string, role: string,
): Record<string, string | null> { ): Record<string, string | null> {
const aliases = LEVEL_MIGRATION[role]; const aliases = LEVEL_ALIASES[role];
if (!aliases) return sessions; if (!aliases) return sessions;
const migrated: Record<string, string | null> = {}; 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; const data = JSON.parse(raw) as ProjectsData;
for (const project of Object.values(data.projects)) { for (const project of Object.values(data.projects)) {
project.dev = project.dev // Migrate old format: hardcoded dev/qa/architect fields → workers map
? parseWorkerState(project.dev as unknown as Record<string, unknown>, "dev") const raw = project as unknown as Record<string, unknown>;
: emptyWorkerState([]); if (!raw.workers && (raw.dev || raw.qa || raw.architect)) {
project.qa = project.qa project.workers = {};
? parseWorkerState(project.qa as unknown as Record<string, unknown>, "qa") for (const role of ["dev", "qa", "architect"]) {
: emptyWorkerState([]); const canonical = ROLE_ALIASES[role] ?? role;
project.architect = project.architect project.workers[canonical] = raw[role]
? parseWorkerState(project.architect as unknown as Record<string, unknown>, "architect") ? parseWorkerState(raw[role] as Record<string, unknown>, role)
: emptyWorkerState([]); : 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) { if (!project.channel) {
project.channel = "telegram"; project.channel = "telegram";
} }
@@ -150,9 +159,9 @@ export function getProject(
export function getWorker( export function getWorker(
project: Project, project: Project,
role: "dev" | "qa" | "architect", role: string,
): WorkerState { ): WorkerState {
return project[role]; return project.workers[role] ?? emptyWorkerState([]);
} }
/** /**
@@ -162,7 +171,7 @@ export function getWorker(
export async function updateWorker( export async function updateWorker(
workspaceDir: string, workspaceDir: string,
groupId: string, groupId: string,
role: "dev" | "qa" | "architect", role: string,
updates: Partial<WorkerState>, updates: Partial<WorkerState>,
): Promise<ProjectsData> { ): Promise<ProjectsData> {
const data = await readProjects(workspaceDir); const data = await readProjects(workspaceDir);
@@ -171,13 +180,13 @@ export async function updateWorker(
throw new Error(`Project not found for groupId: ${groupId}`); throw new Error(`Project not found for groupId: ${groupId}`);
} }
const worker = project[role]; const worker = project.workers[role] ?? emptyWorkerState([]);
if (updates.sessions && worker.sessions) { if (updates.sessions && worker.sessions) {
updates.sessions = { ...worker.sessions, ...updates.sessions }; updates.sessions = { ...worker.sessions, ...updates.sessions };
} }
project[role] = { ...worker, ...updates }; project.workers[role] = { ...worker, ...updates };
await writeProjects(workspaceDir, data); await writeProjects(workspaceDir, data);
return data; return data;
@@ -190,7 +199,7 @@ export async function updateWorker(
export async function activateWorker( export async function activateWorker(
workspaceDir: string, workspaceDir: string,
groupId: string, groupId: string,
role: "dev" | "qa" | "architect", role: string,
params: { params: {
issueId: string; issueId: string;
level: string; level: string;
@@ -220,7 +229,7 @@ export async function activateWorker(
export async function deactivateWorker( export async function deactivateWorker(
workspaceDir: string, workspaceDir: string,
groupId: string, groupId: string,
role: "dev" | "qa" | "architect", role: string,
): Promise<ProjectsData> { ): Promise<ProjectsData> {
return updateWorker(workspaceDir, groupId, role, { return updateWorker(workspaceDir, groupId, role, {
active: false, active: false,

View File

@@ -2,34 +2,13 @@
* IssueProvider — Abstract interface for issue tracker operations. * IssueProvider — Abstract interface for issue tracker operations.
* *
* Implementations: GitHub (gh CLI), GitLab (glab CLI). * 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. * StateLabel type — string for flexibility with custom workflows.
* 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.
*/ */
export type StateLabel = string; 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 // Issue types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -70,6 +49,3 @@ export interface IssueProvider {
addComment(issueId: number, body: string): Promise<void>; addComment(issueId: number, body: string): Promise<void>;
healthCheck(): Promise<boolean>; healthCheck(): Promise<boolean>;
} }
/** @deprecated Use IssueProvider */
export type TaskManager = IssueProvider;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ import { log as auditLog } from "../audit.js";
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
import { projectTick } from "./tick.js"; import { projectTick } from "./tick.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { getAllRoleIds } from "../roles/index.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -307,13 +306,13 @@ async function performHealthPass(
const { provider } = await createProvider({ repo: project.repo, provider: project.provider }); const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
let fixedCount = 0; let fixedCount = 0;
for (const role of getAllRoleIds()) { for (const role of Object.keys(project.workers)) {
// Check worker health (session liveness, label consistency, etc) // Check worker health (session liveness, label consistency, etc)
const healthFixes = await checkWorkerHealth({ const healthFixes = await checkWorkerHealth({
workspaceDir, workspaceDir,
groupId, groupId,
project, project,
role: role as any, role,
sessions, sessions,
autoFix: true, autoFix: true,
provider, provider,
@@ -325,7 +324,7 @@ async function performHealthPass(
workspaceDir, workspaceDir,
groupId, groupId,
project, project,
role: role as any, role,
autoFix: true, autoFix: true,
provider, 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> { async function checkProjectActive(workspaceDir: string, groupId: string): Promise<boolean> {
const fresh = (await readProjects(workspaceDir)).projects[groupId]; const fresh = (await readProjects(workspaceDir)).projects[groupId];
if (!fresh) return false; 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, type WorkflowConfig,
} from "../workflow.js"; } 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 { CompletionRule };
export type CompletionOutput = { export type CompletionOutput = {
@@ -72,7 +38,7 @@ export function getRule(
result: string, result: string,
workflow: WorkflowConfig = DEFAULT_WORKFLOW, workflow: WorkflowConfig = DEFAULT_WORKFLOW,
): CompletionRule | undefined { ): 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: { export async function executeCompletion(opts: {
workspaceDir: string; workspaceDir: string;
groupId: string; groupId: string;
role: "dev" | "qa" | "architect"; role: string;
result: string; result: string;
issueId: number; issueId: number;
summary?: string; summary?: string;

View File

@@ -13,26 +13,6 @@ import {
type Role, type Role,
} from "../workflow.js"; } 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 // Workflow-driven helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

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

View File

@@ -6,7 +6,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; 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. * 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. * Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
*/ */
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; 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 { migrateChannelBinding } from "../binding-manager.js";
import { createAgent, resolveWorkspacePath } from "./agent.js"; import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js"; import { writePluginConfig } from "./config.js";
import { scaffoldWorkspace } from "./workspace.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 = { export type SetupOpts = {
/** OpenClaw plugin API for config access. */ /** OpenClaw plugin API for config access. */
@@ -27,7 +27,7 @@ export type SetupOpts = {
/** Override workspace path (auto-detected from agent if not given). */ /** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string; workspacePath?: string;
/** Model overrides per role.level. Missing levels use defaults. */ /** 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. */ /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential"; projectExecution?: "parallel" | "sequential";
}; };
@@ -113,25 +113,21 @@ async function tryMigrateBinding(
} }
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
const dev: Record<string, string> = { ...DEFAULT_MODELS.dev }; const defaults = getAllDefaultModels();
const qa: Record<string, string> = { ...DEFAULT_MODELS.qa }; const result: ModelConfig = {};
const architect: Record<string, string> = { ...DEFAULT_MODELS.architect };
if (overrides?.dev) { for (const [role, levels] of Object.entries(defaults)) {
for (const [level, model] of Object.entries(overrides.dev)) { result[role] = { ...levels };
if (model) dev[level] = model;
} }
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"; import { runCommand } from "../run-command.js";
export type ModelAssignment = { export type ModelAssignment = {
dev: { developer: {
junior: string; junior: string;
mid: string; medior: string;
senior: string; senior: string;
}; };
qa: { tester: {
junior: string; junior: string;
mid: string; medior: string;
senior: string; senior: string;
}; };
architect: { architect: {
@@ -37,8 +37,8 @@ export async function selectModelsWithLLM(
if (availableModels.length === 1) { if (availableModels.length === 1) {
const model = availableModels[0].model; const model = availableModels[0].model;
return { return {
dev: { junior: model, mid: model, senior: model }, developer: { junior: model, medior: model, senior: model },
qa: { junior: model, mid: model, senior: model }, tester: { junior: model, medior: model, senior: model },
architect: { junior: model, senior: model }, architect: { junior: model, senior: model },
}; };
} }
@@ -53,27 +53,27 @@ ${modelList}
All roles use the same level scheme based on task complexity: All roles use the same level scheme based on task complexity:
- **senior** (most capable): Complex architecture, refactoring, critical decisions - **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 - **junior** (fast/efficient): Simple fixes, routine tasks
Rules: Rules:
1. Prefer same provider for consistency 1. Prefer same provider for consistency
2. Assign most capable model to senior 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 4. Assign fastest/cheapest model to junior
5. Consider model version numbers (higher = newer/better) 5. Consider model version numbers (higher = newer/better)
6. Stable versions (no date) > snapshot versions (with date like 20250514) 6. Stable versions (no date) > snapshot versions (with date like 20250514)
Return ONLY a JSON object in this exact format (no markdown, no explanation): Return ONLY a JSON object in this exact format (no markdown, no explanation):
{ {
"dev": { "developer": {
"junior": "provider/model-name", "junior": "provider/model-name",
"mid": "provider/model-name", "medior": "provider/model-name",
"senior": "provider/model-name" "senior": "provider/model-name"
}, },
"qa": { "tester": {
"junior": "provider/model-name", "junior": "provider/model-name",
"mid": "provider/model-name", "medior": "provider/model-name",
"senior": "provider/model-name" "senior": "provider/model-name"
}, },
"architect": { "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) // Backfill architect if LLM didn't return it (graceful upgrade)
if (!assignment.architect) { if (!assignment.architect) {
assignment.architect = { assignment.architect = {
senior: assignment.dev?.senior ?? availableModels[0].model, senior: assignment.developer?.senior ?? availableModels[0].model,
junior: assignment.dev?.mid ?? availableModels[0].model, junior: assignment.developer?.medior ?? availableModels[0].model,
}; };
} }
if ( if (
!assignment.dev?.junior || !assignment.developer?.junior ||
!assignment.dev?.mid || !assignment.developer?.medior ||
!assignment.dev?.senior || !assignment.developer?.senior ||
!assignment.qa?.junior || !assignment.tester?.junior ||
!assignment.qa?.mid || !assignment.tester?.medior ||
!assignment.qa?.senior !assignment.tester?.senior
) { ) {
console.error("Invalid assignment structure. Got:", assignment); console.error("Invalid assignment structure. Got:", assignment);
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(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 = { export type ModelAssignment = {
dev: { developer: {
junior: string; junior: string;
mid: string; medior: string;
senior: string; senior: string;
}; };
qa: { tester: {
junior: string; junior: string;
mid: string; medior: string;
senior: string; senior: string;
}; };
architect: { architect: {
@@ -44,8 +44,8 @@ export async function assignModels(
if (authenticated.length === 1) { if (authenticated.length === 1) {
const model = authenticated[0].model; const model = authenticated[0].model;
return { return {
dev: { junior: model, mid: model, senior: model }, developer: { junior: model, medior: model, senior: model },
qa: { junior: model, mid: model, senior: model }, tester: { junior: model, medior: model, senior: model },
architect: { junior: model, senior: model }, architect: { junior: model, senior: model },
}; };
} }
@@ -67,15 +67,15 @@ export async function assignModels(
export function formatAssignment(assignment: ModelAssignment): string { export function formatAssignment(assignment: ModelAssignment): string {
const lines = [ const lines = [
"| Role | Level | Model |", "| Role | Level | Model |",
"|------|----------|--------------------------|", "|-----------|----------|--------------------------|",
`| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`, `| DEVELOPER | senior | ${assignment.developer.senior.padEnd(24)} |`,
`| DEV | mid | ${assignment.dev.mid.padEnd(24)} |`, `| DEVELOPER | medior | ${assignment.developer.medior.padEnd(24)} |`,
`| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`, `| DEVELOPER | junior | ${assignment.developer.junior.padEnd(24)} |`,
`| QA | senior | ${assignment.qa.senior.padEnd(24)} |`, `| TESTER | senior | ${assignment.tester.senior.padEnd(24)} |`,
`| QA | mid | ${assignment.qa.mid.padEnd(24)} |`, `| TESTER | medior | ${assignment.tester.medior.padEnd(24)} |`,
`| QA | junior | ${assignment.qa.junior.padEnd(24)} |`, `| TESTER | junior | ${assignment.tester.junior.padEnd(24)} |`,
`| ARCH | senior | ${assignment.architect.senior.padEnd(24)} |`, `| ARCHITECT | senior | ${assignment.architect.senior.padEnd(24)} |`,
`| ARCH | junior | ${assignment.architect.junior.padEnd(24)} |`, `| ARCHITECT | junior | ${assignment.architect.junior.padEnd(24)} |`,
]; ];
return lines.join("\n"); return lines.join("\n");
} }

View File

@@ -3,7 +3,7 @@
* Used by setup and project_register. * 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 ## 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 - 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. - **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 - 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 - If you discover unrelated bugs, call task_create to file them
- Do NOT call work_start, status, health, or project_register - 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 - Pull latest from the base branch
- Run tests and linting - Run tests and linting
- Verify the changes address the issue requirements - Verify the changes address the issue requirements
- Check for regressions in related functionality - 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 - **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 "pass" if everything looks good
- result "fail" with specific issues if problems found - result "fail" with specific issues if problems found
- result "refine" if you need human input to decide - 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 2. **Research alternatives** — Explore >= 3 viable approaches
3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit 3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit
4. **Recommend** — Pick the best option with clear reasoning 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 ## 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. 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) 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). 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:\` - Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\`
- Include issue number: \`feat: add user authentication (#12)\` - Include issue number: \`feat: add user authentication (#12)\`
- Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\` - Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\`
- **DEV always works in a git worktree** (never switch branches in the main repo) - **DEVELOPER always works in a git worktree** (never switch branches in the main repo)
- **DEV must merge to base branch** before announcing completion - **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 QA. - **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.
- **QA tests on the deployed version** and inspects code on the base branch - **TESTER tests on the deployed version** and inspects code on the base branch
- **QA always calls task_comment** with review findings before completing - **TESTER always calls task_comment** with review findings before completing
- Always run tests before completing - Always run tests before completing
### Completing Your Task ### Completing Your Task
When you are done, **call \`work_finish\` yourself** — do not just announce in text. 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>" })\` - **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA pass:** \`work_finish({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\` - **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\` - **TESTER fail:** \`work_finish({ role: "tester", 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>" })\` - **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>" })\` - **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation summary>" })\`
The \`projectGroupId\` is included in your task message. 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: **Never write code yourself.** All implementation work MUST go through the issue → worker pipeline:
1. Create an issue via \`task_create\` 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 3. Let the worker handle implementation, git, and PRs
**Why this matters:** **Why this matters:**
- **Audit trail** — Every code change is tracked to an issue - **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 - **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:** **What you CAN do directly:**
- Planning, analysis, architecture discussions - Planning, analysis, architecture discussions
@@ -195,7 +202,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
Examples: Examples:
- ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42" - ✅ "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) - ❌ "Created issue #42 about the login bug" (missing URL)
### DevClaw Tools ### 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\`: Evaluate each task and pass the appropriate developer level to \`work_start\`:
- **junior** — trivial: typos, single-file fix, quick change - **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 - **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 ### 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: 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 - Developer "done" → issue moves to "To Test" → scheduler dispatches Tester
- QA "fail" → issue moves to "To Improve" → scheduler dispatches DEV - Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer
- QA "pass" → Done, no further dispatch - Tester "pass" → Done, no further dispatch
- QA "refine" / blocked → needs human input - Tester "refine" / blocked → needs human input
- Architect "done" → issue moves to "Planning" → ready for tech lead review - Architect "done" → issue moves to "Planning" → ready for tech lead review
**Always include issue URLs** in your response — these are in the \`announcement\` fields. **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 ### 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 push to main directly
- Don't force-push - 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 - 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 { describe, it } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { parseDevClawSessionKey } from "../bootstrap-hook.js"; 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 { selectLevel } from "../model-selector.js";
import { import {
DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule, DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule,
@@ -14,21 +14,21 @@ import {
describe("architect tiers", () => { describe("architect tiers", () => {
it("should recognize architect levels", () => { it("should recognize architect levels", () => {
assert.strictEqual(isArchitectLevel("junior"), true); assert.strictEqual(isLevelForRole("junior", "architect"), true);
assert.strictEqual(isArchitectLevel("senior"), true); assert.strictEqual(isLevelForRole("senior", "architect"), true);
assert.strictEqual(isArchitectLevel("mid"), false); assert.strictEqual(isLevelForRole("medior", "architect"), false);
}); });
it("should map architect levels to role", () => { it("should map architect levels to role", () => {
// "junior" and "senior" appear in dev first (registry order), so roleForLevel returns "dev" // "junior" and "senior" appear in developer first (registry order), so roleForLevel returns "developer"
// This is expected — use isArchitectLevel for architect-specific checks // This is expected — use isLevelForRole for role-specific checks
assert.strictEqual(levelRole("junior"), "dev"); assert.strictEqual(roleForLevel("junior"), "developer");
assert.strictEqual(levelRole("senior"), "dev"); assert.strictEqual(roleForLevel("senior"), "developer");
}); });
it("should resolve default architect models", () => { it("should resolve default architect models", () => {
assert.strictEqual(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
}); });
it("should resolve architect model from config", () => { it("should resolve architect model from config", () => {
@@ -37,8 +37,8 @@ describe("architect tiers", () => {
}); });
it("should have architect emoji", () => { it("should have architect emoji", () => {
assert.strictEqual(levelEmoji("architect", "senior"), "🏗️"); assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
assert.strictEqual(levelEmoji("architect", "junior"), "📐"); assert.strictEqual(getEmoji("architect", "junior"), "📐");
}); });
}); });
@@ -76,8 +76,9 @@ describe("architect workflow states", () => {
assert.strictEqual(rule!.to, "Refining"); assert.strictEqual(rule!.to, "Refining");
}); });
it("should have architect completion emoji", () => { it("should have completion emoji by result type", () => {
assert.strictEqual(getCompletionEmoji("architect", "done"), "🏗️"); // Emoji is now keyed by result, not role:result
assert.strictEqual(getCompletionEmoji("architect", "done"), "✅");
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫"); assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
}); });
}); });

View File

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

View File

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

View File

@@ -15,40 +15,26 @@ import { resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { getAllRoleIds, getLevelsForRole } from "../roles/index.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. * Returns true if files were created, false if they already existed.
*/ */
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> { async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
const projectDir = path.join(workspaceDir, "projects", "roles", projectName); const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
const projectDev = path.join(projectDir, "dev.md");
const projectQa = path.join(projectDir, "qa.md");
let created = false; let created = false;
for (const role of getAllRoleIds()) {
const filePath = path.join(projectDir, `${role}.md`);
try { try {
await fs.access(projectDev); await fs.access(filePath);
} catch { } 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; 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; return created;
@@ -122,7 +108,8 @@ export function createProjectRegisterTool() {
// 1. Check project not already registered (allow re-register if incomplete) // 1. Check project not already registered (allow re-register if incomplete)
const data = await readProjects(workspaceDir); const data = await readProjects(workspaceDir);
const existing = data.projects[groupId]; 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( throw new Error(
`Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`, `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(); await provider.ensureAllStateLabels();
// 5. Add project to projects.json // 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] = { data.projects[groupId] = {
name, name,
repo, repo,
@@ -163,9 +156,7 @@ export function createProjectRegisterTool() {
channel, channel,
provider: providerType, provider: providerType,
roleExecution, roleExecution,
dev: emptyWorkerState([...getLevelsForRole("dev")]), workers,
qa: emptyWorkerState([...getLevelsForRole("qa")]),
architect: emptyWorkerState([...getLevelsForRole("architect")]),
}; };
await writeProjects(workspaceDir, data); await writeProjects(workspaceDir, data);

View File

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

View File

@@ -8,8 +8,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { runSetup, type SetupOpts } from "../setup/index.js"; import { runSetup, type SetupOpts } from "../setup/index.js";
import { DEFAULT_MODELS } from "../tiers.js"; import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
import { getLevelsForRole } from "../roles/index.js";
export function createSetupTool(api: OpenClawPluginApi) { export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -37,44 +36,18 @@ export function createSetupTool(api: OpenClawPluginApi) {
models: { models: {
type: "object", type: "object",
description: "Model overrides per role and level.", description: "Model overrides per role and level.",
properties: { properties: Object.fromEntries(
dev: { getAllRoleIds().map((role) => [role, {
type: "object", type: "object",
description: "Developer level models", description: `${role.toUpperCase()} level models`,
properties: { properties: Object.fromEntries(
junior: { getLevelsForRole(role).map((level) => [level, {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.dev.junior}`, description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`,
}, }]),
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}`,
},
},
},
},
}, },
projectExecution: { projectExecution: {
type: "string", type: "string",
@@ -112,13 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) {
"", "",
); );
} }
lines.push( lines.push("Models:");
"Models:", for (const [role, levels] of Object.entries(result.models)) {
...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`), for (const [level, model] of Object.entries(levels)) {
...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`), lines.push(` ${role}.${level}: ${model}`);
...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[t]}`), }
"", }
); lines.push("");
lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`)); 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 { log as auditLog } from "../audit.js";
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js"; import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js"; import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
import { DEFAULT_WORKFLOW } from "../workflow.js"; import { loadWorkflow } from "../workflow.js";
export function createStatusTool(api: OpenClawPluginApi) { export function createStatusTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -32,8 +32,8 @@ export function createStatusTool(api: OpenClawPluginApi) {
const pluginConfig = getPluginConfig(api); const pluginConfig = getPluginConfig(api);
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel"; const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
// TODO: Load per-project workflow when supported // Load workspace-level workflow (per-project loaded inside map)
const workflow = DEFAULT_WORKFLOW; const workflow = await loadWorkflow(workspaceDir);
const data = await readProjects(workspaceDir); const data = await readProjects(workspaceDir);
const projectIds = groupId ? [groupId] : Object.keys(data.projects); const projectIds = groupId ? [groupId] : Object.keys(data.projects);
@@ -52,28 +52,22 @@ export function createStatusTool(api: OpenClawPluginApi) {
queueCounts[label] = issues.length; 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 { return {
name: project.name, name: project.name,
groupId: pid, groupId: pid,
roleExecution: project.roleExecution ?? "parallel", roleExecution: project.roleExecution ?? "parallel",
dev: { workers,
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,
},
queue: queueCounts, queue: queueCounts,
}; };
}), }),

View File

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

View File

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

View File

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

View File

@@ -8,16 +8,17 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { getWorker, resolveRepoPath } from "../projects.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 { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js"; import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
import { loadWorkflow } from "../workflow.js";
export function createWorkFinishTool(api: OpenClawPluginApi) { export function createWorkFinishTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
name: "work_finish", name: "work_finish",
label: "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: { parameters: {
type: "object", type: "object",
required: ["role", "result", "projectGroupId"], required: ["role", "result", "projectGroupId"],
@@ -31,7 +32,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
}, },
async execute(_id: string, params: Record<string, unknown>) { 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 result = params.result as string;
const groupId = params.projectGroupId as string; const groupId = params.projectGroupId as string;
const summary = params.summary as string | undefined; const summary = params.summary as string | undefined;
@@ -59,6 +60,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
const issue = await provider.getIssue(issueId); const issue = await provider.getIssue(issueId);
const pluginConfig = getPluginConfig(api); const pluginConfig = getPluginConfig(api);
const workflow = await loadWorkflow(workspaceDir, project.name);
// Execute completion (pipeline service handles notification with runtime) // Execute completion (pipeline service handles notification with runtime)
const completion = await executeCompletion({ const completion = await executeCompletion({
@@ -67,6 +69,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
channel: project.channel, channel: project.channel,
pluginConfig, pluginConfig,
runtime: api.runtime, runtime: api.runtime,
workflow,
}); });
const output: Record<string, unknown> = { const output: Record<string, unknown> = {

View File

@@ -13,10 +13,9 @@ import { selectLevel } from "../model-selector.js";
import { getWorker } from "../projects.js"; import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
import { isDevLevel } from "../tiers.js"; import { getAllRoleIds, isLevelForRole } from "../roles/index.js";
import { getAllRoleIds } from "../roles/index.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js"; import { loadWorkflow, getActiveLabel } from "../workflow.js";
export function createWorkStartTool(api: OpenClawPluginApi) { export function createWorkStartTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -36,7 +35,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const issueIdParam = params.issueId as number | undefined; 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 groupId = params.projectGroupId as string;
const levelParam = (params.level ?? params.tier) as string | undefined; const levelParam = (params.level ?? params.tier) as string | undefined;
const workspaceDir = requireWorkspaceDir(ctx); const workspaceDir = requireWorkspaceDir(ctx);
@@ -45,8 +44,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const { project } = await resolveProject(workspaceDir, groupId); const { project } = await resolveProject(workspaceDir, groupId);
const { provider } = await resolveProvider(project); const { provider } = await resolveProvider(project);
// TODO: Load per-project workflow when supported const workflow = await loadWorkflow(workspaceDir, project.name);
const workflow = DEFAULT_WORKFLOW;
// Find issue // Find issue
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }; 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); const worker = getWorker(project, role);
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`); if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
if ((project.roleExecution ?? "parallel") === "sequential") { if ((project.roleExecution ?? "parallel") === "sequential") {
const other = role === "dev" ? "qa" : "dev"; for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`); if (otherRole !== role && otherWorker.active) {
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);
}
}
} }
// Get target label from workflow // Get target label from workflow
@@ -87,9 +88,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
} else { } else {
const labelLevel = detectLevelFromLabels(issue.labels); const labelLevel = detectLevelFromLabels(issue.labels);
if (labelLevel) { 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"; } if (!isLevelForRole(labelLevel, role)) {
else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; } // Label level belongs to a different role — use heuristic for this role
else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; } 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 { } else {
const s = selectLevel(issue.title, issue.description ?? "", role); const s = selectLevel(issue.title, issue.description ?? "", role);
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; 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. * All workflow behavior is derived from this config — no hardcoded state names.
*/ */
import fs from "node:fs/promises";
import path from "node:path";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type StateType = "queue" | "active" | "hold" | "terminal"; export type StateType = "queue" | "active" | "hold" | "terminal";
/** @deprecated Use WorkerRole from lib/roles/ */ /** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
export type Role = "dev" | "qa" | "architect"; export type Role = string;
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue"; export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
export type TransitionTarget = string | { export type TransitionTarget = string | {
@@ -64,7 +61,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
}, },
todo: { todo: {
type: "queue", type: "queue",
role: "dev", role: "developer",
label: "To Do", label: "To Do",
color: "#428bca", color: "#428bca",
priority: 1, priority: 1,
@@ -72,7 +69,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
}, },
doing: { doing: {
type: "active", type: "active",
role: "dev", role: "developer",
label: "Doing", label: "Doing",
color: "#f0ad4e", color: "#f0ad4e",
on: { on: {
@@ -82,7 +79,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
}, },
toTest: { toTest: {
type: "queue", type: "queue",
role: "qa", role: "tester",
label: "To Test", label: "To Test",
color: "#5bc0de", color: "#5bc0de",
priority: 2, priority: 2,
@@ -90,7 +87,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
}, },
testing: { testing: {
type: "active", type: "active",
role: "qa", role: "tester",
label: "Testing", label: "Testing",
color: "#9b59b6", color: "#9b59b6",
on: { on: {
@@ -102,7 +99,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
}, },
toImprove: { toImprove: {
type: "queue", type: "queue",
role: "dev", role: "developer",
label: "To Improve", label: "To Improve",
color: "#d9534f", color: "#d9534f",
priority: 3, priority: 3,
@@ -146,38 +143,15 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
/** /**
* Load workflow config for a project. * 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( export async function loadWorkflow(
workspaceDir: string, workspaceDir: string,
_groupId?: string, projectName?: string,
): Promise<WorkflowConfig> { ): Promise<WorkflowConfig> {
// TODO: Support per-project overrides from projects.json when needed const { loadConfig } = await import("./config/loader.js");
// For now, try workspace-level config, fall back to default const config = await loadConfig(workspaceDir, projectName);
return config.workflow;
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 },
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -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> = { function resultToEvent(result: string): string {
"dev:done": "COMPLETE", if (result === "done") return "COMPLETE";
"dev:blocked": "BLOCKED", return result.toUpperCase();
"qa:pass": "PASS", }
"qa:fail": "FAIL",
"qa:refine": "REFINE",
"qa:blocked": "BLOCKED",
"architect:done": "COMPLETE",
"architect:blocked": "BLOCKED",
};
/** /**
* Get completion rule for a role:result pair. * Get completion rule for a role:result pair.
* Derives entirely from workflow transitions — no hardcoded role:result mapping.
*/ */
export function getCompletionRule( export function getCompletionRule(
workflow: WorkflowConfig, workflow: WorkflowConfig,
role: Role, role: Role,
result: string, result: string,
): CompletionRule | null { ): CompletionRule | null {
const event = RESULT_TO_EVENT[`${role}:${result}`]; const event = resultToEvent(result);
if (!event) return null;
let activeLabel: string;
try {
activeLabel = getActiveLabel(workflow, role);
} catch { return null; }
const activeLabel = getActiveLabel(workflow, role);
const activeKey = findStateKeyByLabel(workflow, activeLabel); const activeKey = findStateKeyByLabel(workflow, activeLabel);
if (!activeKey) return null; if (!activeKey) return null;
@@ -356,6 +329,7 @@ export function getCompletionRule(
/** /**
* Get human-readable next state description. * Get human-readable next state description.
* Derives from target state type — no hardcoded role names.
*/ */
export function getNextStateDescription( export function getNextStateDescription(
workflow: WorkflowConfig, workflow: WorkflowConfig,
@@ -365,15 +339,13 @@ export function getNextStateDescription(
const rule = getCompletionRule(workflow, role, result); const rule = getCompletionRule(workflow, role, result);
if (!rule) return ""; if (!rule) return "";
// Find the target state to determine the description
const targetState = findStateByLabel(workflow, rule.to); const targetState = findStateByLabel(workflow, rule.to);
if (!targetState) return ""; if (!targetState) return "";
if (targetState.type === "terminal") return "Done!"; if (targetState.type === "terminal") return "Done!";
if (targetState.type === "hold") return "awaiting human decision"; if (targetState.type === "hold") return "awaiting human decision";
if (targetState.type === "queue") { if (targetState.type === "queue" && targetState.role) {
if (targetState.role === "qa") return "QA queue"; return `${targetState.role.toUpperCase()} queue`;
if (targetState.role === "dev") return "back to DEV";
} }
return rule.to; return rule.to;
@@ -381,19 +353,18 @@ export function getNextStateDescription(
/** /**
* Get emoji for a completion result. * Get emoji for a completion result.
* Keyed by result name — role-independent.
*/ */
export function getCompletionEmoji(role: Role, result: string): string { const RESULT_EMOJI: Record<string, string> = {
const map: Record<string, string> = { done: "✅",
"dev:done": "", pass: "🎉",
"qa:pass": "🎉", fail: "",
"qa:fail": "", refine: "🤔",
"qa:refine": "🤔", blocked: "🚫",
"dev:blocked": "🚫",
"qa:blocked": "🚫",
"architect:done": "🏗️",
"architect:blocked": "🚫",
}; };
return map[`${role}:${result}`] ?? "📋";
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", "name": "@laurentenhoor/devclaw",
"version": "1.1.0", "version": "1.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@laurentenhoor/devclaw", "name": "@laurentenhoor/devclaw",
"version": "1.1.0", "version": "1.2.2",
"license": "MIT", "license": "MIT",
"dependencies": {
"yaml": "^2.8.2"
},
"devDependencies": { "devDependencies": {
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"typescript": "^5.9.3" "typescript": "^5.9.3"
@@ -8729,7 +8732,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View File

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