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:
@@ -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 });
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
57
lib/cli.ts
57
lib/cli.ts
@@ -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
14
lib/config/index.ts
Normal 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
170
lib/config/loader.ts
Normal 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
82
lib/config/merge.ts
Normal 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
49
lib/config/types.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
254
lib/projects.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))}`);
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
90
lib/tiers.ts
90
lib/tiers.ts
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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"), "🚫");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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: "🎛️",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
107
lib/workflow.ts
107
lib/workflow.ts
@@ -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": "🏗️",
|
export function getCompletionEmoji(_role: Role, result: string): string {
|
||||||
"architect:blocked": "🚫",
|
return RESULT_EMOJI[result] ?? "📋";
|
||||||
};
|
|
||||||
return map[`${role}:${result}`] ?? "📋";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user