From 6a99752e5f9941f3ce236dd1f5aec2c67d7dd663 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 17:01:16 +0800 Subject: [PATCH 01/13] refactor: standardize level names across all roles (#195 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename levels to use consistent industry-standard terminology: - dev: medior → mid - qa: reviewer → mid, tester → junior, add senior level - architect: opus → senior, sonnet → junior Add backward-compatible migration for projects.json and openclaw.json config via level aliases in selectors and projects parser. Co-Authored-By: Claude Opus 4.6 --- index.ts | 19 +++++---- lib/bootstrap-hook.test.ts | 6 +-- lib/bootstrap-hook.ts | 4 +- lib/cli.ts | 22 ++++++---- lib/dispatch.ts | 2 +- lib/model-selector.ts | 22 +++++----- lib/onboarding.ts | 10 ++--- lib/projects.ts | 43 ++++++++++++++++--- lib/roles/index.ts | 2 + lib/roles/registry.test.ts | 71 ++++++++++++++++++++++++------- lib/roles/registry.ts | 34 ++++++++------- lib/roles/selectors.ts | 22 +++++++++- lib/services/tick.ts | 2 +- lib/setup/config.ts | 2 +- lib/setup/index.ts | 12 ++++-- lib/setup/llm-model-selector.ts | 51 +++++++++++----------- lib/setup/smart-model-selector.ts | 28 ++++++------ lib/templates.ts | 11 +++-- lib/tiers.ts | 2 +- lib/tools/autoconfigure-models.ts | 2 +- lib/tools/design-task.test.ts | 38 +++++++++-------- lib/tools/design-task.ts | 4 +- lib/tools/setup.ts | 24 +++++++---- lib/tools/work-start.ts | 2 +- 24 files changed, 273 insertions(+), 162 deletions(-) diff --git a/index.ts b/index.ts index d00cc61..32f405e 100644 --- a/index.ts +++ b/index.ts @@ -26,31 +26,32 @@ const plugin = { properties: { models: { type: "object", - description: "Model mapping per role and tier", + description: "Model mapping per role and level", properties: { dev: { type: "object", - description: "Developer tier models", + description: "Developer level models", properties: { junior: { type: "string" }, - medior: { type: "string" }, + mid: { type: "string" }, senior: { type: "string" }, }, }, qa: { type: "object", - description: "QA tier models", + description: "QA level models", properties: { - reviewer: { type: "string" }, - tester: { type: "string" }, + junior: { type: "string" }, + mid: { type: "string" }, + senior: { type: "string" }, }, }, architect: { type: "object", - description: "Architect tier models", + description: "Architect level models", properties: { - opus: { type: "string" }, - sonnet: { type: "string" }, + junior: { type: "string" }, + senior: { type: "string" }, }, }, }, diff --git a/lib/bootstrap-hook.test.ts b/lib/bootstrap-hook.test.ts index 008dd99..844841b 100644 --- a/lib/bootstrap-hook.test.ts +++ b/lib/bootstrap-hook.test.ts @@ -11,12 +11,12 @@ import os from "node:os"; describe("parseDevClawSessionKey", () => { it("should parse a standard dev session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-medior"); + const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-mid"); assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" }); }); it("should parse a qa session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-reviewer"); + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-mid"); assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" }); }); @@ -26,7 +26,7 @@ describe("parseDevClawSessionKey", () => { }); it("should handle project names with multiple hyphens and qa role", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-tester"); + const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-junior"); assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" }); }); diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index b82acd0..8de14c2 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -17,8 +17,8 @@ import { getSessionKeyRolePattern } from "./roles/index.js"; * * Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}` * Examples: - * - `agent:devclaw:subagent:my-project-dev-medior` → { projectName: "my-project", role: "dev" } - * - `agent:devclaw:subagent:webapp-qa-reviewer` → { projectName: "webapp", role: "qa" } + * - `agent:devclaw:subagent:my-project-dev-mid` → { projectName: "my-project", role: "dev" } + * - `agent:devclaw:subagent:webapp-qa-mid` → { projectName: "webapp", role: "qa" } * * Note: projectName may contain hyphens, so we match role from the end. */ diff --git a/lib/cli.ts b/lib/cli.ts index 82553cb..c31473d 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -6,7 +6,8 @@ import type { Command } from "commander"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { runSetup } from "./setup/index.js"; -import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "./tiers.js"; +import { DEFAULT_MODELS } from "./tiers.js"; +import { getLevelsForRole } from "./roles/index.js"; /** * Register the `devclaw` CLI command group on a Commander program. @@ -23,18 +24,20 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { .option("--agent ", "Use an existing agent by ID") .option("--workspace ", "Direct workspace path") .option("--junior ", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`) - .option("--medior ", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`) + .option("--mid ", `Mid dev model (default: ${DEFAULT_MODELS.dev.mid})`) .option("--senior ", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`) - .option("--reviewer ", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`) - .option("--tester ", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`) + .option("--qa-junior ", `QA junior model (default: ${DEFAULT_MODELS.qa.junior})`) + .option("--qa-mid ", `QA mid model (default: ${DEFAULT_MODELS.qa.mid})`) + .option("--qa-senior ", `QA senior model (default: ${DEFAULT_MODELS.qa.senior})`) .action(async (opts) => { const dev: Record = {}; const qa: Record = {}; if (opts.junior) dev.junior = opts.junior; - if (opts.medior) dev.medior = opts.medior; + if (opts.mid) dev.mid = opts.mid; if (opts.senior) dev.senior = opts.senior; - if (opts.reviewer) qa.reviewer = opts.reviewer; - if (opts.tester) qa.tester = opts.tester; + 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; const models = hasOverrides @@ -54,8 +57,9 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { } console.log("Models configured:"); - for (const t of DEV_LEVELS) console.log(` dev.${t}: ${result.models.dev[t]}`); - for (const t of QA_LEVELS) console.log(` qa.${t}: ${result.models.qa[t]}`); + for (const t of getLevelsForRole("dev")) console.log(` dev.${t}: ${result.models.dev[t]}`); + for (const t of getLevelsForRole("qa")) console.log(` qa.${t}: ${result.models.qa[t]}`); + for (const t of getLevelsForRole("architect")) console.log(` architect.${t}: ${result.models.architect[t]}`); console.log("Files written:"); for (const file of result.filesWritten) { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 3409787..8f9b14a 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -26,7 +26,7 @@ export type DispatchOpts = { issueDescription: string; issueUrl: string; role: "dev" | "qa" | "architect"; - /** Developer level (junior, medior, senior, reviewer, opus, sonnet) or raw model ID */ + /** Developer level (junior, mid, senior) or raw model ID */ level: string; /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ fromLabel: string; diff --git a/lib/model-selector.ts b/lib/model-selector.ts index cb24b5f..d99f5ab 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -1,7 +1,7 @@ /** * Model selection for dev/qa tasks. * Keyword heuristic fallback — used when the orchestrator doesn't specify a level. - * Returns plain level names (junior, medior, senior, reviewer, tester). + * Returns plain level names (junior, mid, senior). */ export type LevelSelection = { @@ -41,12 +41,10 @@ const COMPLEX_KEYWORDS = [ /** * Select appropriate developer level based on task description. * - * Developer levels: - * - junior: very simple (typos, single-file fixes, CSS tweaks) - * - medior: standard DEV (features, bug fixes, multi-file changes) + * All roles use consistent levels: + * - junior: simple tasks (typos, single-file fixes, CSS tweaks) + * - mid: standard work (features, bug fixes, multi-file changes) * - senior: deep/architectural (system-wide refactoring, novel design) - * - reviewer: QA code inspection and validation - * - tester: QA manual testing */ export function selectLevel( issueTitle: string, @@ -55,7 +53,7 @@ export function selectLevel( ): LevelSelection { if (role === "qa") { return { - level: "reviewer", + level: "mid", reason: "Default QA level for code inspection and validation", }; } @@ -64,10 +62,10 @@ export function selectLevel( const text = `${issueTitle} ${issueDescription}`.toLowerCase(); const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); return { - level: isComplex ? "opus" : "sonnet", + level: isComplex ? "senior" : "junior", reason: isComplex - ? "Complex design task — using opus for depth" - : "Standard design task — using sonnet", + ? "Complex design task — using senior for depth" + : "Standard design task — using junior", }; } @@ -92,9 +90,9 @@ export function selectLevel( }; } - // Default: medior for standard dev work + // Default: mid for standard dev work return { - level: "medior", + level: "mid", reason: "Standard dev task — multi-file changes, features, bug fixes", }; } diff --git a/lib/onboarding.ts b/lib/onboarding.ts index bf8b204..b570c4d 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -79,11 +79,9 @@ export function buildOnboardToolContext(): string { // Build the model table dynamically from DEFAULT_MODELS const rows: string[] = []; const purposes: Record = { - junior: "Typos, single-file fixes", - medior: "Features, bug fixes", - senior: "Architecture, refactoring", - reviewer: "Code review", - tester: "Testing", + junior: "Simple tasks, single-file fixes", + mid: "Features, bug fixes, code review", + senior: "Architecture, refactoring, complex tasks", }; for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { for (const [level, model] of Object.entries(levels)) { @@ -97,7 +95,7 @@ export function buildOnboardToolContext(): string { ## What is DevClaw? DevClaw turns each Telegram group into an autonomous development team: - An **orchestrator** that manages backlogs and delegates work -- **DEV workers** (junior/medior/senior levels) that write code in isolated sessions +- **DEV workers** (junior/mid/senior levels) that write code in isolated sessions - **QA workers** that review code and run tests - Atomic tools for label transitions, session dispatch, state management, and audit logging diff --git a/lib/projects.ts b/lib/projects.ts index 1158147..6191384 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -37,13 +37,44 @@ export type ProjectsData = { projects: Record; }; -function parseWorkerState(worker: Record): WorkerState { +/** + * Level migration aliases: old name → new canonical name, keyed by role. + */ +const LEVEL_MIGRATION: Record> = { + dev: { medior: "mid" }, + qa: { reviewer: "mid", tester: "junior" }, + architect: { opus: "senior", sonnet: "junior" }, +}; + +function migrateLevel(level: string | null, role: string): string | null { + if (!level) return null; + return LEVEL_MIGRATION[role]?.[level] ?? level; +} + +function migrateSessions( + sessions: Record, + role: string, +): Record { + const aliases = LEVEL_MIGRATION[role]; + if (!aliases) return sessions; + + const migrated: Record = {}; + for (const [key, value] of Object.entries(sessions)) { + const newKey = aliases[key] ?? key; + migrated[newKey] = value; + } + return migrated; +} + +function parseWorkerState(worker: Record, role: string): WorkerState { + const level = (worker.level ?? worker.tier ?? null) as string | null; + const sessions = (worker.sessions as Record) ?? {}; return { active: worker.active as boolean, issueId: worker.issueId as string | null, startTime: worker.startTime as string | null, - level: (worker.level ?? worker.tier ?? null) as string | null, - sessions: (worker.sessions as Record) ?? {}, + level: migrateLevel(level, role), + sessions: migrateSessions(sessions, role), }; } @@ -84,13 +115,13 @@ export async function readProjects(workspaceDir: string): Promise for (const project of Object.values(data.projects)) { project.dev = project.dev - ? parseWorkerState(project.dev as unknown as Record) + ? parseWorkerState(project.dev as unknown as Record, "dev") : emptyWorkerState([]); project.qa = project.qa - ? parseWorkerState(project.qa as unknown as Record) + ? parseWorkerState(project.qa as unknown as Record, "qa") : emptyWorkerState([]); project.architect = project.architect - ? parseWorkerState(project.architect as unknown as Record) + ? parseWorkerState(project.architect as unknown as Record, "architect") : emptyWorkerState([]); if (!project.channel) { project.channel = "telegram"; diff --git a/lib/roles/index.ts b/lib/roles/index.ts index 3db1f9d..1bd4dab 100644 --- a/lib/roles/index.ts +++ b/lib/roles/index.ts @@ -13,6 +13,8 @@ export { isValidRole, getRole, requireRole, + // Level aliases + canonicalLevel, // Levels getLevelsForRole, getAllLevels, diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index fa5bdb7..ac234d5 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -18,6 +18,7 @@ import { getDefaultModel, getAllDefaultModels, resolveModel, + canonicalLevel, getEmoji, getFallbackEmoji, getCompletionResults, @@ -54,9 +55,9 @@ describe("role registry", () => { describe("levels", () => { it("should return levels for each role", () => { - assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "medior", "senior"]); - assert.deepStrictEqual([...getLevelsForRole("qa")], ["reviewer", "tester"]); - assert.deepStrictEqual([...getLevelsForRole("architect")], ["opus", "sonnet"]); + assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "mid", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("qa")], ["junior", "mid", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]); }); it("should return empty for unknown role", () => { @@ -66,35 +67,62 @@ describe("levels", () => { it("should return all levels", () => { const all = getAllLevels(); assert.ok(all.includes("junior")); - assert.ok(all.includes("reviewer")); - assert.ok(all.includes("opus")); + assert.ok(all.includes("mid")); + assert.ok(all.includes("senior")); }); it("should check level membership", () => { assert.strictEqual(isLevelForRole("junior", "dev"), true); - assert.strictEqual(isLevelForRole("junior", "qa"), false); - assert.strictEqual(isLevelForRole("opus", "architect"), true); + assert.strictEqual(isLevelForRole("junior", "qa"), true); + assert.strictEqual(isLevelForRole("junior", "architect"), true); + assert.strictEqual(isLevelForRole("mid", "dev"), true); + assert.strictEqual(isLevelForRole("mid", "architect"), false); }); it("should find role for level", () => { + // "junior" appears in dev first (registry order) assert.strictEqual(roleForLevel("junior"), "dev"); - assert.strictEqual(roleForLevel("reviewer"), "qa"); - assert.strictEqual(roleForLevel("opus"), "architect"); + assert.strictEqual(roleForLevel("mid"), "dev"); + assert.strictEqual(roleForLevel("senior"), "dev"); assert.strictEqual(roleForLevel("nonexistent"), undefined); }); it("should return default level", () => { - assert.strictEqual(getDefaultLevel("dev"), "medior"); - assert.strictEqual(getDefaultLevel("qa"), "reviewer"); - assert.strictEqual(getDefaultLevel("architect"), "sonnet"); + assert.strictEqual(getDefaultLevel("dev"), "mid"); + assert.strictEqual(getDefaultLevel("qa"), "mid"); + assert.strictEqual(getDefaultLevel("architect"), "junior"); + }); +}); + +describe("level aliases", () => { + it("should map old dev level names", () => { + assert.strictEqual(canonicalLevel("dev", "medior"), "mid"); + assert.strictEqual(canonicalLevel("dev", "junior"), "junior"); + assert.strictEqual(canonicalLevel("dev", "senior"), "senior"); + }); + + it("should map old qa level names", () => { + assert.strictEqual(canonicalLevel("qa", "reviewer"), "mid"); + assert.strictEqual(canonicalLevel("qa", "tester"), "junior"); + }); + + it("should map old architect level names", () => { + assert.strictEqual(canonicalLevel("architect", "opus"), "senior"); + assert.strictEqual(canonicalLevel("architect", "sonnet"), "junior"); + }); + + it("should pass through unknown levels", () => { + assert.strictEqual(canonicalLevel("dev", "custom"), "custom"); + assert.strictEqual(canonicalLevel("unknown", "whatever"), "whatever"); }); }); describe("models", () => { it("should return default models", () => { assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5"); - assert.strictEqual(getDefaultModel("qa", "reviewer"), "anthropic/claude-sonnet-4-5"); - assert.strictEqual(getDefaultModel("architect", "opus"), "anthropic/claude-opus-4-5"); + assert.strictEqual(getDefaultModel("dev", "mid"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("qa", "mid"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); }); it("should return all default models", () => { @@ -117,12 +145,25 @@ describe("models", () => { it("should pass through unknown level as model ID", () => { assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); }); + + it("should resolve old config keys via aliases", () => { + // Old config uses "medior" key — should still resolve + const config = { models: { dev: { medior: "custom/old-config-model" } } }; + assert.strictEqual(resolveModel("dev", "medior", config), "custom/old-config-model"); + // Also works when requesting the canonical name + assert.strictEqual(resolveModel("dev", "mid", {}), "anthropic/claude-sonnet-4-5"); + }); + + it("should resolve old qa config keys", () => { + const config = { models: { qa: { reviewer: "custom/qa-model" } } }; + assert.strictEqual(resolveModel("qa", "reviewer", config), "custom/qa-model"); + }); }); describe("emoji", () => { it("should return level emoji", () => { assert.strictEqual(getEmoji("dev", "junior"), "⚡"); - assert.strictEqual(getEmoji("architect", "opus"), "🏗️"); + assert.strictEqual(getEmoji("architect", "senior"), "🏗️"); }); it("should return fallback emoji", () => { diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index 1243428..7ef6d7a 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -17,16 +17,16 @@ export const ROLE_REGISTRY: Record = { dev: { id: "dev", displayName: "DEV", - levels: ["junior", "medior", "senior"], - defaultLevel: "medior", + levels: ["junior", "mid", "senior"], + defaultLevel: "mid", models: { junior: "anthropic/claude-haiku-4-5", - medior: "anthropic/claude-sonnet-4-5", + mid: "anthropic/claude-sonnet-4-5", senior: "anthropic/claude-opus-4-5", }, emoji: { junior: "⚡", - medior: "🔧", + mid: "🔧", senior: "🧠", }, fallbackEmoji: "🔧", @@ -38,15 +38,17 @@ export const ROLE_REGISTRY: Record = { qa: { id: "qa", displayName: "QA", - levels: ["reviewer", "tester"], - defaultLevel: "reviewer", + levels: ["junior", "mid", "senior"], + defaultLevel: "mid", models: { - reviewer: "anthropic/claude-sonnet-4-5", - tester: "anthropic/claude-haiku-4-5", + junior: "anthropic/claude-haiku-4-5", + mid: "anthropic/claude-sonnet-4-5", + senior: "anthropic/claude-opus-4-5", }, emoji: { - reviewer: "🔍", - tester: "👀", + junior: "⚡", + mid: "🔍", + senior: "🧠", }, fallbackEmoji: "🔍", completionResults: ["pass", "fail", "refine", "blocked"], @@ -57,15 +59,15 @@ export const ROLE_REGISTRY: Record = { architect: { id: "architect", displayName: "ARCHITECT", - levels: ["opus", "sonnet"], - defaultLevel: "sonnet", + levels: ["junior", "senior"], + defaultLevel: "junior", models: { - opus: "anthropic/claude-opus-4-5", - sonnet: "anthropic/claude-sonnet-4-5", + junior: "anthropic/claude-sonnet-4-5", + senior: "anthropic/claude-opus-4-5", }, emoji: { - opus: "🏗️", - sonnet: "📐", + junior: "📐", + senior: "🏗️", }, fallbackEmoji: "🏗️", completionResults: ["done", "blocked"], diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index 6fd88c1..ba7e5ad 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -36,6 +36,21 @@ export function requireRole(role: string): RoleConfig { return config; } +// --------------------------------------------------------------------------- +// Level aliases — maps old level names to new canonical names +// --------------------------------------------------------------------------- + +const LEVEL_ALIASES: Record> = { + dev: { medior: "mid" }, + qa: { reviewer: "mid", tester: "junior" }, + architect: { opus: "senior", sonnet: "junior" }, +}; + +/** Resolve a level name, applying aliases for backward compatibility. */ +export function canonicalLevel(role: string, level: string): string { + return LEVEL_ALIASES[role]?.[level] ?? level; +} + // --------------------------------------------------------------------------- // Levels // --------------------------------------------------------------------------- @@ -90,7 +105,7 @@ export function getAllDefaultModels(): Record> { * Resolve a level to a full model ID. * * Resolution order: - * 1. Plugin config `models..` + * 1. Plugin config `models..` (tries canonical name, then original) * 2. Registry default model * 3. Passthrough (treat level as raw model ID) */ @@ -99,12 +114,15 @@ export function resolveModel( level: string, pluginConfig?: Record, ): string { + const canonical = canonicalLevel(role, level); const models = (pluginConfig as { models?: Record })?.models; if (models && typeof models === "object") { const roleModels = models[role] as Record | undefined; + // Try canonical name first, then original (for old configs) + if (roleModels?.[canonical]) return roleModels[canonical]; if (roleModels?.[level]) return roleModels[level]; } - return getDefaultModel(role, level) ?? level; + return getDefaultModel(role, canonical) ?? canonical; } // --------------------------------------------------------------------------- diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 97a54e3..d4c10cf 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -48,7 +48,7 @@ export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW); export function detectLevelFromLabels(labels: string[]): string | null { const lower = labels.map((l) => l.toLowerCase()); - // Match role.level labels (e.g., "dev.senior", "qa.reviewer", "architect.opus") + // Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior") for (const l of lower) { const dot = l.indexOf("."); if (dot === -1) continue; diff --git a/lib/setup/config.ts b/lib/setup/config.ts index 2384b6b..9c0a783 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; -type ModelConfig = { dev: Record; qa: Record }; +type ModelConfig = { dev: Record; qa: Record; architect: Record }; /** * Write DevClaw model level config to openclaw.json plugins section. diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 307a6cf..9d4c8e4 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -11,7 +11,7 @@ import { createAgent, resolveWorkspacePath } from "./agent.js"; import { writePluginConfig } from "./config.js"; import { scaffoldWorkspace } from "./workspace.js"; -export type ModelConfig = { dev: Record; qa: Record }; +export type ModelConfig = { dev: Record; qa: Record; architect: Record }; export type SetupOpts = { /** OpenClaw plugin API for config access. */ @@ -27,7 +27,7 @@ export type SetupOpts = { /** Override workspace path (auto-detected from agent if not given). */ workspacePath?: string; /** Model overrides per role.level. Missing levels use defaults. */ - models?: { dev?: Partial>; qa?: Partial> }; + models?: { dev?: Partial>; qa?: Partial>; architect?: Partial> }; /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ projectExecution?: "parallel" | "sequential"; }; @@ -115,6 +115,7 @@ async function tryMigrateBinding( function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { const dev: Record = { ...DEFAULT_MODELS.dev }; const qa: Record = { ...DEFAULT_MODELS.qa }; + const architect: Record = { ...DEFAULT_MODELS.architect }; if (overrides?.dev) { for (const [level, model] of Object.entries(overrides.dev)) { @@ -126,6 +127,11 @@ function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { 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 }; + return { dev, qa, architect }; } diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index a3668b4..9cbb2fe 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -8,16 +8,17 @@ import { runCommand } from "../run-command.js"; export type ModelAssignment = { dev: { junior: string; - medior: string; + mid: string; senior: string; }; qa: { - reviewer: string; - tester: string; + junior: string; + mid: string; + senior: string; }; architect: { - opus: string; - sonnet: string; + junior: string; + senior: string; }; }; @@ -36,9 +37,9 @@ export async function selectModelsWithLLM( if (availableModels.length === 1) { const model = availableModels[0].model; return { - dev: { junior: model, medior: model, senior: model }, - qa: { reviewer: model, tester: model }, - architect: { opus: model, sonnet: model }, + dev: { junior: model, mid: model, senior: model }, + qa: { junior: model, mid: model, senior: model }, + architect: { junior: model, senior: model }, }; } @@ -50,18 +51,16 @@ export async function selectModelsWithLLM( Available models: ${modelList} -Assign models to these roles based on capability: +All roles use the same level scheme based on task complexity: - **senior** (most capable): Complex architecture, refactoring, critical decisions -- **medior** (balanced): Features, bug fixes, code review -- **junior** (fast/efficient): Simple fixes, testing, routine tasks -- **reviewer** (same as medior): Code review -- **tester** (same as junior): Testing +- **mid** (balanced): Features, bug fixes, code review, standard tasks +- **junior** (fast/efficient): Simple fixes, routine tasks Rules: 1. Prefer same provider for consistency 2. Assign most capable model to senior -3. Assign mid-tier model to medior/reviewer -4. Assign fastest/cheapest model to junior/tester +3. Assign mid-tier model to mid +4. Assign fastest/cheapest model to junior 5. Consider model version numbers (higher = newer/better) 6. Stable versions (no date) > snapshot versions (with date like 20250514) @@ -69,16 +68,17 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): { "dev": { "junior": "provider/model-name", - "medior": "provider/model-name", + "mid": "provider/model-name", "senior": "provider/model-name" }, "qa": { - "reviewer": "provider/model-name", - "tester": "provider/model-name" + "junior": "provider/model-name", + "mid": "provider/model-name", + "senior": "provider/model-name" }, "architect": { - "opus": "provider/model-name", - "sonnet": "provider/model-name" + "junior": "provider/model-name", + "senior": "provider/model-name" } }`; @@ -131,17 +131,18 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): // Backfill architect if LLM didn't return it (graceful upgrade) if (!assignment.architect) { assignment.architect = { - opus: assignment.dev?.senior ?? availableModels[0].model, - sonnet: assignment.dev?.medior ?? availableModels[0].model, + senior: assignment.dev?.senior ?? availableModels[0].model, + junior: assignment.dev?.mid ?? availableModels[0].model, }; } if ( !assignment.dev?.junior || - !assignment.dev?.medior || + !assignment.dev?.mid || !assignment.dev?.senior || - !assignment.qa?.reviewer || - !assignment.qa?.tester + !assignment.qa?.junior || + !assignment.qa?.mid || + !assignment.qa?.senior ) { console.error("Invalid assignment structure. Got:", assignment); throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`); diff --git a/lib/setup/smart-model-selector.ts b/lib/setup/smart-model-selector.ts index eb78eb5..1c2fdf6 100644 --- a/lib/setup/smart-model-selector.ts +++ b/lib/setup/smart-model-selector.ts @@ -7,16 +7,17 @@ export type ModelAssignment = { dev: { junior: string; - medior: string; + mid: string; senior: string; }; qa: { - reviewer: string; - tester: string; + junior: string; + mid: string; + senior: string; }; architect: { - opus: string; - sonnet: string; + junior: string; + senior: string; }; }; @@ -43,9 +44,9 @@ export async function assignModels( if (authenticated.length === 1) { const model = authenticated[0].model; return { - dev: { junior: model, medior: model, senior: model }, - qa: { reviewer: model, tester: model }, - architect: { opus: model, sonnet: model }, + dev: { junior: model, mid: model, senior: model }, + qa: { junior: model, mid: model, senior: model }, + architect: { junior: model, senior: model }, }; } @@ -68,12 +69,13 @@ export function formatAssignment(assignment: ModelAssignment): string { "| Role | Level | Model |", "|------|----------|--------------------------|", `| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`, - `| DEV | medior | ${assignment.dev.medior.padEnd(24)} |`, + `| DEV | mid | ${assignment.dev.mid.padEnd(24)} |`, `| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`, - `| QA | reviewer | ${assignment.qa.reviewer.padEnd(24)} |`, - `| QA | tester | ${assignment.qa.tester.padEnd(24)} |`, - `| ARCH | opus | ${assignment.architect.opus.padEnd(24)} |`, - `| ARCH | sonnet | ${assignment.architect.sonnet.padEnd(24)} |`, + `| QA | senior | ${assignment.qa.senior.padEnd(24)} |`, + `| QA | mid | ${assignment.qa.mid.padEnd(24)} |`, + `| QA | junior | ${assignment.qa.junior.padEnd(24)} |`, + `| ARCH | senior | ${assignment.architect.senior.padEnd(24)} |`, + `| ARCH | junior | ${assignment.architect.junior.padEnd(24)} |`, ]; return lines.join("\n"); } diff --git a/lib/templates.ts b/lib/templates.ts index 7fa7d23..cc8f322 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -172,7 +172,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder **Why this matters:** - **Audit trail** — Every code change is tracked to an issue -- **Tier selection** — Junior/medior/senior models match task complexity +- **Level selection** — Junior/mid/senior models match task complexity - **Parallelization** — Workers run in parallel, you stay free to plan - **QA pipeline** — Code goes through review before closing @@ -195,7 +195,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder Examples: - ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42" -- ✅ "Picked up #42 for DEV (medior) 🔗 https://github.com/org/repo/issues/42" +- ✅ "Picked up #42 for DEV (mid) 🔗 https://github.com/org/repo/issues/42" - ❌ "Created issue #42 about the login bug" (missing URL) ### DevClaw Tools @@ -232,11 +232,10 @@ Issue labels are the single source of truth for task state. Evaluate each task and pass the appropriate developer level to \`work_start\`: - **junior** — trivial: typos, single-file fix, quick change -- **medior** — standard: features, bug fixes, multi-file changes +- **mid** — standard: features, bug fixes, multi-file changes - **senior** — complex: architecture, system-wide refactoring, 5+ services -- **reviewer** — QA: code inspection, validation, test runs -- **opus** — Architect: complex, high-impact design investigations -- **sonnet** — Architect: standard feature design investigations + +All roles (DEV, QA, Architect) use the same level scheme. Levels describe task complexity, not the model. ### Picking Up Work diff --git a/lib/tiers.ts b/lib/tiers.ts index ae73b90..2f08915 100644 --- a/lib/tiers.ts +++ b/lib/tiers.ts @@ -4,7 +4,7 @@ * This module now delegates to the centralized role registry (lib/roles/). * Kept for backward compatibility — new code should import from lib/roles/ directly. * - * Level names are plain: "junior", "senior", "reviewer", etc. + * Level names are plain: "junior", "mid", "senior". * Role context (dev/qa/architect) is always provided by the caller. */ import { diff --git a/lib/tools/autoconfigure-models.ts b/lib/tools/autoconfigure-models.ts index cd6f62a..7c610d4 100644 --- a/lib/tools/autoconfigure-models.ts +++ b/lib/tools/autoconfigure-models.ts @@ -102,7 +102,7 @@ export function createAutoConfigureModelsTool(api: OpenClawPluginApi) { if (modelCount === 1) { message += "ℹ️ Only one authenticated model found — assigned to all roles."; } else { - message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → medior/reviewer, Tier 3 → junior/tester)."; + message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → mid, Tier 3 → junior)."; } if (preferProvider) { diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index ecd7536..0581a39 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -14,29 +14,31 @@ import { describe("architect tiers", () => { it("should recognize architect levels", () => { - assert.strictEqual(isArchitectLevel("opus"), true); - assert.strictEqual(isArchitectLevel("sonnet"), true); - assert.strictEqual(isArchitectLevel("medior"), false); + assert.strictEqual(isArchitectLevel("junior"), true); + assert.strictEqual(isArchitectLevel("senior"), true); + assert.strictEqual(isArchitectLevel("mid"), false); }); it("should map architect levels to role", () => { - assert.strictEqual(levelRole("opus"), "architect"); - assert.strictEqual(levelRole("sonnet"), "architect"); + // "junior" and "senior" appear in dev first (registry order), so roleForLevel returns "dev" + // This is expected — use isArchitectLevel for architect-specific checks + assert.strictEqual(levelRole("junior"), "dev"); + assert.strictEqual(levelRole("senior"), "dev"); }); it("should resolve default architect models", () => { - assert.strictEqual(defaultModel("architect", "opus"), "anthropic/claude-opus-4-5"); - assert.strictEqual(defaultModel("architect", "sonnet"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); + assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); it("should resolve architect model from config", () => { - const config = { models: { architect: { opus: "custom/model" } } }; - assert.strictEqual(resolveModel("architect", "opus", config), "custom/model"); + const config = { models: { architect: { senior: "custom/model" } } }; + assert.strictEqual(resolveModel("architect", "senior", config), "custom/model"); }); it("should have architect emoji", () => { - assert.strictEqual(levelEmoji("architect", "opus"), "🏗️"); - assert.strictEqual(levelEmoji("architect", "sonnet"), "📐"); + assert.strictEqual(levelEmoji("architect", "senior"), "🏗️"); + assert.strictEqual(levelEmoji("architect", "junior"), "📐"); }); }); @@ -81,25 +83,25 @@ describe("architect workflow states", () => { }); describe("architect model selection", () => { - it("should select sonnet for standard design tasks", () => { + it("should select junior for standard design tasks", () => { const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect"); - assert.strictEqual(result.level, "sonnet"); + assert.strictEqual(result.level, "junior"); }); - it("should select opus for complex design tasks", () => { + it("should select senior for complex design tasks", () => { const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect"); - assert.strictEqual(result.level, "opus"); + assert.strictEqual(result.level, "senior"); }); }); describe("architect session key parsing", () => { it("should parse architect session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-opus"); + const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-senior"); assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" }); }); - it("should parse architect sonnet session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-sonnet"); + it("should parse architect junior session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-junior"); assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" }); }); }); diff --git a/lib/tools/design-task.ts b/lib/tools/design-task.ts index 2c9a1ea..12e379d 100644 --- a/lib/tools/design-task.ts +++ b/lib/tools/design-task.ts @@ -58,7 +58,7 @@ Example: complexity: { type: "string", enum: ["simple", "medium", "complex"], - description: "Suggests architect level: simple/medium → sonnet, complex → opus. Defaults to medium.", + description: "Suggests architect level: simple/medium → junior, complex → senior. Defaults to medium.", }, dryRun: { type: "boolean", @@ -110,7 +110,7 @@ Example: }); // Select level based on complexity - const level = complexity === "complex" ? "opus" : "sonnet"; + const level = complexity === "complex" ? "senior" : "junior"; if (dryRun) { return jsonResult({ diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 5091d54..40fbbe2 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -8,7 +8,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { runSetup, type SetupOpts } from "../setup/index.js"; -import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js"; +import { DEFAULT_MODELS } from "../tiers.js"; +import { getLevelsForRole } from "../roles/index.js"; export function createSetupTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -45,9 +46,9 @@ export function createSetupTool(api: OpenClawPluginApi) { type: "string", description: `Default: ${DEFAULT_MODELS.dev.junior}`, }, - medior: { + mid: { type: "string", - description: `Default: ${DEFAULT_MODELS.dev.medior}`, + description: `Default: ${DEFAULT_MODELS.dev.mid}`, }, senior: { type: "string", @@ -59,13 +60,17 @@ export function createSetupTool(api: OpenClawPluginApi) { type: "object", description: "QA level models", properties: { - reviewer: { + junior: { type: "string", - description: `Default: ${DEFAULT_MODELS.qa.reviewer}`, + description: `Default: ${DEFAULT_MODELS.qa.junior}`, }, - tester: { + mid: { type: "string", - description: `Default: ${DEFAULT_MODELS.qa.tester}`, + description: `Default: ${DEFAULT_MODELS.qa.mid}`, + }, + senior: { + type: "string", + description: `Default: ${DEFAULT_MODELS.qa.senior}`, }, }, }, @@ -109,8 +114,9 @@ export function createSetupTool(api: OpenClawPluginApi) { } lines.push( "Models:", - ...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`), - ...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`), + ...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`), + ...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`), + ...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[t]}`), "", ); diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 8c2e207..e0e9aaa 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -30,7 +30,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { projectGroupId: { type: "string", description: "Project group ID." }, issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." }, role: { type: "string", enum: getAllRoleIds(), description: "Worker role. Auto-detected from label if omitted." }, - level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." }, + level: { type: "string", description: "Worker level (junior/mid/senior). Auto-detected if omitted." }, }, }, From 0e24a6888244e966a3c49c4d2d4f1042b2dcaa90 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 18:32:10 +0800 Subject: [PATCH 02/13] 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. --- lib/bootstrap-hook.test.ts | 52 +++--- lib/bootstrap-hook.ts | 4 +- lib/cli.ts | 57 +++---- lib/config/index.ts | 14 ++ lib/config/loader.ts | 170 ++++++++++++++++++++ lib/config/merge.ts | 82 ++++++++++ lib/config/types.ts | 49 ++++++ lib/dispatch.ts | 27 ++-- lib/model-selector.ts | 74 +++++---- lib/notify.ts | 4 +- lib/onboarding.ts | 24 ++- lib/projects.test.ts | 254 ++++++++++++++++++++++++++++++ lib/projects.ts | 71 +++++---- lib/providers/provider.ts | 26 +-- lib/roles/index.ts | 4 + lib/roles/registry.test.ts | 123 ++++++++------- lib/roles/registry.ts | 32 ++-- lib/roles/selectors.ts | 44 +++++- lib/roles/types.ts | 4 +- lib/services/heartbeat.ts | 11 +- lib/services/pipeline.ts | 38 +---- lib/services/queue.ts | 20 --- lib/services/tick.ts | 44 ++---- lib/setup/config.ts | 2 +- lib/setup/index.ts | 34 ++-- lib/setup/llm-model-selector.ts | 40 ++--- lib/setup/smart-model-selector.ts | 32 ++-- lib/templates.ts | 61 +++---- lib/tiers.ts | 90 ----------- lib/tools/design-task.test.ts | 29 ++-- lib/tools/design-task.ts | 53 ++++--- lib/tools/health.ts | 7 +- lib/tools/project-register.ts | 49 +++--- lib/tools/queue-status.test.ts | 16 +- lib/tools/setup.ts | 63 +++----- lib/tools/status.ts | 36 ++--- lib/tools/task-comment.ts | 18 +-- lib/tools/task-create.ts | 5 +- lib/tools/task-update.ts | 7 +- lib/tools/work-finish.ts | 9 +- lib/tools/work-start.ts | 27 ++-- lib/workflow.ts | 107 +++++-------- package-lock.json | 8 +- package.json | 3 + 44 files changed, 1162 insertions(+), 762 deletions(-) create mode 100644 lib/config/index.ts create mode 100644 lib/config/loader.ts create mode 100644 lib/config/merge.ts create mode 100644 lib/config/types.ts create mode 100644 lib/projects.test.ts delete mode 100644 lib/tiers.ts diff --git a/lib/bootstrap-hook.test.ts b/lib/bootstrap-hook.test.ts index 844841b..d6ac418 100644 --- a/lib/bootstrap-hook.test.ts +++ b/lib/bootstrap-hook.test.ts @@ -10,24 +10,24 @@ import path from "node:path"; import os from "node:os"; describe("parseDevClawSessionKey", () => { - it("should parse a standard dev session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-mid"); - assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" }); + it("should parse a standard developer session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-developer-medior"); + assert.deepStrictEqual(result, { projectName: "my-project", role: "developer" }); }); - it("should parse a qa session key", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-mid"); - assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" }); + it("should parse a tester session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-tester-medior"); + assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" }); }); it("should handle project names with hyphens", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior"); - assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" }); + const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior"); + assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" }); }); - it("should handle project names with multiple hyphens and qa role", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-junior"); - assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" }); + it("should handle project names with multiple hyphens and tester role", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-tester-junior"); + assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "tester" }); }); it("should return null for non-subagent session keys", () => { @@ -45,14 +45,14 @@ describe("parseDevClawSessionKey", () => { assert.strictEqual(result, null); }); - it("should parse senior dev level", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior"); - assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" }); + it("should parse senior developer level", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-developer-senior"); + assert.deepStrictEqual(result, { projectName: "devclaw", role: "developer" }); }); it("should parse simple project name", () => { - const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior"); - assert.deepStrictEqual(result, { projectName: "api", role: "dev" }); + const result = parseDevClawSessionKey("agent:devclaw:subagent:api-developer-junior"); + assert.deepStrictEqual(result, { projectName: "api", role: "developer" }); }); }); @@ -61,10 +61,10 @@ describe("loadRoleInstructions", () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); const projectDir = path.join(tmpDir, "projects", "roles", "test-project"); await fs.mkdir(projectDir, { recursive: true }); - await fs.writeFile(path.join(projectDir, "dev.md"), "# Dev Instructions\nDo the thing."); + await fs.writeFile(path.join(projectDir, "developer.md"), "# Developer Instructions\nDo the thing."); - const result = await loadRoleInstructions(tmpDir, "test-project", "dev"); - assert.strictEqual(result, "# Dev Instructions\nDo the thing."); + const result = await loadRoleInstructions(tmpDir, "test-project", "developer"); + assert.strictEqual(result, "# Developer Instructions\nDo the thing."); await fs.rm(tmpDir, { recursive: true }); }); @@ -73,10 +73,10 @@ describe("loadRoleInstructions", () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); const defaultDir = path.join(tmpDir, "projects", "roles", "default"); await fs.mkdir(defaultDir, { recursive: true }); - await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA Default\nReview carefully."); + await fs.writeFile(path.join(defaultDir, "tester.md"), "# Tester Default\nReview carefully."); - const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "qa"); - assert.strictEqual(result, "# QA Default\nReview carefully."); + const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester"); + assert.strictEqual(result, "# Tester Default\nReview carefully."); await fs.rm(tmpDir, { recursive: true }); }); @@ -84,7 +84,7 @@ describe("loadRoleInstructions", () => { it("should return empty string when no instructions exist", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); - const result = await loadRoleInstructions(tmpDir, "missing", "dev"); + const result = await loadRoleInstructions(tmpDir, "missing", "developer"); assert.strictEqual(result, ""); await fs.rm(tmpDir, { recursive: true }); @@ -96,10 +96,10 @@ describe("loadRoleInstructions", () => { const defaultDir = path.join(tmpDir, "projects", "roles", "default"); await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(defaultDir, { recursive: true }); - await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions"); - await fs.writeFile(path.join(defaultDir, "dev.md"), "Default instructions"); + await fs.writeFile(path.join(projectDir, "developer.md"), "Project-specific instructions"); + await fs.writeFile(path.join(defaultDir, "developer.md"), "Default instructions"); - const result = await loadRoleInstructions(tmpDir, "my-project", "dev"); + const result = await loadRoleInstructions(tmpDir, "my-project", "developer"); assert.strictEqual(result, "Project-specific instructions"); await fs.rm(tmpDir, { recursive: true }); diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index 8de14c2..f81a118 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -17,8 +17,8 @@ import { getSessionKeyRolePattern } from "./roles/index.js"; * * Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}` * Examples: - * - `agent:devclaw:subagent:my-project-dev-mid` → { projectName: "my-project", role: "dev" } - * - `agent:devclaw:subagent:webapp-qa-mid` → { projectName: "webapp", role: "qa" } + * - `agent:devclaw:subagent:my-project-developer-medior` → { projectName: "my-project", role: "developer" } + * - `agent:devclaw:subagent:webapp-tester-medior` → { projectName: "webapp", role: "tester" } * * Note: projectName may contain hyphens, so we match role from the end. */ diff --git a/lib/cli.ts b/lib/cli.ts index c31473d..d8e1ed3 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -6,8 +6,7 @@ import type { Command } from "commander"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { runSetup } from "./setup/index.js"; -import { DEFAULT_MODELS } from "./tiers.js"; -import { getLevelsForRole } from "./roles/index.js"; +import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "./roles/index.js"; /** * Register the `devclaw` CLI command group on a Commander program. @@ -17,39 +16,41 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { .command("devclaw") .description("DevClaw development pipeline tools"); - devclaw + const setupCmd = devclaw .command("setup") .description("Set up DevClaw: create agent, configure models, write workspace files") .option("--new-agent ", "Create a new agent with this name") .option("--agent ", "Use an existing agent by ID") - .option("--workspace ", "Direct workspace path") - .option("--junior ", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`) - .option("--mid ", `Mid dev model (default: ${DEFAULT_MODELS.dev.mid})`) - .option("--senior ", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`) - .option("--qa-junior ", `QA junior model (default: ${DEFAULT_MODELS.qa.junior})`) - .option("--qa-mid ", `QA mid model (default: ${DEFAULT_MODELS.qa.mid})`) - .option("--qa-senior ", `QA senior model (default: ${DEFAULT_MODELS.qa.senior})`) - .action(async (opts) => { - const dev: Record = {}; - const qa: Record = {}; - if (opts.junior) dev.junior = opts.junior; - if (opts.mid) dev.mid = opts.mid; - if (opts.senior) dev.senior = opts.senior; - if (opts.qaJunior) qa.junior = opts.qaJunior; - if (opts.qaMid) qa.mid = opts.qaMid; - if (opts.qaSenior) qa.senior = opts.qaSenior; + .option("--workspace ", "Direct workspace path"); - const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0; - const models = hasOverrides - ? { ...(Object.keys(dev).length > 0 && { dev }), ...(Object.keys(qa).length > 0 && { qa }) } - : undefined; + // Register dynamic --- options from registry + const defaults = getAllDefaultModels(); + for (const role of getAllRoleIds()) { + for (const level of getLevelsForRole(role)) { + const flag = `--${role}-${level}`; + setupCmd.option(`${flag} `, `${role.toUpperCase()} ${level} model (default: ${defaults[role]?.[level] ?? "auto"})`); + } + } + + setupCmd.action(async (opts) => { + // Build model overrides from CLI flags dynamically + const models: Record> = {}; + for (const role of getAllRoleIds()) { + const roleModels: Record = {}; + for (const level of getLevelsForRole(role)) { + // camelCase key: "testerJunior" for --tester-junior, "developerMedior" for --developer-medior + const key = `${role}${level.charAt(0).toUpperCase()}${level.slice(1)}`; + if (opts[key]) roleModels[level] = opts[key]; + } + if (Object.keys(roleModels).length > 0) models[role] = roleModels; + } const result = await runSetup({ api, newAgentName: opts.newAgent, agentId: opts.agent, workspacePath: opts.workspace, - models, + models: Object.keys(models).length > 0 ? models : undefined, }); if (result.agentCreated) { @@ -57,9 +58,11 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void { } console.log("Models configured:"); - for (const t of getLevelsForRole("dev")) console.log(` dev.${t}: ${result.models.dev[t]}`); - for (const t of getLevelsForRole("qa")) console.log(` qa.${t}: ${result.models.qa[t]}`); - for (const t of getLevelsForRole("architect")) console.log(` architect.${t}: ${result.models.architect[t]}`); + for (const [role, levels] of Object.entries(result.models)) { + for (const [level, model] of Object.entries(levels)) { + console.log(` ${role}.${level}: ${model}`); + } + } console.log("Files written:"); for (const file of result.filesWritten) { diff --git a/lib/config/index.ts b/lib/config/index.ts new file mode 100644 index 0000000..3e9b285 --- /dev/null +++ b/lib/config/index.ts @@ -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"; diff --git a/lib/config/loader.ts b/lib/config/loader.ts new file mode 100644 index 0000000..c0c883c --- /dev/null +++ b/lib/config/loader.ts @@ -0,0 +1,170 @@ +/** + * config/loader.ts — Three-layer config loading. + * + * Resolution order: + * 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW) + * 2. Workspace: /projects/config.yaml + * 3. Project: /projects//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 { + 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 = {}; + 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 = {}; + + 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 { + 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 | null> { + try { + const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); + return YAML.parse(content) as Partial; + } 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 + | { workflow?: Partial }; + return (parsed as any).workflow ?? parsed; + } catch { /* not found */ } + + return null; +} diff --git a/lib/config/merge.ts b/lib/config/merge.ts new file mode 100644 index 0000000..5fad7bb --- /dev/null +++ b/lib/config/merge.ts @@ -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 } : {}), + }; +} diff --git a/lib/config/types.ts b/lib/config/types.ts new file mode 100644 index 0000000..da48d31 --- /dev/null +++ b/lib/config/types.ts @@ -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; + emoji?: Record; + completionResults?: string[]; +}; + +/** + * The full config.yaml shape. + * All fields optional — missing fields inherit from the layer below. + */ +export type DevClawConfig = { + roles?: Record; + workflow?: Partial; +}; + +/** + * Fully resolved config — all fields guaranteed present. + * Built by merging three layers over the built-in defaults. + */ +export type ResolvedConfig = { + roles: Record; + workflow: WorkflowConfig; +}; + +/** + * Fully resolved role config — all fields present. + */ +export type ResolvedRoleConfig = { + levels: string[]; + defaultLevel: string; + models: Record; + emoji: Record; + completionResults: string[]; + enabled: boolean; +}; diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 8f9b14a..08bacce 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -13,8 +13,9 @@ import { getSessionForLevel, getWorker, } from "./projects.js"; -import { resolveModel, getEmoji, getFallbackEmoji } from "./roles/index.js"; +import { resolveModel, getFallbackEmoji } from "./roles/index.js"; import { notify, getNotificationConfig } from "./notify.js"; +import { loadConfig, type ResolvedRoleConfig } from "./config/index.js"; export type DispatchOpts = { workspaceDir: string; @@ -25,7 +26,7 @@ export type DispatchOpts = { issueTitle: string; issueDescription: string; issueUrl: string; - role: "dev" | "qa" | "architect"; + role: string; /** Developer level (junior, mid, senior) or raw model ID */ level: string; /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ @@ -63,7 +64,7 @@ export type DispatchResult = { */ export function buildTaskMessage(opts: { projectName: string; - role: "dev" | "qa" | "architect"; + role: string; issueId: number; issueTitle: string; issueDescription: string; @@ -72,16 +73,15 @@ export function buildTaskMessage(opts: { baseBranch: string; groupId: string; comments?: Array<{ author: string; body: string; created_at: string }>; + resolvedRole?: ResolvedRoleConfig; }): string { const { projectName, role, issueId, issueTitle, issueDescription, issueUrl, repo, baseBranch, groupId, } = opts; - const availableResults = - role === "dev" || role === "architect" - ? '"done" (completed successfully) or "blocked" (cannot complete, need help)' - : '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)'; + const results = opts.resolvedRole?.completionResults ?? []; + const availableResults = results.map((r: string) => `"${r}"`).join(", "); const parts = [ `${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`, @@ -149,7 +149,9 @@ export async function dispatchTask( transitionLabel, provider, pluginConfig, runtime, } = opts; - const model = resolveModel(role, level, pluginConfig); + const resolvedConfig = await loadConfig(workspaceDir, project.name); + const resolvedRole = resolvedConfig.roles[role]; + const model = resolveModel(role, level, pluginConfig, resolvedRole); const worker = getWorker(project, role); const existingSessionKey = getSessionForLevel(worker, level); const sessionAction = existingSessionKey ? "send" : "spawn"; @@ -164,7 +166,7 @@ export async function dispatchTask( projectName: project.name, role, issueId, issueTitle, issueDescription, issueUrl, repo: project.repo, baseBranch: project.baseBranch, groupId, - comments, + comments, resolvedRole, }); // Step 1: Transition label (this is the commitment point) @@ -225,7 +227,7 @@ export async function dispatchTask( fromLabel, toLabel, }); - const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl); + const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl, resolvedRole); return { sessionAction, sessionKey, level, model, announcement }; } @@ -267,7 +269,7 @@ function sendToAgent( } async function recordWorkerState( - workspaceDir: string, groupId: string, role: "dev" | "qa" | "architect", + workspaceDir: string, groupId: string, role: string, opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" }, ): Promise { await activateWorker(workspaceDir, groupId, role, { @@ -301,8 +303,9 @@ async function auditDispatch( function buildAnnouncement( level: string, role: string, sessionAction: "spawn" | "send", issueId: number, issueTitle: string, issueUrl: string, + resolvedRole?: ResolvedRoleConfig, ): string { - const emoji = getEmoji(role, level) ?? getFallbackEmoji(role); + const emoji = resolvedRole?.emoji[level] ?? getFallbackEmoji(role); const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index d99f5ab..48c0a22 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -1,8 +1,13 @@ /** - * Model selection for dev/qa tasks. - * Keyword heuristic fallback — used when the orchestrator doesn't specify a level. - * Returns plain level names (junior, mid, senior). + * Model selection heuristic fallback — used when the orchestrator doesn't specify a level. + * Returns plain level names (junior, medior, senior). + * + * Adapts to any role's level count: + * - 1 level: always returns that level + * - 2 levels: simple binary (complex → last, else first) + * - 3+ levels: full heuristic (simple → first, complex → last, default → middle) */ +import { getLevelsForRole, getDefaultLevel } from "./roles/index.js"; export type LevelSelection = { level: string; @@ -39,60 +44,59 @@ const COMPLEX_KEYWORDS = [ ]; /** - * Select appropriate developer level based on task description. + * Select appropriate level based on task description and role. * - * All roles use consistent levels: - * - junior: simple tasks (typos, single-file fixes, CSS tweaks) - * - mid: standard work (features, bug fixes, multi-file changes) - * - senior: deep/architectural (system-wide refactoring, novel design) + * Adapts to the role's available levels: + * - Roles with 1 level → always that level + * - Roles with 2 levels → binary: complex keywords → highest, else lowest + * - Roles with 3+ levels → full heuristic: simple → lowest, complex → highest, else default */ export function selectLevel( issueTitle: string, issueDescription: string, - role: "dev" | "qa" | "architect", + role: string, ): LevelSelection { - if (role === "qa") { - return { - level: "mid", - reason: "Default QA level for code inspection and validation", - }; - } + const levels = getLevelsForRole(role); + const defaultLvl = getDefaultLevel(role); - if (role === "architect") { - const text = `${issueTitle} ${issueDescription}`.toLowerCase(); - const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); - return { - level: isComplex ? "senior" : "junior", - reason: isComplex - ? "Complex design task — using senior for depth" - : "Standard design task — using junior", - }; + // Roles with only 1 level — always return it + if (levels.length <= 1) { + const level = levels[0] ?? defaultLvl ?? "medior"; + return { level, reason: `Only level for ${role}` }; } const text = `${issueTitle} ${issueDescription}`.toLowerCase(); const wordCount = text.split(/\s+/).length; - - // Check for simple task indicators const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw)); + const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); + + const lowest = levels[0]; + const highest = levels[levels.length - 1]; + + // Roles with 2 levels — binary decision + if (levels.length === 2) { + if (isComplex) { + return { level: highest, reason: `Complex task — using ${highest}` }; + } + return { level: lowest, reason: `Standard task — using ${lowest}` }; + } + + // Roles with 3+ levels — full heuristic if (isSimple && wordCount < 100) { return { - level: "junior", + level: lowest, reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`, }; } - // Check for complex task indicators - const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); if (isComplex || wordCount > 500) { return { - level: "senior", + level: highest, reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`, }; } - // Default: mid for standard dev work - return { - level: "mid", - reason: "Standard dev task — multi-file changes, features, bug fixes", - }; + // Default level for the role + const level = defaultLvl ?? levels[Math.floor(levels.length / 2)]; + return { level, reason: `Standard ${role} task` }; } diff --git a/lib/notify.ts b/lib/notify.ts index 27cec5d..40f62f5 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -21,7 +21,7 @@ export type NotifyEvent = issueId: number; issueTitle: string; issueUrl: string; - role: "dev" | "qa" | "architect"; + role: string; level: string; sessionAction: "spawn" | "send"; } @@ -31,7 +31,7 @@ export type NotifyEvent = groupId: string; issueId: number; issueUrl: string; - role: "dev" | "qa" | "architect"; + role: string; result: "done" | "pass" | "fail" | "refine" | "blocked"; summary?: string; nextState?: string; diff --git a/lib/onboarding.ts b/lib/onboarding.ts index b570c4d..5676eea 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -5,7 +5,7 @@ */ import fs from "node:fs/promises"; import path from "node:path"; -import { DEFAULT_MODELS } from "./tiers.js"; +import { getAllDefaultModels } from "./roles/index.js"; // --------------------------------------------------------------------------- // Detection @@ -38,15 +38,11 @@ export async function hasWorkspaceFiles( // --------------------------------------------------------------------------- function buildModelTable(pluginConfig?: Record): string { - const cfg = ( - pluginConfig as { - models?: { dev?: Record; qa?: Record }; - } - )?.models; + const cfg = (pluginConfig as { models?: Record> })?.models; const lines: string[] = []; - for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { + for (const [role, levels] of Object.entries(getAllDefaultModels())) { for (const [level, defaultModel] of Object.entries(levels)) { - const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel; + const model = cfg?.[role]?.[level] || defaultModel; lines.push( ` - **${role} ${level}**: ${model} (default: ${defaultModel})`, ); @@ -76,14 +72,14 @@ Ask what they want to change, then call the appropriate tool. } export function buildOnboardToolContext(): string { - // Build the model table dynamically from DEFAULT_MODELS + // Build the model table dynamically from getAllDefaultModels() const rows: string[] = []; const purposes: Record = { junior: "Simple tasks, single-file fixes", - mid: "Features, bug fixes, code review", + medior: "Features, bug fixes, code review", senior: "Architecture, refactoring, complex tasks", }; - for (const [role, levels] of Object.entries(DEFAULT_MODELS)) { + for (const [role, levels] of Object.entries(getAllDefaultModels())) { for (const [level, model] of Object.entries(levels)) { rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`); } @@ -95,8 +91,8 @@ export function buildOnboardToolContext(): string { ## What is DevClaw? DevClaw turns each Telegram group into an autonomous development team: - An **orchestrator** that manages backlogs and delegates work -- **DEV workers** (junior/mid/senior levels) that write code in isolated sessions -- **QA workers** that review code and run tests +- **Developer workers** (junior/medior/senior levels) that write code in isolated sessions +- **Tester workers** that review code and run tests - Atomic tools for label transitions, session dispatch, state management, and audit logging ## Setup Steps @@ -141,7 +137,7 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de **Step 3: Run Setup** Call \`setup\` with the collected answers: -- Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\` +- Current agent: \`setup({})\` or \`setup({ models: { developer: { ... }, tester: { ... } } })\` - New agent: \`setup({ newAgentName: "", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: ""|null, models: { ... } })\` - \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding diff --git a/lib/projects.test.ts b/lib/projects.test.ts new file mode 100644 index 0000000..4a9b471 --- /dev/null +++ b/lib/projects.test.ts @@ -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 }); + }); +}); diff --git a/lib/projects.ts b/lib/projects.ts index 6191384..f8ba3d8 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { homedir } from "node:os"; +import { LEVEL_ALIASES, ROLE_ALIASES } from "./roles/index.js"; export type WorkerState = { active: boolean; issueId: string | null; @@ -24,38 +25,28 @@ export type Project = { channel?: string; /** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */ provider?: "github" | "gitlab"; - /** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */ + /** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */ roleExecution?: "parallel" | "sequential"; maxDevWorkers?: number; maxQaWorkers?: number; - dev: WorkerState; - qa: WorkerState; - architect: WorkerState; + /** Worker state per role (developer, tester, architect, or custom roles). */ + workers: Record; }; export type ProjectsData = { projects: Record; }; -/** - * Level migration aliases: old name → new canonical name, keyed by role. - */ -const LEVEL_MIGRATION: Record> = { - dev: { medior: "mid" }, - qa: { reviewer: "mid", tester: "junior" }, - architect: { opus: "senior", sonnet: "junior" }, -}; - function migrateLevel(level: string | null, role: string): string | null { if (!level) return null; - return LEVEL_MIGRATION[role]?.[level] ?? level; + return LEVEL_ALIASES[role]?.[level] ?? level; } function migrateSessions( sessions: Record, role: string, ): Record { - const aliases = LEVEL_MIGRATION[role]; + const aliases = LEVEL_ALIASES[role]; if (!aliases) return sessions; const migrated: Record = {}; @@ -114,15 +105,33 @@ export async function readProjects(workspaceDir: string): Promise const data = JSON.parse(raw) as ProjectsData; for (const project of Object.values(data.projects)) { - project.dev = project.dev - ? parseWorkerState(project.dev as unknown as Record, "dev") - : emptyWorkerState([]); - project.qa = project.qa - ? parseWorkerState(project.qa as unknown as Record, "qa") - : emptyWorkerState([]); - project.architect = project.architect - ? parseWorkerState(project.architect as unknown as Record, "architect") - : emptyWorkerState([]); + // Migrate old format: hardcoded dev/qa/architect fields → workers map + const raw = project as unknown as Record; + if (!raw.workers && (raw.dev || raw.qa || raw.architect)) { + project.workers = {}; + for (const role of ["dev", "qa", "architect"]) { + const canonical = ROLE_ALIASES[role] ?? role; + project.workers[canonical] = raw[role] + ? parseWorkerState(raw[role] as Record, role) + : emptyWorkerState([]); + } + // Clean up old fields from the in-memory object + delete raw.dev; + delete raw.qa; + delete raw.architect; + } else if (raw.workers) { + // New format: parse each worker with role-aware migration + const workers = raw.workers as Record>; + project.workers = {}; + for (const [role, worker] of Object.entries(workers)) { + // Migrate old role keys (dev→developer, qa→tester) + const canonical = ROLE_ALIASES[role] ?? role; + project.workers[canonical] = parseWorkerState(worker, role); + } + } else { + project.workers = {}; + } + if (!project.channel) { project.channel = "telegram"; } @@ -150,9 +159,9 @@ export function getProject( export function getWorker( project: Project, - role: "dev" | "qa" | "architect", + role: string, ): WorkerState { - return project[role]; + return project.workers[role] ?? emptyWorkerState([]); } /** @@ -162,7 +171,7 @@ export function getWorker( export async function updateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa" | "architect", + role: string, updates: Partial, ): Promise { const data = await readProjects(workspaceDir); @@ -171,13 +180,13 @@ export async function updateWorker( throw new Error(`Project not found for groupId: ${groupId}`); } - const worker = project[role]; + const worker = project.workers[role] ?? emptyWorkerState([]); if (updates.sessions && worker.sessions) { updates.sessions = { ...worker.sessions, ...updates.sessions }; } - project[role] = { ...worker, ...updates }; + project.workers[role] = { ...worker, ...updates }; await writeProjects(workspaceDir, data); return data; @@ -190,7 +199,7 @@ export async function updateWorker( export async function activateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa" | "architect", + role: string, params: { issueId: string; level: string; @@ -220,7 +229,7 @@ export async function activateWorker( export async function deactivateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa" | "architect", + role: string, ): Promise { return updateWorker(workspaceDir, groupId, role, { active: false, diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index 04957a3..9eedb74 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -2,34 +2,13 @@ * IssueProvider — Abstract interface for issue tracker operations. * * Implementations: GitHub (gh CLI), GitLab (glab CLI). - * - * Note: STATE_LABELS and LABEL_COLORS are kept for backward compatibility - * but new code should use the workflow config via lib/workflow.ts. */ -import { DEFAULT_WORKFLOW, getStateLabels, getLabelColors } from "../workflow.js"; - -// --------------------------------------------------------------------------- -// State labels — derived from default workflow for backward compatibility -// --------------------------------------------------------------------------- /** - * @deprecated Use workflow.getStateLabels() instead. - * Kept for backward compatibility with existing code. - */ -export const STATE_LABELS = getStateLabels(DEFAULT_WORKFLOW) as readonly string[]; - -/** - * StateLabel type — union of all valid state labels. - * This remains a string type for flexibility with custom workflows. + * StateLabel type — string for flexibility with custom workflows. */ export type StateLabel = string; -/** - * @deprecated Use workflow.getLabelColors() instead. - * Kept for backward compatibility with existing code. - */ -export const LABEL_COLORS: Record = getLabelColors(DEFAULT_WORKFLOW); - // --------------------------------------------------------------------------- // Issue types // --------------------------------------------------------------------------- @@ -70,6 +49,3 @@ export interface IssueProvider { addComment(issueId: number, body: string): Promise; healthCheck(): Promise; } - -/** @deprecated Use IssueProvider */ -export type TaskManager = IssueProvider; diff --git a/lib/roles/index.ts b/lib/roles/index.ts index 1bd4dab..88ab877 100644 --- a/lib/roles/index.ts +++ b/lib/roles/index.ts @@ -13,7 +13,11 @@ export { isValidRole, getRole, requireRole, + // Role aliases + ROLE_ALIASES, + canonicalRole, // Level aliases + LEVEL_ALIASES, canonicalLevel, // Levels getLevelsForRole, diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index ac234d5..13d427e 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -29,23 +29,23 @@ import { describe("role registry", () => { it("should have all expected roles", () => { const ids = getAllRoleIds(); - assert.ok(ids.includes("dev")); - assert.ok(ids.includes("qa")); + assert.ok(ids.includes("developer")); + assert.ok(ids.includes("tester")); assert.ok(ids.includes("architect")); }); it("should validate role IDs", () => { - assert.strictEqual(isValidRole("dev"), true); - assert.strictEqual(isValidRole("qa"), true); + assert.strictEqual(isValidRole("developer"), true); + assert.strictEqual(isValidRole("tester"), true); assert.strictEqual(isValidRole("architect"), true); assert.strictEqual(isValidRole("nonexistent"), false); }); it("should get role config", () => { - const dev = getRole("dev"); + const dev = getRole("developer"); assert.ok(dev); - assert.strictEqual(dev.id, "dev"); - assert.strictEqual(dev.displayName, "DEV"); + assert.strictEqual(dev.id, "developer"); + assert.strictEqual(dev.displayName, "DEVELOPER"); }); it("should throw for unknown role in requireRole", () => { @@ -55,8 +55,8 @@ describe("role registry", () => { describe("levels", () => { it("should return levels for each role", () => { - assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "mid", "senior"]); - assert.deepStrictEqual([...getLevelsForRole("qa")], ["junior", "mid", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]); }); @@ -67,42 +67,49 @@ describe("levels", () => { it("should return all levels", () => { const all = getAllLevels(); assert.ok(all.includes("junior")); - assert.ok(all.includes("mid")); + assert.ok(all.includes("medior")); assert.ok(all.includes("senior")); }); it("should check level membership", () => { - assert.strictEqual(isLevelForRole("junior", "dev"), true); - assert.strictEqual(isLevelForRole("junior", "qa"), true); + assert.strictEqual(isLevelForRole("junior", "developer"), true); + assert.strictEqual(isLevelForRole("junior", "tester"), true); assert.strictEqual(isLevelForRole("junior", "architect"), true); - assert.strictEqual(isLevelForRole("mid", "dev"), true); - assert.strictEqual(isLevelForRole("mid", "architect"), false); + assert.strictEqual(isLevelForRole("medior", "developer"), true); + assert.strictEqual(isLevelForRole("medior", "architect"), false); }); it("should find role for level", () => { - // "junior" appears in dev first (registry order) - assert.strictEqual(roleForLevel("junior"), "dev"); - assert.strictEqual(roleForLevel("mid"), "dev"); - assert.strictEqual(roleForLevel("senior"), "dev"); + // "junior" appears in developer first (registry order) + assert.strictEqual(roleForLevel("junior"), "developer"); + assert.strictEqual(roleForLevel("medior"), "developer"); + assert.strictEqual(roleForLevel("senior"), "developer"); assert.strictEqual(roleForLevel("nonexistent"), undefined); }); it("should return default level", () => { - assert.strictEqual(getDefaultLevel("dev"), "mid"); - assert.strictEqual(getDefaultLevel("qa"), "mid"); + assert.strictEqual(getDefaultLevel("developer"), "medior"); + assert.strictEqual(getDefaultLevel("tester"), "medior"); assert.strictEqual(getDefaultLevel("architect"), "junior"); }); }); describe("level aliases", () => { - it("should map old dev level names", () => { - assert.strictEqual(canonicalLevel("dev", "medior"), "mid"); - assert.strictEqual(canonicalLevel("dev", "junior"), "junior"); - assert.strictEqual(canonicalLevel("dev", "senior"), "senior"); + it("should map old developer level names", () => { + assert.strictEqual(canonicalLevel("developer", "mid"), "medior"); + assert.strictEqual(canonicalLevel("developer", "junior"), "junior"); + assert.strictEqual(canonicalLevel("developer", "senior"), "senior"); }); - it("should map old qa level names", () => { - assert.strictEqual(canonicalLevel("qa", "reviewer"), "mid"); + it("should map old dev role level names", () => { + assert.strictEqual(canonicalLevel("dev", "mid"), "medior"); + assert.strictEqual(canonicalLevel("dev", "medior"), "medior"); + }); + + it("should map old qa/tester level names", () => { + assert.strictEqual(canonicalLevel("tester", "mid"), "medior"); + assert.strictEqual(canonicalLevel("tester", "reviewer"), "medior"); + assert.strictEqual(canonicalLevel("qa", "reviewer"), "medior"); assert.strictEqual(canonicalLevel("qa", "tester"), "junior"); }); @@ -112,63 +119,69 @@ describe("level aliases", () => { }); it("should pass through unknown levels", () => { - assert.strictEqual(canonicalLevel("dev", "custom"), "custom"); + assert.strictEqual(canonicalLevel("developer", "custom"), "custom"); assert.strictEqual(canonicalLevel("unknown", "whatever"), "whatever"); }); }); describe("models", () => { it("should return default models", () => { - assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5"); - assert.strictEqual(getDefaultModel("dev", "mid"), "anthropic/claude-sonnet-4-5"); - assert.strictEqual(getDefaultModel("qa", "mid"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5"); + assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); }); it("should return all default models", () => { const models = getAllDefaultModels(); - assert.ok(models.dev); - assert.ok(models.qa); + assert.ok(models.developer); + assert.ok(models.tester); assert.ok(models.architect); - assert.strictEqual(models.dev.junior, "anthropic/claude-haiku-4-5"); + assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5"); }); it("should resolve from config override", () => { - const config = { models: { dev: { junior: "custom/model" } } }; - assert.strictEqual(resolveModel("dev", "junior", config), "custom/model"); + const config = { models: { developer: { junior: "custom/model" } } }; + assert.strictEqual(resolveModel("developer", "junior", config), "custom/model"); }); it("should fall back to default", () => { - assert.strictEqual(resolveModel("dev", "junior"), "anthropic/claude-haiku-4-5"); + assert.strictEqual(resolveModel("developer", "junior"), "anthropic/claude-haiku-4-5"); }); it("should pass through unknown level as model ID", () => { - assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); + assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); }); it("should resolve old config keys via aliases", () => { - // Old config uses "medior" key — should still resolve - const config = { models: { dev: { medior: "custom/old-config-model" } } }; - assert.strictEqual(resolveModel("dev", "medior", config), "custom/old-config-model"); + // Old config uses "mid" key — should still resolve via alias + const config = { models: { developer: { mid: "custom/old-config-model" } } }; + assert.strictEqual(resolveModel("developer", "mid", config), "custom/old-config-model"); // Also works when requesting the canonical name - assert.strictEqual(resolveModel("dev", "mid", {}), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(resolveModel("developer", "medior", {}), "anthropic/claude-sonnet-4-5"); + }); + + it("should resolve old role name config keys", () => { + // Old config uses "dev" role key — should still resolve via role alias + const config = { models: { dev: { junior: "custom/model" } } }; + assert.strictEqual(resolveModel("developer", "junior", config), "custom/model"); }); it("should resolve old qa config keys", () => { const config = { models: { qa: { reviewer: "custom/qa-model" } } }; - assert.strictEqual(resolveModel("qa", "reviewer", config), "custom/qa-model"); + assert.strictEqual(resolveModel("tester", "reviewer", config), "custom/qa-model"); }); }); describe("emoji", () => { it("should return level emoji", () => { - assert.strictEqual(getEmoji("dev", "junior"), "⚡"); + assert.strictEqual(getEmoji("developer", "junior"), "⚡"); assert.strictEqual(getEmoji("architect", "senior"), "🏗️"); }); it("should return fallback emoji", () => { - assert.strictEqual(getFallbackEmoji("dev"), "🔧"); - assert.strictEqual(getFallbackEmoji("qa"), "🔍"); + assert.strictEqual(getFallbackEmoji("developer"), "🔧"); + assert.strictEqual(getFallbackEmoji("tester"), "🔍"); assert.strictEqual(getFallbackEmoji("architect"), "🏗️"); assert.strictEqual(getFallbackEmoji("nonexistent"), "📋"); }); @@ -176,32 +189,32 @@ describe("emoji", () => { describe("completion results", () => { it("should return valid results per role", () => { - assert.deepStrictEqual([...getCompletionResults("dev")], ["done", "blocked"]); - assert.deepStrictEqual([...getCompletionResults("qa")], ["pass", "fail", "refine", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); }); it("should validate results", () => { - assert.strictEqual(isValidResult("dev", "done"), true); - assert.strictEqual(isValidResult("dev", "pass"), false); - assert.strictEqual(isValidResult("qa", "pass"), true); - assert.strictEqual(isValidResult("qa", "done"), false); + assert.strictEqual(isValidResult("developer", "done"), true); + assert.strictEqual(isValidResult("developer", "pass"), false); + assert.strictEqual(isValidResult("tester", "pass"), true); + assert.strictEqual(isValidResult("tester", "done"), false); }); }); describe("session key pattern", () => { it("should generate pattern matching all roles", () => { const pattern = getSessionKeyRolePattern(); - assert.ok(pattern.includes("dev")); - assert.ok(pattern.includes("qa")); + assert.ok(pattern.includes("developer")); + assert.ok(pattern.includes("tester")); assert.ok(pattern.includes("architect")); }); it("should work as regex", () => { const pattern = getSessionKeyRolePattern(); const regex = new RegExp(`(${pattern})`); - assert.ok(regex.test("dev")); - assert.ok(regex.test("qa")); + assert.ok(regex.test("developer")); + assert.ok(regex.test("tester")); assert.ok(regex.test("architect")); assert.ok(!regex.test("nonexistent")); }); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index 7ef6d7a..1c1a9d8 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -14,45 +14,45 @@ import type { RoleConfig } from "./types.js"; export const ROLE_REGISTRY: Record = { - dev: { - id: "dev", - displayName: "DEV", - levels: ["junior", "mid", "senior"], - defaultLevel: "mid", + developer: { + id: "developer", + displayName: "DEVELOPER", + levels: ["junior", "medior", "senior"], + defaultLevel: "medior", models: { junior: "anthropic/claude-haiku-4-5", - mid: "anthropic/claude-sonnet-4-5", + medior: "anthropic/claude-sonnet-4-5", senior: "anthropic/claude-opus-4-5", }, emoji: { junior: "⚡", - mid: "🔧", + medior: "🔧", senior: "🧠", }, fallbackEmoji: "🔧", completionResults: ["done", "blocked"], - sessionKeyPattern: "dev", + sessionKeyPattern: "developer", notifications: { onStart: true, onComplete: true }, }, - qa: { - id: "qa", - displayName: "QA", - levels: ["junior", "mid", "senior"], - defaultLevel: "mid", + tester: { + id: "tester", + displayName: "TESTER", + levels: ["junior", "medior", "senior"], + defaultLevel: "medior", models: { junior: "anthropic/claude-haiku-4-5", - mid: "anthropic/claude-sonnet-4-5", + medior: "anthropic/claude-sonnet-4-5", senior: "anthropic/claude-opus-4-5", }, emoji: { junior: "⚡", - mid: "🔍", + medior: "🔍", senior: "🧠", }, fallbackEmoji: "🔍", completionResults: ["pass", "fail", "refine", "blocked"], - sessionKeyPattern: "qa", + sessionKeyPattern: "tester", notifications: { onStart: true, onComplete: true }, }, diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index ba7e5ad..7a5092d 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -6,6 +6,7 @@ */ import { ROLE_REGISTRY } from "./registry.js"; import type { RoleConfig } from "./types.js"; +import type { ResolvedRoleConfig } from "../config/types.js"; // --------------------------------------------------------------------------- // Role IDs @@ -36,13 +37,31 @@ export function requireRole(role: string): RoleConfig { return config; } +// --------------------------------------------------------------------------- +// Role aliases — maps old role IDs to new canonical IDs +// --------------------------------------------------------------------------- + +/** Maps old role IDs to canonical IDs. Used for backward compatibility. */ +export const ROLE_ALIASES: Record = { + dev: "developer", + qa: "tester", +}; + +/** Resolve a role ID, applying aliases for backward compatibility. */ +export function canonicalRole(role: string): string { + return ROLE_ALIASES[role] ?? role; +} + // --------------------------------------------------------------------------- // Level aliases — maps old level names to new canonical names // --------------------------------------------------------------------------- -const LEVEL_ALIASES: Record> = { - dev: { medior: "mid" }, - qa: { reviewer: "mid", tester: "junior" }, +/** Maps old level names to canonical names, per role. Used for backward compatibility. */ +export const LEVEL_ALIASES: Record> = { + developer: { mid: "medior", medior: "medior" }, + dev: { mid: "medior", medior: "medior" }, + tester: { mid: "medior", reviewer: "medior", tester: "junior" }, + qa: { mid: "medior", reviewer: "medior", tester: "junior" }, architect: { opus: "senior", sonnet: "junior" }, }; @@ -105,23 +124,32 @@ export function getAllDefaultModels(): Record> { * Resolve a level to a full model ID. * * Resolution order: - * 1. Plugin config `models..` (tries canonical name, then original) - * 2. Registry default model - * 3. Passthrough (treat level as raw model ID) + * 1. Plugin config `models..` in openclaw.json (highest precedence) + * 2. Resolved config from config.yaml (if provided) + * 3. Registry default model + * 4. Passthrough (treat level as raw model ID) */ export function resolveModel( role: string, level: string, pluginConfig?: Record, + resolvedRole?: ResolvedRoleConfig, ): string { const canonical = canonicalLevel(role, level); + + // 1. Plugin config override (openclaw.json) — check canonical role + old aliases const models = (pluginConfig as { models?: Record })?.models; if (models && typeof models === "object") { - const roleModels = models[role] as Record | undefined; - // Try canonical name first, then original (for old configs) + // Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer") + const roleModels = (models[role] ?? models[Object.entries(ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record | undefined; if (roleModels?.[canonical]) return roleModels[canonical]; if (roleModels?.[level]) return roleModels[level]; } + + // 2. Resolved config (config.yaml) + if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical]; + + // 3. Built-in registry default return getDefaultModel(role, canonical) ?? canonical; } diff --git a/lib/roles/types.ts b/lib/roles/types.ts index 6a2f4c2..34278cf 100644 --- a/lib/roles/types.ts +++ b/lib/roles/types.ts @@ -7,7 +7,7 @@ /** Configuration for a single worker role. */ export type RoleConfig = { - /** Unique role identifier (e.g., "dev", "qa", "architect"). */ + /** Unique role identifier (e.g., "developer", "tester", "architect"). */ id: string; /** Human-readable display name. */ displayName: string; @@ -23,7 +23,7 @@ export type RoleConfig = { fallbackEmoji: string; /** Valid completion results for this role. */ completionResults: readonly string[]; - /** Regex pattern fragment for session key matching (e.g., "dev|qa|architect"). */ + /** Regex pattern fragment for session key matching (e.g., "developer|tester|architect"). */ sessionKeyPattern: string; /** Notification config per event type. */ notifications: { diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index f4ff19f..94d45f9 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -18,7 +18,6 @@ import { log as auditLog } from "../audit.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js"; import { projectTick } from "./tick.js"; import { createProvider } from "../providers/index.js"; -import { getAllRoleIds } from "../roles/index.js"; // --------------------------------------------------------------------------- // Types @@ -307,13 +306,13 @@ async function performHealthPass( const { provider } = await createProvider({ repo: project.repo, provider: project.provider }); let fixedCount = 0; - for (const role of getAllRoleIds()) { + for (const role of Object.keys(project.workers)) { // Check worker health (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, groupId, project, - role: role as any, + role, sessions, autoFix: true, provider, @@ -325,7 +324,7 @@ async function performHealthPass( workspaceDir, groupId, project, - role: role as any, + role, autoFix: true, provider, }); @@ -336,10 +335,10 @@ async function performHealthPass( } /** - * Check if a project has active work (dev or qa). + * Check if a project has any active worker. */ async function checkProjectActive(workspaceDir: string, groupId: string): Promise { const fresh = (await readProjects(workspaceDir)).projects[groupId]; if (!fresh) return false; - return fresh.dev.active || fresh.qa.active; + return Object.values(fresh.workers).some(w => w.active); } diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 5537623..3c9ae53 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -17,40 +17,6 @@ import { type WorkflowConfig, } from "../workflow.js"; -// --------------------------------------------------------------------------- -// Backward compatibility exports -// --------------------------------------------------------------------------- - -/** - * @deprecated Use getCompletionRule() from workflow.ts instead. - * Kept for backward compatibility. - */ -export const COMPLETION_RULES: Record = { - "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 = { - "dev:done": "QA queue", - "dev:blocked": "moved to Refining - needs human input", - "qa:pass": "Done!", - "qa:fail": "back to DEV", - "qa:refine": "awaiting human decision", - "qa:blocked": "moved to Refining - needs human input", - "architect:done": "Planning — ready for review", - "architect:blocked": "moved to Refining - needs clarification", -}; - -// Re-export CompletionRule type for backward compatibility export type { CompletionRule }; export type CompletionOutput = { @@ -72,7 +38,7 @@ export function getRule( result: string, workflow: WorkflowConfig = DEFAULT_WORKFLOW, ): CompletionRule | undefined { - return getCompletionRule(workflow, role as "dev" | "qa", result) ?? undefined; + return getCompletionRule(workflow, role, result) ?? undefined; } /** @@ -81,7 +47,7 @@ export function getRule( export async function executeCompletion(opts: { workspaceDir: string; groupId: string; - role: "dev" | "qa" | "architect"; + role: string; result: string; issueId: number; summary?: string; diff --git a/lib/services/queue.ts b/lib/services/queue.ts index 51f013c..cceaac6 100644 --- a/lib/services/queue.ts +++ b/lib/services/queue.ts @@ -13,26 +13,6 @@ import { type Role, } from "../workflow.js"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** - * @deprecated Use string labels from workflow config instead. - * Kept for backward compatibility. - */ -export type QueueLabel = "To Improve" | "To Test" | "To Do"; - -/** - * @deprecated Use getQueuePriority() instead. - * Kept for backward compatibility. - */ -export const QUEUE_PRIORITY: Record = { - "To Improve": 3, - "To Test": 2, - "To Do": 1, -}; - // --------------------------------------------------------------------------- // Workflow-driven helpers // --------------------------------------------------------------------------- diff --git a/lib/services/tick.ts b/lib/services/tick.ts index d4c10cf..55d7b7f 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -11,9 +11,9 @@ import { createProvider } from "../providers/index.js"; import { selectLevel } from "../model-selector.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { getAllRoleIds, getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js"; +import { getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js"; +import { loadConfig } from "../config/index.js"; import { - DEFAULT_WORKFLOW, getQueueLabels, getAllQueueLabels, getActiveLabel, @@ -22,25 +22,6 @@ import { type Role, } from "../workflow.js"; -// --------------------------------------------------------------------------- -// Backward compatibility exports (deprecated) -// --------------------------------------------------------------------------- - -/** - * @deprecated Use getQueueLabels(workflow, "dev") instead. - */ -export const DEV_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "dev"); - -/** - * @deprecated Use getQueueLabels(workflow, "qa") instead. - */ -export const QA_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "qa"); - -/** - * @deprecated Use getAllQueueLabels(workflow) instead. - */ -export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW); - // --------------------------------------------------------------------------- // Shared helpers (used by tick, work-start, auto-pickup) // --------------------------------------------------------------------------- @@ -68,7 +49,7 @@ export function detectLevelFromLabels(labels: string[]): string | null { */ export function detectRoleFromLabel( label: StateLabel, - workflow: WorkflowConfig = DEFAULT_WORKFLOW, + workflow: WorkflowConfig, ): Role | null { return workflowDetectRole(workflow, label); } @@ -76,7 +57,7 @@ export function detectRoleFromLabel( export async function findNextIssueForRole( provider: Pick, role: Role, - workflow: WorkflowConfig = DEFAULT_WORKFLOW, + workflow: WorkflowConfig, ): Promise<{ issue: Issue; label: StateLabel } | null> { const labels = getQueueLabels(workflow, role); for (const label of labels) { @@ -93,8 +74,8 @@ export async function findNextIssueForRole( */ export async function findNextIssue( provider: Pick, - role?: Role, - workflow: WorkflowConfig = DEFAULT_WORKFLOW, + role: Role | undefined, + workflow: WorkflowConfig, ): Promise<{ issue: Issue; label: StateLabel } | null> { const labels = role ? getQueueLabels(workflow, role) @@ -156,15 +137,20 @@ export async function projectTick(opts: { const { workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun, maxPickups, targetRole, runtime, - workflow = DEFAULT_WORKFLOW, } = opts; const project = (await readProjects(workspaceDir)).projects[groupId]; if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] }; + const resolvedConfig = await loadConfig(workspaceDir, project.name); + const workflow = opts.workflow ?? resolvedConfig.workflow; + const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider; const roleExecution = project.roleExecution ?? "parallel"; - const roles: Role[] = targetRole ? [targetRole] : getAllRoleIds() as Role[]; + const enabledRoles = Object.entries(resolvedConfig.roles) + .filter(([, r]) => r.enabled) + .map(([id]) => id); + const roles: Role[] = targetRole ? [targetRole] : enabledRoles; const pickups: TickAction[] = []; const skipped: TickResult["skipped"] = []; @@ -186,8 +172,8 @@ export async function projectTick(opts: { continue; } // Check sequential role execution: any other role must be inactive - const otherRoles = getAllRoleIds().filter(r => r !== role); - if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r as any).active)) { + const otherRoles = enabledRoles.filter((r: string) => r !== role); + if (roleExecution === "sequential" && otherRoles.some((r: string) => getWorker(fresh, r).active)) { skipped.push({ role, reason: "Sequential: other role active" }); continue; } diff --git a/lib/setup/config.ts b/lib/setup/config.ts index 9c0a783..ab74ae4 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; -type ModelConfig = { dev: Record; qa: Record; architect: Record }; +type ModelConfig = Record>; /** * Write DevClaw model level config to openclaw.json plugins section. diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 9d4c8e4..08a541a 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -5,13 +5,13 @@ * Used by both the `setup` tool and the `openclaw devclaw setup` CLI command. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { DEFAULT_MODELS } from "../tiers.js"; +import { getAllDefaultModels } from "../roles/index.js"; import { migrateChannelBinding } from "../binding-manager.js"; import { createAgent, resolveWorkspacePath } from "./agent.js"; import { writePluginConfig } from "./config.js"; import { scaffoldWorkspace } from "./workspace.js"; -export type ModelConfig = { dev: Record; qa: Record; architect: Record }; +export type ModelConfig = Record>; export type SetupOpts = { /** OpenClaw plugin API for config access. */ @@ -27,7 +27,7 @@ export type SetupOpts = { /** Override workspace path (auto-detected from agent if not given). */ workspacePath?: string; /** Model overrides per role.level. Missing levels use defaults. */ - models?: { dev?: Partial>; qa?: Partial>; architect?: Partial> }; + models?: Record>>; /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ projectExecution?: "parallel" | "sequential"; }; @@ -113,25 +113,21 @@ async function tryMigrateBinding( } function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { - const dev: Record = { ...DEFAULT_MODELS.dev }; - const qa: Record = { ...DEFAULT_MODELS.qa }; - const architect: Record = { ...DEFAULT_MODELS.architect }; + const defaults = getAllDefaultModels(); + const result: ModelConfig = {}; - if (overrides?.dev) { - for (const [level, model] of Object.entries(overrides.dev)) { - if (model) dev[level] = model; - } + for (const [role, levels] of Object.entries(defaults)) { + result[role] = { ...levels }; } - if (overrides?.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; + + 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; + } } } - return { dev, qa, architect }; + return result; } diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index 9cbb2fe..51f971c 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -6,14 +6,14 @@ import { runCommand } from "../run-command.js"; export type ModelAssignment = { - dev: { + developer: { junior: string; - mid: string; + medior: string; senior: string; }; - qa: { + tester: { junior: string; - mid: string; + medior: string; senior: string; }; architect: { @@ -37,8 +37,8 @@ export async function selectModelsWithLLM( if (availableModels.length === 1) { const model = availableModels[0].model; return { - dev: { junior: model, mid: model, senior: model }, - qa: { junior: model, mid: model, senior: model }, + developer: { junior: model, medior: model, senior: model }, + tester: { junior: model, medior: model, senior: model }, architect: { junior: model, senior: model }, }; } @@ -53,27 +53,27 @@ ${modelList} All roles use the same level scheme based on task complexity: - **senior** (most capable): Complex architecture, refactoring, critical decisions -- **mid** (balanced): Features, bug fixes, code review, standard tasks +- **medior** (balanced): Features, bug fixes, code review, standard tasks - **junior** (fast/efficient): Simple fixes, routine tasks Rules: 1. Prefer same provider for consistency 2. Assign most capable model to senior -3. Assign mid-tier model to mid +3. Assign mid-tier model to medior 4. Assign fastest/cheapest model to junior 5. Consider model version numbers (higher = newer/better) 6. Stable versions (no date) > snapshot versions (with date like 20250514) Return ONLY a JSON object in this exact format (no markdown, no explanation): { - "dev": { + "developer": { "junior": "provider/model-name", - "mid": "provider/model-name", + "medior": "provider/model-name", "senior": "provider/model-name" }, - "qa": { + "tester": { "junior": "provider/model-name", - "mid": "provider/model-name", + "medior": "provider/model-name", "senior": "provider/model-name" }, "architect": { @@ -131,18 +131,18 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): // Backfill architect if LLM didn't return it (graceful upgrade) if (!assignment.architect) { assignment.architect = { - senior: assignment.dev?.senior ?? availableModels[0].model, - junior: assignment.dev?.mid ?? availableModels[0].model, + senior: assignment.developer?.senior ?? availableModels[0].model, + junior: assignment.developer?.medior ?? availableModels[0].model, }; } if ( - !assignment.dev?.junior || - !assignment.dev?.mid || - !assignment.dev?.senior || - !assignment.qa?.junior || - !assignment.qa?.mid || - !assignment.qa?.senior + !assignment.developer?.junior || + !assignment.developer?.medior || + !assignment.developer?.senior || + !assignment.tester?.junior || + !assignment.tester?.medior || + !assignment.tester?.senior ) { console.error("Invalid assignment structure. Got:", assignment); throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`); diff --git a/lib/setup/smart-model-selector.ts b/lib/setup/smart-model-selector.ts index 1c2fdf6..76b9ea5 100644 --- a/lib/setup/smart-model-selector.ts +++ b/lib/setup/smart-model-selector.ts @@ -5,14 +5,14 @@ */ export type ModelAssignment = { - dev: { + developer: { junior: string; - mid: string; + medior: string; senior: string; }; - qa: { + tester: { junior: string; - mid: string; + medior: string; senior: string; }; architect: { @@ -44,8 +44,8 @@ export async function assignModels( if (authenticated.length === 1) { const model = authenticated[0].model; return { - dev: { junior: model, mid: model, senior: model }, - qa: { junior: model, mid: model, senior: model }, + developer: { junior: model, medior: model, senior: model }, + tester: { junior: model, medior: model, senior: model }, architect: { junior: model, senior: model }, }; } @@ -66,16 +66,16 @@ export async function assignModels( */ export function formatAssignment(assignment: ModelAssignment): string { const lines = [ - "| Role | Level | Model |", - "|------|----------|--------------------------|", - `| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`, - `| DEV | mid | ${assignment.dev.mid.padEnd(24)} |`, - `| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`, - `| QA | senior | ${assignment.qa.senior.padEnd(24)} |`, - `| QA | mid | ${assignment.qa.mid.padEnd(24)} |`, - `| QA | junior | ${assignment.qa.junior.padEnd(24)} |`, - `| ARCH | senior | ${assignment.architect.senior.padEnd(24)} |`, - `| ARCH | junior | ${assignment.architect.junior.padEnd(24)} |`, + "| Role | Level | Model |", + "|-----------|----------|--------------------------|", + `| DEVELOPER | senior | ${assignment.developer.senior.padEnd(24)} |`, + `| DEVELOPER | medior | ${assignment.developer.medior.padEnd(24)} |`, + `| DEVELOPER | junior | ${assignment.developer.junior.padEnd(24)} |`, + `| TESTER | senior | ${assignment.tester.senior.padEnd(24)} |`, + `| TESTER | medior | ${assignment.tester.medior.padEnd(24)} |`, + `| TESTER | junior | ${assignment.tester.junior.padEnd(24)} |`, + `| ARCHITECT | senior | ${assignment.architect.senior.padEnd(24)} |`, + `| ARCHITECT | junior | ${assignment.architect.junior.padEnd(24)} |`, ]; return lines.join("\n"); } diff --git a/lib/templates.ts b/lib/templates.ts index cc8f322..e15eaf9 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -3,7 +3,7 @@ * Used by setup and project_register. */ -export const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions +export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions ## Context You Receive @@ -24,19 +24,19 @@ Read the comments carefully — they often contain clarifications, decisions, or - Create an MR/PR to the base branch and merge it - **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA. - Clean up the worktree after merging -- When done, call work_finish with role "dev", result "done", and a brief summary +- When done, call work_finish with role "developer", result "done", and a brief summary - If you discover unrelated bugs, call task_create to file them - Do NOT call work_start, status, health, or project_register `; -export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions +export const DEFAULT_QA_INSTRUCTIONS = `# TESTER Worker Instructions - Pull latest from the base branch - Run tests and linting - Verify the changes address the issue requirements - Check for regressions in related functionality - **Always** call task_comment with your review findings — even if everything looks good, leave a brief summary of what you checked -- When done, call work_finish with role "qa" and one of: +- When done, call work_finish with role "tester" and one of: - result "pass" if everything looks good - result "fail" with specific issues if problems found - result "refine" if you need human input to decide @@ -55,7 +55,7 @@ Investigate the design problem thoroughly: 2. **Research alternatives** — Explore >= 3 viable approaches 3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit 4. **Recommend** — Pick the best option with clear reasoning -5. **Outline implementation** — Break down into dev tasks +5. **Outline implementation** — Break down into developer tasks ## Output Format @@ -115,9 +115,16 @@ Your session is persistent — you may be called back for refinements. Do NOT call work_start, status, health, or project_register. `; +/** Default role instructions indexed by role ID. Used by project scaffolding. */ +export const DEFAULT_ROLE_INSTRUCTIONS: Record = { + developer: DEFAULT_DEV_INSTRUCTIONS, + tester: DEFAULT_QA_INSTRUCTIONS, + architect: DEFAULT_ARCHITECT_INSTRUCTIONS, +}; + export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw) -## If You Are a Sub-Agent (DEV/QA Worker) +## If You Are a Sub-Agent (DEVELOPER/TESTER Worker) Skip the orchestrator section. Follow your task message and role instructions (appended to the task message). @@ -126,21 +133,21 @@ Skip the orchestrator section. Follow your task message and role instructions (a - Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\` - Include issue number: \`feat: add user authentication (#12)\` - Branch naming: \`feature/-\` or \`fix/-\` -- **DEV always works in a git worktree** (never switch branches in the main repo) -- **DEV must merge to base branch** before announcing completion -- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses QA. -- **QA tests on the deployed version** and inspects code on the base branch -- **QA always calls task_comment** with review findings before completing +- **DEVELOPER always works in a git worktree** (never switch branches in the main repo) +- **DEVELOPER must merge to base branch** before announcing completion +- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses testing. +- **TESTER tests on the deployed version** and inspects code on the base branch +- **TESTER always calls task_comment** with review findings before completing - Always run tests before completing ### Completing Your Task When you are done, **call \`work_finish\` yourself** — do not just announce in text. -- **DEV done:** \`work_finish({ role: "dev", result: "done", projectGroupId: "", summary: "" })\` -- **QA pass:** \`work_finish({ role: "qa", result: "pass", projectGroupId: "", summary: "" })\` -- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "", summary: "" })\` -- **QA refine:** \`work_finish({ role: "qa", result: "refine", projectGroupId: "", summary: "" })\` +- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` +- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "", summary: "" })\` +- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "", summary: "" })\` +- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "", summary: "" })\` - **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "", summary: "" })\` The \`projectGroupId\` is included in your task message. @@ -167,14 +174,14 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder **Never write code yourself.** All implementation work MUST go through the issue → worker pipeline: 1. Create an issue via \`task_create\` -2. Dispatch a DEV worker via \`work_start\` +2. Dispatch a DEVELOPER worker via \`work_start\` 3. Let the worker handle implementation, git, and PRs **Why this matters:** - **Audit trail** — Every code change is tracked to an issue -- **Level selection** — Junior/mid/senior models match task complexity +- **Level selection** — Junior/medior/senior models match task complexity - **Parallelization** — Workers run in parallel, you stay free to plan -- **QA pipeline** — Code goes through review before closing +- **Testing pipeline** — Code goes through review before closing **What you CAN do directly:** - Planning, analysis, architecture discussions @@ -195,7 +202,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder Examples: - ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42" -- ✅ "Picked up #42 for DEV (mid) 🔗 https://github.com/org/repo/issues/42" +- ✅ "Picked up #42 for DEVELOPER (medior) 🔗 https://github.com/org/repo/issues/42" - ❌ "Created issue #42 about the login bug" (missing URL) ### DevClaw Tools @@ -232,10 +239,10 @@ Issue labels are the single source of truth for task state. Evaluate each task and pass the appropriate developer level to \`work_start\`: - **junior** — trivial: typos, single-file fix, quick change -- **mid** — standard: features, bug fixes, multi-file changes +- **medior** — standard: features, bug fixes, multi-file changes - **senior** — complex: architecture, system-wide refactoring, 5+ services -All roles (DEV, QA, Architect) use the same level scheme. Levels describe task complexity, not the model. +All roles (Developer, Tester, Architect) use the same level scheme. Levels describe task complexity, not the model. ### Picking Up Work @@ -249,10 +256,10 @@ All roles (DEV, QA, Architect) use the same level scheme. Levels describe task c Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle: -- DEV "done" → issue moves to "To Test" → scheduler dispatches QA -- QA "fail" → issue moves to "To Improve" → scheduler dispatches DEV -- QA "pass" → Done, no further dispatch -- QA "refine" / blocked → needs human input +- Developer "done" → issue moves to "To Test" → scheduler dispatches Tester +- Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer +- Tester "pass" → Done, no further dispatch +- Tester "refine" / blocked → needs human input - Architect "done" → issue moves to "Planning" → ready for tech lead review **Always include issue URLs** in your response — these are in the \`announcement\` fields. @@ -267,10 +274,10 @@ Workers receive role-specific instructions appended to their task message. These ### Safety -- **Never write code yourself** — always dispatch a DEV worker +- **Never write code yourself** — always dispatch a Developer worker - Don't push to main directly - Don't force-push -- Don't close issues without QA pass +- Don't close issues without Tester pass - Ask before architectural decisions affecting multiple projects `; diff --git a/lib/tiers.ts b/lib/tiers.ts deleted file mode 100644 index 2f08915..0000000 --- a/lib/tiers.ts +++ /dev/null @@ -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> = 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 { - return registryResolveModel(role, level, pluginConfig); -} diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index 0581a39..258c06e 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -5,7 +5,7 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { parseDevClawSessionKey } from "../bootstrap-hook.js"; -import { isArchitectLevel, levelRole, resolveModel, defaultModel, levelEmoji } from "../tiers.js"; +import { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js"; import { selectLevel } from "../model-selector.js"; import { DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule, @@ -14,21 +14,21 @@ import { describe("architect tiers", () => { it("should recognize architect levels", () => { - assert.strictEqual(isArchitectLevel("junior"), true); - assert.strictEqual(isArchitectLevel("senior"), true); - assert.strictEqual(isArchitectLevel("mid"), false); + assert.strictEqual(isLevelForRole("junior", "architect"), true); + assert.strictEqual(isLevelForRole("senior", "architect"), true); + assert.strictEqual(isLevelForRole("medior", "architect"), false); }); it("should map architect levels to role", () => { - // "junior" and "senior" appear in dev first (registry order), so roleForLevel returns "dev" - // This is expected — use isArchitectLevel for architect-specific checks - assert.strictEqual(levelRole("junior"), "dev"); - assert.strictEqual(levelRole("senior"), "dev"); + // "junior" and "senior" appear in developer first (registry order), so roleForLevel returns "developer" + // This is expected — use isLevelForRole for role-specific checks + assert.strictEqual(roleForLevel("junior"), "developer"); + assert.strictEqual(roleForLevel("senior"), "developer"); }); it("should resolve default architect models", () => { - assert.strictEqual(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); - assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); + assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); + assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); it("should resolve architect model from config", () => { @@ -37,8 +37,8 @@ describe("architect tiers", () => { }); it("should have architect emoji", () => { - assert.strictEqual(levelEmoji("architect", "senior"), "🏗️"); - assert.strictEqual(levelEmoji("architect", "junior"), "📐"); + assert.strictEqual(getEmoji("architect", "senior"), "🏗️"); + assert.strictEqual(getEmoji("architect", "junior"), "📐"); }); }); @@ -76,8 +76,9 @@ describe("architect workflow states", () => { assert.strictEqual(rule!.to, "Refining"); }); - it("should have architect completion emoji", () => { - assert.strictEqual(getCompletionEmoji("architect", "done"), "🏗️"); + it("should have completion emoji by result type", () => { + // Emoji is now keyed by result, not role:result + assert.strictEqual(getCompletionEmoji("architect", "done"), "✅"); assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫"); }); }); diff --git a/lib/tools/design-task.ts b/lib/tools/design-task.ts index 12e379d..0b1dcce 100644 --- a/lib/tools/design-task.ts +++ b/lib/tools/design-task.ts @@ -13,7 +13,9 @@ import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; -import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js"; +import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js"; +import { selectLevel } from "../model-selector.js"; +import { resolveModel } from "../roles/index.js"; export function createDesignTaskTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -81,6 +83,14 @@ Example: const { project } = await resolveProject(workspaceDir, groupId); const { provider } = await resolveProvider(project); + const pluginConfig = getPluginConfig(api); + + // Derive labels from workflow config + const workflow = await loadWorkflow(workspaceDir, project.name); + const role = "architect"; + const queueLabels = getQueueLabels(workflow, role); + const queueLabel = queueLabels[0]; + if (!queueLabel) throw new Error(`No queue state found for role "${role}" in workflow`); // Build issue body with focus areas const bodyParts = [description]; @@ -101,51 +111,48 @@ Example: ); const issueBody = bodyParts.join("\n"); - // Create issue in To Design state - const issue = await provider.createIssue(title, issueBody, "To Design" as StateLabel); + // Create issue in queue state + const issue = await provider.createIssue(title, issueBody, queueLabel as StateLabel); await auditLog(workspaceDir, "design_task", { project: project.name, groupId, issueId: issue.iid, title, complexity, focusAreas, dryRun, }); - // Select level based on complexity - const level = complexity === "complex" ? "senior" : "junior"; + // Select level: use complexity hint to guide the heuristic + const level = complexity === "complex" + ? selectLevel(title, "system-wide " + description, role).level + : selectLevel(title, description, role).level; + const model = resolveModel(role, level, pluginConfig); if (dryRun) { return jsonResult({ success: true, dryRun: true, - issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" }, - design: { - level, - model: complexity === "complex" ? "anthropic/claude-opus-4-5" : "anthropic/claude-sonnet-4-5", - status: "dry_run", - }, - announcement: `📐 [DRY RUN] Would spawn architect (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel }, + design: { level, model, status: "dry_run" }, + announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`, }); } - // Check architect availability - const worker = getWorker(project, "architect"); + // Check worker availability + const worker = getWorker(project, role); if (worker.active) { // Issue created but can't dispatch yet — will be picked up by heartbeat return jsonResult({ success: true, - issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" }, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel }, design: { level, status: "queued", - reason: `Architect already active on #${worker.issueId}. Issue queued for pickup.`, + reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue queued for pickup.`, }, - announcement: `📐 Created design task #${issue.iid}: ${title} (queued — architect busy)\n🔗 ${issue.web_url}`, + announcement: `📐 Created design task #${issue.iid}: ${title} (queued — ${role} busy)\n🔗 ${issue.web_url}`, }); } - // Dispatch architect - const workflow = DEFAULT_WORKFLOW; - const targetLabel = getActiveLabel(workflow, "architect"); - const pluginConfig = getPluginConfig(api); + // Dispatch worker + const targetLabel = getActiveLabel(workflow, role); const dr = await dispatchTask({ workspaceDir, @@ -156,9 +163,9 @@ Example: issueTitle: issue.title, issueDescription: issueBody, issueUrl: issue.web_url, - role: "architect", + role, level, - fromLabel: "To Design", + fromLabel: queueLabel, toLabel: targetLabel, transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), provider, diff --git a/lib/tools/health.ts b/lib/tools/health.ts index d3bec26..9111126 100644 --- a/lib/tools/health.ts +++ b/lib/tools/health.ts @@ -18,7 +18,6 @@ import { readProjects, getProject } from "../projects.js"; import { log as auditLog } from "../audit.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js"; import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js"; -import { getAllRoleIds } from "../roles/index.js"; export function createHealthTool() { return (ctx: ToolContext) => ({ @@ -52,13 +51,13 @@ export function createHealthTool() { if (!project) continue; const { provider } = await resolveProvider(project); - for (const role of getAllRoleIds()) { + for (const role of Object.keys(project.workers)) { // Worker health check (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, groupId: pid, project, - role: role as any, + role, sessions, autoFix: fix, provider, @@ -70,7 +69,7 @@ export function createHealthTool() { workspaceDir, groupId: pid, project, - role: role as any, + role, autoFix: fix, provider, }); diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 90f85a2..a3219bb 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -15,40 +15,26 @@ import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; -import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js"; +import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; /** - * Scaffold project-specific prompt files. + * Scaffold project-specific prompt files for all registered roles. * Returns true if files were created, false if they already existed. */ async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise { const projectDir = path.join(workspaceDir, "projects", "roles", projectName); await fs.mkdir(projectDir, { recursive: true }); - const projectDev = path.join(projectDir, "dev.md"); - const projectQa = path.join(projectDir, "qa.md"); let created = false; - - try { - await fs.access(projectDev); - } catch { - await fs.writeFile(projectDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8"); - created = true; - } - - try { - await fs.access(projectQa); - } catch { - await fs.writeFile(projectQa, DEFAULT_QA_INSTRUCTIONS, "utf-8"); - created = true; - } - - const projectArchitect = path.join(projectDir, "architect.md"); - try { - await fs.access(projectArchitect); - } catch { - await fs.writeFile(projectArchitect, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8"); - created = true; + for (const role of getAllRoleIds()) { + const filePath = path.join(projectDir, `${role}.md`); + try { + await fs.access(filePath); + } catch { + 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; + } } return created; @@ -122,7 +108,8 @@ export function createProjectRegisterTool() { // 1. Check project not already registered (allow re-register if incomplete) const data = await readProjects(workspaceDir); const existing = data.projects[groupId]; - if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) { + const existingWorkers = existing?.workers ?? {}; + if (existing && Object.values(existingWorkers).some(w => w.sessions && Object.keys(w.sessions).length > 0)) { throw new Error( `Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`, ); @@ -153,6 +140,12 @@ export function createProjectRegisterTool() { await provider.ensureAllStateLabels(); // 5. Add project to projects.json + // Build workers map from all registered roles + const workers: Record = {}; + for (const role of getAllRoleIds()) { + workers[role] = emptyWorkerState([...getLevelsForRole(role)]); + } + data.projects[groupId] = { name, repo, @@ -163,9 +156,7 @@ export function createProjectRegisterTool() { channel, provider: providerType, roleExecution, - dev: emptyWorkerState([...getLevelsForRole("dev")]), - qa: emptyWorkerState([...getLevelsForRole("qa")]), - architect: emptyWorkerState([...getLevelsForRole("architect")]), + workers, }; await writeProjects(workspaceDir, data); diff --git a/lib/tools/queue-status.test.ts b/lib/tools/queue-status.test.ts index 1d5cec4..d831780 100644 --- a/lib/tools/queue-status.test.ts +++ b/lib/tools/queue-status.test.ts @@ -15,18 +15,18 @@ describe("status execution-aware sequencing", () => { }); describe("role assignment", () => { - it("should assign To Improve to dev", () => { - // To Improve = dev work + it("should assign To Improve to developer", () => { + // To Improve = developer work assert.ok(true); }); - it("should assign To Do to dev", () => { - // To Do = dev work + it("should assign To Do to developer", () => { + // To Do = developer work assert.ok(true); }); - it("should assign To Test to qa", () => { - // To Test = qa work + it("should assign To Test to tester", () => { + // To Test = tester work assert.ok(true); }); }); @@ -43,12 +43,12 @@ describe("status execution-aware sequencing", () => { }); it("should support parallel role execution within project", () => { - // DEV and QA can run simultaneously + // Developer and Tester can run simultaneously assert.ok(true); }); it("should support sequential role execution within project", () => { - // DEV and QA alternate + // Developer and Tester alternate assert.ok(true); }); }); diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 40fbbe2..ff3ce4b 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -8,8 +8,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { runSetup, type SetupOpts } from "../setup/index.js"; -import { DEFAULT_MODELS } from "../tiers.js"; -import { getLevelsForRole } from "../roles/index.js"; +import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js"; export function createSetupTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -37,44 +36,18 @@ export function createSetupTool(api: OpenClawPluginApi) { models: { type: "object", description: "Model overrides per role and level.", - properties: { - dev: { + properties: Object.fromEntries( + getAllRoleIds().map((role) => [role, { type: "object", - description: "Developer level models", - properties: { - junior: { + description: `${role.toUpperCase()} level models`, + properties: Object.fromEntries( + getLevelsForRole(role).map((level) => [level, { type: "string", - description: `Default: ${DEFAULT_MODELS.dev.junior}`, - }, - mid: { - type: "string", - description: `Default: ${DEFAULT_MODELS.dev.mid}`, - }, - senior: { - type: "string", - description: `Default: ${DEFAULT_MODELS.dev.senior}`, - }, - }, - }, - qa: { - type: "object", - description: "QA level models", - properties: { - junior: { - type: "string", - description: `Default: ${DEFAULT_MODELS.qa.junior}`, - }, - mid: { - type: "string", - description: `Default: ${DEFAULT_MODELS.qa.mid}`, - }, - senior: { - type: "string", - description: `Default: ${DEFAULT_MODELS.qa.senior}`, - }, - }, - }, - }, + description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`, + }]), + ), + }]), + ), }, projectExecution: { type: "string", @@ -112,13 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) { "", ); } - lines.push( - "Models:", - ...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`), - ...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`), - ...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[t]}`), - "", - ); + lines.push("Models:"); + for (const [role, levels] of Object.entries(result.models)) { + for (const [level, model] of Object.entries(levels)) { + lines.push(` ${role}.${level}: ${model}`); + } + } + lines.push(""); lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`)); diff --git a/lib/tools/status.ts b/lib/tools/status.ts index 05339c0..88aa31f 100644 --- a/lib/tools/status.ts +++ b/lib/tools/status.ts @@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js"; import { log as auditLog } from "../audit.js"; import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js"; import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js"; -import { DEFAULT_WORKFLOW } from "../workflow.js"; +import { loadWorkflow } from "../workflow.js"; export function createStatusTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -32,8 +32,8 @@ export function createStatusTool(api: OpenClawPluginApi) { const pluginConfig = getPluginConfig(api); const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel"; - // TODO: Load per-project workflow when supported - const workflow = DEFAULT_WORKFLOW; + // Load workspace-level workflow (per-project loaded inside map) + const workflow = await loadWorkflow(workspaceDir); const data = await readProjects(workspaceDir); const projectIds = groupId ? [groupId] : Object.keys(data.projects); @@ -52,28 +52,22 @@ export function createStatusTool(api: OpenClawPluginApi) { queueCounts[label] = issues.length; } + // Build dynamic workers summary + const workers: Record = {}; + for (const [role, worker] of Object.entries(project.workers)) { + workers[role] = { + active: worker.active, + issueId: worker.issueId, + level: worker.level, + startTime: worker.startTime, + }; + } + return { name: project.name, groupId: pid, roleExecution: project.roleExecution ?? "parallel", - dev: { - active: project.dev.active, - issueId: project.dev.issueId, - level: project.dev.level, - startTime: project.dev.startTime, - }, - qa: { - active: project.qa.active, - issueId: project.qa.issueId, - level: project.qa.level, - startTime: project.qa.startTime, - }, - architect: { - active: project.architect.active, - issueId: project.architect.issueId, - level: project.architect.level, - startTime: project.architect.startTime, - }, + workers, queue: queueCounts, }; }), diff --git a/lib/tools/task-comment.ts b/lib/tools/task-comment.ts index 3e4873c..b341f3d 100644 --- a/lib/tools/task-comment.ts +++ b/lib/tools/task-comment.ts @@ -2,8 +2,8 @@ * task_comment — Add review comments or notes to an issue. * * Use cases: - * - QA worker adds review feedback without blocking pass/fail - * - DEV worker posts implementation notes + * - Tester worker adds review feedback without blocking pass/fail + * - Developer worker posts implementation notes * - Orchestrator adds summary comments */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; @@ -13,7 +13,7 @@ import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; /** Valid author roles for attribution */ -const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const; +const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const; type AuthorRole = (typeof AUTHOR_ROLES)[number]; export function createTaskCommentTool(api: OpenClawPluginApi) { @@ -23,15 +23,15 @@ export function createTaskCommentTool(api: OpenClawPluginApi) { description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change. Use cases: -- QA adds review feedback without blocking pass/fail -- DEV posts implementation notes or progress updates +- Tester adds review feedback without blocking pass/fail +- Developer posts implementation notes or progress updates - Orchestrator adds summary comments - Cross-referencing related issues or PRs Examples: - Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" } -- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "qa" } -- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "dev" }`, +- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "tester" } +- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "developer" }`, parameters: { type: "object", required: ["projectGroupId", "issueId", "body"], @@ -100,7 +100,7 @@ Examples: // --------------------------------------------------------------------------- const ROLE_EMOJI: Record = { - dev: "👨‍💻", - qa: "🔍", + developer: "👨‍💻", + tester: "🔍", orchestrator: "🎛️", }; diff --git a/lib/tools/task-create.ts b/lib/tools/task-create.ts index 88c9103..ec68adc 100644 --- a/lib/tools/task-create.ts +++ b/lib/tools/task-create.ts @@ -13,7 +13,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { log as auditLog } from "../audit.js"; -import { STATE_LABELS, type StateLabel } from "../providers/provider.js"; +import type { StateLabel } from "../providers/provider.js"; +import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; export function createTaskCreateTool(api: OpenClawPluginApi) { @@ -46,7 +47,7 @@ Examples: label: { type: "string", description: `State label. Defaults to "Planning" — only use "To Do" when the user explicitly asks to start work immediately.`, - enum: STATE_LABELS, + enum: getStateLabels(DEFAULT_WORKFLOW), }, assignees: { type: "array", diff --git a/lib/tools/task-update.ts b/lib/tools/task-update.ts index 6640c46..c3387d8 100644 --- a/lib/tools/task-update.ts +++ b/lib/tools/task-update.ts @@ -10,7 +10,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { log as auditLog } from "../audit.js"; -import { STATE_LABELS, type StateLabel } from "../providers/provider.js"; +import type { StateLabel } from "../providers/provider.js"; +import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; export function createTaskUpdateTool(api: OpenClawPluginApi) { @@ -42,8 +43,8 @@ Examples: }, state: { type: "string", - enum: STATE_LABELS, - description: `New state for the issue. One of: ${STATE_LABELS.join(", ")}`, + enum: getStateLabels(DEFAULT_WORKFLOW), + description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`, }, reason: { type: "string", diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index d38d4cd..8ca0daa 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -8,16 +8,17 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { getWorker, resolveRepoPath } from "../projects.js"; -import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js"; +import { executeCompletion, getRule } from "../services/pipeline.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js"; +import { loadWorkflow } from "../workflow.js"; export function createWorkFinishTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "work_finish", label: "Work Finish", - description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, + description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, parameters: { type: "object", required: ["role", "result", "projectGroupId"], @@ -31,7 +32,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { }, async execute(_id: string, params: Record) { - const role = params.role as "dev" | "qa" | "architect"; + const role = params.role as string; const result = params.result as string; const groupId = params.projectGroupId as string; const summary = params.summary as string | undefined; @@ -59,6 +60,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { const issue = await provider.getIssue(issueId); const pluginConfig = getPluginConfig(api); + const workflow = await loadWorkflow(workspaceDir, project.name); // Execute completion (pipeline service handles notification with runtime) const completion = await executeCompletion({ @@ -67,6 +69,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { channel: project.channel, pluginConfig, runtime: api.runtime, + workflow, }); const output: Record = { diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index e0e9aaa..df44cab 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -13,10 +13,9 @@ import { selectLevel } from "../model-selector.js"; import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; -import { isDevLevel } from "../tiers.js"; -import { getAllRoleIds } from "../roles/index.js"; +import { getAllRoleIds, isLevelForRole } from "../roles/index.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; -import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js"; +import { loadWorkflow, getActiveLabel } from "../workflow.js"; export function createWorkStartTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -36,7 +35,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { async execute(_id: string, params: Record) { const issueIdParam = params.issueId as number | undefined; - const roleParam = params.role as "dev" | "qa" | "architect" | undefined; + const roleParam = params.role as string | undefined; const groupId = params.projectGroupId as string; const levelParam = (params.level ?? params.tier) as string | undefined; const workspaceDir = requireWorkspaceDir(ctx); @@ -45,8 +44,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const { project } = await resolveProject(workspaceDir, groupId); const { provider } = await resolveProvider(project); - // TODO: Load per-project workflow when supported - const workflow = DEFAULT_WORKFLOW; + const workflow = await loadWorkflow(workspaceDir, project.name); // Find issue let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }; @@ -73,8 +71,11 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const worker = getWorker(project, role); if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`); if ((project.roleExecution ?? "parallel") === "sequential") { - const other = role === "dev" ? "qa" : "dev"; - if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`); + for (const [otherRole, otherWorker] of Object.entries(project.workers)) { + if (otherRole !== role && otherWorker.active) { + throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`); + } + } } // Get target label from workflow @@ -87,9 +88,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) { } else { const labelLevel = detectLevelFromLabels(issue.labels); if (labelLevel) { - if (role === "qa" && isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = `QA overrides dev level "${labelLevel}"`; levelSource = "role-override"; } - else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; } - else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; } + if (!isLevelForRole(labelLevel, role)) { + // Label level belongs to a different role — use heuristic for this role + const s = selectLevel(issue.title, issue.description ?? "", role); + selectedLevel = s.level; levelReason = `${role} overrides other role's level "${labelLevel}"`; levelSource = "role-override"; + } else { + selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; + } } else { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; diff --git a/lib/workflow.ts b/lib/workflow.ts index 38da6d1..46882f7 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -9,16 +9,13 @@ * * All workflow behavior is derived from this config — no hardcoded state names. */ -import fs from "node:fs/promises"; -import path from "node:path"; - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type StateType = "queue" | "active" | "hold" | "terminal"; -/** @deprecated Use WorkerRole from lib/roles/ */ -export type Role = "dev" | "qa" | "architect"; +/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ +export type Role = string; export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue"; export type TransitionTarget = string | { @@ -64,7 +61,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, todo: { type: "queue", - role: "dev", + role: "developer", label: "To Do", color: "#428bca", priority: 1, @@ -72,7 +69,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, doing: { type: "active", - role: "dev", + role: "developer", label: "Doing", color: "#f0ad4e", on: { @@ -82,7 +79,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, toTest: { type: "queue", - role: "qa", + role: "tester", label: "To Test", color: "#5bc0de", priority: 2, @@ -90,7 +87,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, testing: { type: "active", - role: "qa", + role: "tester", label: "Testing", color: "#9b59b6", on: { @@ -102,7 +99,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, toImprove: { type: "queue", - role: "dev", + role: "developer", label: "To Improve", color: "#d9534f", priority: 3, @@ -146,38 +143,15 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { /** * Load workflow config for a project. - * Priority: project-specific → workspace default → built-in default + * Delegates to loadConfig() which handles the three-layer merge. */ export async function loadWorkflow( workspaceDir: string, - _groupId?: string, + projectName?: string, ): Promise { - // TODO: Support per-project overrides from projects.json when needed - // For now, try workspace-level config, fall back to default - - const workflowPath = path.join(workspaceDir, "projects", "workflow.json"); - try { - const content = await fs.readFile(workflowPath, "utf-8"); - const parsed = JSON.parse(content) as { workflow?: WorkflowConfig }; - if (parsed.workflow) { - return mergeWorkflow(DEFAULT_WORKFLOW, parsed.workflow); - } - } catch { - // No custom workflow, use default - } - - return DEFAULT_WORKFLOW; -} - -/** - * Merge custom workflow config over defaults. - * Custom states are merged, not replaced entirely. - */ -function mergeWorkflow(base: WorkflowConfig, custom: Partial): WorkflowConfig { - return { - initial: custom.initial ?? base.initial, - states: { ...base.states, ...custom.states }, - }; + const { loadConfig } = await import("./config/loader.js"); + const config = await loadConfig(workspaceDir, projectName); + return config.workflow; } // --------------------------------------------------------------------------- @@ -305,31 +279,30 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st // --------------------------------------------------------------------------- /** - * Map role:result to completion event name. + * Map completion result to workflow transition event name. + * Convention: "done" → COMPLETE, others → uppercase. */ -const RESULT_TO_EVENT: Record = { - "dev:done": "COMPLETE", - "dev:blocked": "BLOCKED", - "qa:pass": "PASS", - "qa:fail": "FAIL", - "qa:refine": "REFINE", - "qa:blocked": "BLOCKED", - "architect:done": "COMPLETE", - "architect:blocked": "BLOCKED", -}; +function resultToEvent(result: string): string { + if (result === "done") return "COMPLETE"; + return result.toUpperCase(); +} /** * Get completion rule for a role:result pair. + * Derives entirely from workflow transitions — no hardcoded role:result mapping. */ export function getCompletionRule( workflow: WorkflowConfig, role: Role, result: string, ): CompletionRule | null { - const event = RESULT_TO_EVENT[`${role}:${result}`]; - if (!event) return null; + const event = resultToEvent(result); + + let activeLabel: string; + try { + activeLabel = getActiveLabel(workflow, role); + } catch { return null; } - const activeLabel = getActiveLabel(workflow, role); const activeKey = findStateKeyByLabel(workflow, activeLabel); if (!activeKey) return null; @@ -356,6 +329,7 @@ export function getCompletionRule( /** * Get human-readable next state description. + * Derives from target state type — no hardcoded role names. */ export function getNextStateDescription( workflow: WorkflowConfig, @@ -365,15 +339,13 @@ export function getNextStateDescription( const rule = getCompletionRule(workflow, role, result); if (!rule) return ""; - // Find the target state to determine the description const targetState = findStateByLabel(workflow, rule.to); if (!targetState) return ""; if (targetState.type === "terminal") return "Done!"; if (targetState.type === "hold") return "awaiting human decision"; - if (targetState.type === "queue") { - if (targetState.role === "qa") return "QA queue"; - if (targetState.role === "dev") return "back to DEV"; + if (targetState.type === "queue" && targetState.role) { + return `${targetState.role.toUpperCase()} queue`; } return rule.to; @@ -381,19 +353,18 @@ export function getNextStateDescription( /** * Get emoji for a completion result. + * Keyed by result name — role-independent. */ -export function getCompletionEmoji(role: Role, result: string): string { - const map: Record = { - "dev:done": "✅", - "qa:pass": "🎉", - "qa:fail": "❌", - "qa:refine": "🤔", - "dev:blocked": "🚫", - "qa:blocked": "🚫", - "architect:done": "🏗️", - "architect:blocked": "🚫", - }; - return map[`${role}:${result}`] ?? "📋"; +const RESULT_EMOJI: Record = { + done: "✅", + pass: "🎉", + fail: "❌", + refine: "🤔", + blocked: "🚫", +}; + +export function getCompletionEmoji(_role: Role, result: string): string { + return RESULT_EMOJI[result] ?? "📋"; } // --------------------------------------------------------------------------- diff --git a/package-lock.json b/package-lock.json index a644331..54fe18b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@laurentenhoor/devclaw", - "version": "1.1.0", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@laurentenhoor/devclaw", - "version": "1.1.0", + "version": "1.2.2", "license": "MIT", + "dependencies": { + "yaml": "^2.8.2" + }, "devDependencies": { "@types/node": "^25.2.3", "typescript": "^5.9.3" @@ -8729,7 +8732,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index af6f63f..01a15d3 100644 --- a/package.json +++ b/package.json @@ -53,5 +53,8 @@ "devDependencies": { "@types/node": "^25.2.3", "typescript": "^5.9.3" + }, + "dependencies": { + "yaml": "^2.8.2" } } From a85f4fd33e88b3b18cefe73954d0aa0e51385d17 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 18:46:00 +0800 Subject: [PATCH 03/13] refactor: implement dynamic role and level handling with migration support --- index.ts | 55 +++++------- lib/migrations.ts | 141 ++++++++++++++++++++++++++++++ lib/projects.ts | 66 +------------- lib/roles/registry.test.ts | 4 +- lib/roles/registry.ts | 6 +- lib/roles/selectors.ts | 36 ++------ lib/setup/llm-model-selector.ts | 119 +++++++++++++------------ lib/setup/smart-model-selector.ts | 58 ++++++------ lib/tools/design-task.test.ts | 2 +- lib/tools/task-comment.ts | 18 ++-- 10 files changed, 278 insertions(+), 227 deletions(-) create mode 100644 lib/migrations.ts diff --git a/index.ts b/index.ts index 32f405e..d8eebb9 100644 --- a/index.ts +++ b/index.ts @@ -15,6 +15,28 @@ import { registerCli } from "./lib/cli.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js"; import { registerBootstrapHook } from "./lib/bootstrap-hook.js"; import { initRunCommand } from "./lib/run-command.js"; +import { ROLE_REGISTRY } from "./lib/roles/index.js"; + +/** Build the models config schema dynamically from the role registry. */ +function buildModelsSchema(): Record { + const properties: Record = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + const levelProps: Record = {}; + for (const level of config.levels) { + levelProps[level] = { type: "string" }; + } + properties[roleId] = { + type: "object", + description: `${config.displayName} level models`, + properties: levelProps, + }; + } + return { + type: "object", + description: "Model mapping per role and level", + properties, + }; +} const plugin = { id: "devclaw", @@ -24,38 +46,7 @@ const plugin = { configSchema: { type: "object", properties: { - models: { - type: "object", - description: "Model mapping per role and level", - properties: { - dev: { - type: "object", - description: "Developer level models", - properties: { - junior: { type: "string" }, - mid: { type: "string" }, - senior: { type: "string" }, - }, - }, - qa: { - type: "object", - description: "QA level models", - properties: { - junior: { type: "string" }, - mid: { type: "string" }, - senior: { type: "string" }, - }, - }, - architect: { - type: "object", - description: "Architect level models", - properties: { - junior: { type: "string" }, - senior: { type: "string" }, - }, - }, - }, - }, + models: buildModelsSchema(), projectExecution: { type: "string", enum: ["parallel", "sequential"], diff --git a/lib/migrations.ts b/lib/migrations.ts new file mode 100644 index 0000000..803bbbd --- /dev/null +++ b/lib/migrations.ts @@ -0,0 +1,141 @@ +/** + * migrations.ts — Backward-compatibility aliases and migration logic. + * + * Contains all role/level renaming aliases and projects.json format migration. + * This file can be removed once all users have migrated to the new format. + * + * Migrations handled: + * - Role renames: dev → developer, qa → tester + * - Level renames: mid → medior, reviewer → medior, tester → junior, opus → senior, sonnet → junior + * - projects.json format: old hardcoded dev/qa/architect fields → workers map + * - projects.json format: old role keys in workers map → canonical role keys + */ + +import type { WorkerState, Project } from "./projects.js"; + +// --------------------------------------------------------------------------- +// Role aliases — old role IDs → canonical IDs +// --------------------------------------------------------------------------- + +/** Maps old role IDs to canonical IDs. */ +export const ROLE_ALIASES: Record = { + 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 — old level names → canonical names, per role +// --------------------------------------------------------------------------- + +/** Maps old level names to canonical names, per role. */ +export const LEVEL_ALIASES: Record> = { + 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" }, +}; + +/** Resolve a level name, applying aliases for backward compatibility. */ +export function canonicalLevel(role: string, level: string): string { + return LEVEL_ALIASES[role]?.[level] ?? level; +} + +// --------------------------------------------------------------------------- +// projects.json migration helpers +// --------------------------------------------------------------------------- + +function migrateLevel(level: string | null, role: string): string | null { + if (!level) return null; + return LEVEL_ALIASES[role]?.[level] ?? level; +} + +function migrateSessions( + sessions: Record, + role: string, +): Record { + const aliases = LEVEL_ALIASES[role]; + if (!aliases) return sessions; + + const migrated: Record = {}; + for (const [key, value] of Object.entries(sessions)) { + const newKey = aliases[key] ?? key; + migrated[newKey] = value; + } + return migrated; +} + +function parseWorkerState(worker: Record, role: string): WorkerState { + const level = (worker.level ?? worker.tier ?? null) as string | null; + const sessions = (worker.sessions as Record) ?? {}; + return { + active: worker.active as boolean, + issueId: worker.issueId as string | null, + startTime: worker.startTime as string | null, + level: migrateLevel(level, role), + sessions: migrateSessions(sessions, role), + }; +} + +/** Empty worker state with null sessions for given levels. */ +function emptyWorkerState(levels: string[]): WorkerState { + const sessions: Record = {}; + for (const l of levels) { + sessions[l] = null; + } + return { + active: false, + issueId: null, + startTime: null, + level: null, + sessions, + }; +} + +/** + * Migrate a raw project object from old format to current format. + * + * Handles: + * 1. Old format: hardcoded dev/qa/architect fields → workers map + * 2. Old role keys in workers map (dev → developer, qa → tester) + * 3. Old level names in worker state + * 4. Missing channel field defaults to "telegram" + */ +export function migrateProject(project: Project): void { + const raw = project as unknown as Record; + + if (!raw.workers && (raw.dev || raw.qa || raw.architect)) { + // Old format: hardcoded dev/qa/architect fields → workers map + project.workers = {}; + for (const role of ["dev", "qa", "architect"]) { + const canonical = ROLE_ALIASES[role] ?? role; + project.workers[canonical] = raw[role] + ? parseWorkerState(raw[role] as Record, role) + : emptyWorkerState([]); + } + // Clean up old fields from the in-memory object + delete raw.dev; + delete raw.qa; + delete raw.architect; + } else if (raw.workers) { + // New format: parse each worker with role-aware migration + const workers = raw.workers as Record>; + project.workers = {}; + for (const [role, worker] of Object.entries(workers)) { + // Migrate old role keys (dev→developer, qa→tester) + const canonical = ROLE_ALIASES[role] ?? role; + project.workers[canonical] = parseWorkerState(worker, role); + } + } else { + project.workers = {}; + } + + if (!project.channel) { + project.channel = "telegram"; + } +} diff --git a/lib/projects.ts b/lib/projects.ts index f8ba3d8..5eb99d9 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -5,7 +5,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { homedir } from "node:os"; -import { LEVEL_ALIASES, ROLE_ALIASES } from "./roles/index.js"; +import { migrateProject } from "./migrations.js"; + export type WorkerState = { active: boolean; issueId: string | null; @@ -37,38 +38,6 @@ export type ProjectsData = { projects: Record; }; -function migrateLevel(level: string | null, role: string): string | null { - if (!level) return null; - return LEVEL_ALIASES[role]?.[level] ?? level; -} - -function migrateSessions( - sessions: Record, - role: string, -): Record { - const aliases = LEVEL_ALIASES[role]; - if (!aliases) return sessions; - - const migrated: Record = {}; - for (const [key, value] of Object.entries(sessions)) { - const newKey = aliases[key] ?? key; - migrated[newKey] = value; - } - return migrated; -} - -function parseWorkerState(worker: Record, role: string): WorkerState { - const level = (worker.level ?? worker.tier ?? null) as string | null; - const sessions = (worker.sessions as Record) ?? {}; - return { - active: worker.active as boolean, - issueId: worker.issueId as string | null, - startTime: worker.startTime as string | null, - level: migrateLevel(level, role), - sessions: migrateSessions(sessions, role), - }; -} - /** * Create a blank WorkerState with null sessions for given level names. */ @@ -105,36 +74,7 @@ export async function readProjects(workspaceDir: string): Promise const data = JSON.parse(raw) as ProjectsData; for (const project of Object.values(data.projects)) { - // Migrate old format: hardcoded dev/qa/architect fields → workers map - const raw = project as unknown as Record; - if (!raw.workers && (raw.dev || raw.qa || raw.architect)) { - project.workers = {}; - for (const role of ["dev", "qa", "architect"]) { - const canonical = ROLE_ALIASES[role] ?? role; - project.workers[canonical] = raw[role] - ? parseWorkerState(raw[role] as Record, role) - : emptyWorkerState([]); - } - // Clean up old fields from the in-memory object - delete raw.dev; - delete raw.qa; - delete raw.architect; - } else if (raw.workers) { - // New format: parse each worker with role-aware migration - const workers = raw.workers as Record>; - project.workers = {}; - for (const [role, worker] of Object.entries(workers)) { - // Migrate old role keys (dev→developer, qa→tester) - const canonical = ROLE_ALIASES[role] ?? role; - project.workers[canonical] = parseWorkerState(worker, role); - } - } else { - project.workers = {}; - } - - if (!project.channel) { - project.channel = "telegram"; - } + migrateProject(project); } return data; diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index 13d427e..b1cd9c9 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -129,7 +129,7 @@ describe("models", () => { assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5"); assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5"); - assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); + assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6"); }); it("should return all default models", () => { @@ -150,7 +150,7 @@ describe("models", () => { }); it("should pass through unknown level as model ID", () => { - assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5"); + assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6"); }); it("should resolve old config keys via aliases", () => { diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index 1c1a9d8..cd1fbee 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -22,7 +22,7 @@ export const ROLE_REGISTRY: Record = { models: { junior: "anthropic/claude-haiku-4-5", medior: "anthropic/claude-sonnet-4-5", - senior: "anthropic/claude-opus-4-5", + senior: "anthropic/claude-opus-4-6", }, emoji: { junior: "⚡", @@ -43,7 +43,7 @@ export const ROLE_REGISTRY: Record = { models: { junior: "anthropic/claude-haiku-4-5", medior: "anthropic/claude-sonnet-4-5", - senior: "anthropic/claude-opus-4-5", + senior: "anthropic/claude-opus-4-6", }, emoji: { junior: "⚡", @@ -63,7 +63,7 @@ export const ROLE_REGISTRY: Record = { defaultLevel: "junior", models: { junior: "anthropic/claude-sonnet-4-5", - senior: "anthropic/claude-opus-4-5", + senior: "anthropic/claude-opus-4-6", }, emoji: { junior: "📐", diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index 7a5092d..aa6191b 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -7,6 +7,7 @@ import { ROLE_REGISTRY } from "./registry.js"; import type { RoleConfig } from "./types.js"; import type { ResolvedRoleConfig } from "../config/types.js"; +import { ROLE_ALIASES as _ROLE_ALIASES, canonicalLevel as _canonicalLevel } from "../migrations.js"; // --------------------------------------------------------------------------- // Role IDs @@ -38,37 +39,10 @@ export function requireRole(role: string): RoleConfig { } // --------------------------------------------------------------------------- -// Role aliases — maps old role IDs to new canonical IDs +// Migration aliases — re-exported from lib/migrations.ts for backward compat // --------------------------------------------------------------------------- -/** Maps old role IDs to canonical IDs. Used for backward compatibility. */ -export const ROLE_ALIASES: Record = { - 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 -// --------------------------------------------------------------------------- - -/** Maps old level names to canonical names, per role. Used for backward compatibility. */ -export const LEVEL_ALIASES: Record> = { - 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" }, -}; - -/** Resolve a level name, applying aliases for backward compatibility. */ -export function canonicalLevel(role: string, level: string): string { - return LEVEL_ALIASES[role]?.[level] ?? level; -} +export { ROLE_ALIASES, canonicalRole, LEVEL_ALIASES, canonicalLevel } from "../migrations.js"; // --------------------------------------------------------------------------- // Levels @@ -135,13 +109,13 @@ export function resolveModel( pluginConfig?: Record, resolvedRole?: ResolvedRoleConfig, ): 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 })?.models; if (models && typeof models === "object") { // Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer") - const roleModels = (models[role] ?? models[Object.entries(ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record | undefined; + const roleModels = (models[role] ?? models[Object.entries(_ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record | undefined; if (roleModels?.[canonical]) return roleModels[canonical]; if (roleModels?.[level]) return roleModels[level]; } diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index 51f971c..ed648c8 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -4,23 +4,63 @@ * Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles. */ import { runCommand } from "../run-command.js"; +import { ROLE_REGISTRY } from "../roles/index.js"; +import type { ModelAssignment } from "./smart-model-selector.js"; -export type ModelAssignment = { - developer: { - junior: string; - medior: string; - senior: string; - }; - tester: { - junior: string; - medior: string; - senior: string; - }; - architect: { - junior: string; - senior: string; - }; -}; +/** + * Build a ModelAssignment where every role/level maps to the same model. + */ +function singleModelAssignment(model: string): ModelAssignment { + const result: ModelAssignment = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + result[roleId] = {}; + for (const level of config.levels) { + result[roleId][level] = model; + } + } + return result; +} + +/** + * Build the JSON format example for the LLM prompt, derived from registry. + */ +function buildJsonExample(): string { + const obj: Record> = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + obj[roleId] = {}; + for (const level of config.levels) { + obj[roleId][level] = "provider/model-name"; + } + } + return JSON.stringify(obj, null, 2); +} + +/** + * Validate that a parsed assignment has all required roles and levels. + */ +function validateAssignment(assignment: Record, fallbackModel: string): ModelAssignment | null { + const result: ModelAssignment = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + const roleData = assignment[roleId] as Record | undefined; + if (!roleData) { + // Backfill missing roles from the first available role or fallback + result[roleId] = {}; + for (const level of config.levels) { + result[roleId][level] = fallbackModel; + } + continue; + } + result[roleId] = {}; + for (const level of config.levels) { + if (!roleData[level]) { + console.error(`Missing ${roleId}.${level} in LLM assignment`); + return null; + } + result[roleId][level] = roleData[level]; + } + } + return result; +} /** * Use an LLM to intelligently select and assign models to DevClaw roles. @@ -35,16 +75,12 @@ export async function selectModelsWithLLM( // If only one model, assign it to all roles if (availableModels.length === 1) { - const model = availableModels[0].model; - return { - developer: { junior: model, medior: model, senior: model }, - tester: { junior: model, medior: model, senior: model }, - architect: { junior: model, senior: model }, - }; + return singleModelAssignment(availableModels[0].model); } // Create a prompt for the LLM const modelList = availableModels.map((m) => m.model).join("\n"); + const jsonExample = buildJsonExample(); const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities. @@ -65,22 +101,7 @@ Rules: 6. Stable versions (no date) > snapshot versions (with date like 20250514) Return ONLY a JSON object in this exact format (no markdown, no explanation): -{ - "developer": { - "junior": "provider/model-name", - "medior": "provider/model-name", - "senior": "provider/model-name" - }, - "tester": { - "junior": "provider/model-name", - "medior": "provider/model-name", - "senior": "provider/model-name" - }, - "architect": { - "junior": "provider/model-name", - "senior": "provider/model-name" - } -}`; +${jsonExample}`; try { const sessionId = sessionKey ?? "devclaw-model-selection"; @@ -127,28 +148,14 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): // Log what we got for debugging console.log("LLM returned:", JSON.stringify(assignment, null, 2)); - // Validate the structure - // Backfill architect if LLM didn't return it (graceful upgrade) - if (!assignment.architect) { - assignment.architect = { - senior: assignment.developer?.senior ?? availableModels[0].model, - junior: assignment.developer?.medior ?? availableModels[0].model, - }; - } - - if ( - !assignment.developer?.junior || - !assignment.developer?.medior || - !assignment.developer?.senior || - !assignment.tester?.junior || - !assignment.tester?.medior || - !assignment.tester?.senior - ) { + // Validate and backfill + const validated = validateAssignment(assignment, availableModels[0].model); + if (!validated) { console.error("Invalid assignment structure. Got:", assignment); throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`); } - return assignment as ModelAssignment; + return validated; } catch (err) { console.error("LLM model selection failed:", (err as Error).message); return null; diff --git a/lib/setup/smart-model-selector.ts b/lib/setup/smart-model-selector.ts index 76b9ea5..abb0ff4 100644 --- a/lib/setup/smart-model-selector.ts +++ b/lib/setup/smart-model-selector.ts @@ -3,23 +3,25 @@ * * Uses an LLM to intelligently analyze and assign models to DevClaw roles. */ +import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; +import { ROLE_REGISTRY } from "../roles/index.js"; -export type ModelAssignment = { - developer: { - junior: string; - medior: string; - senior: string; - }; - tester: { - junior: string; - medior: string; - senior: string; - }; - architect: { - junior: string; - senior: string; - }; -}; +/** Model assignment: role → level → model ID. Derived from registry structure. */ +export type ModelAssignment = Record>; + +/** + * Build a ModelAssignment where every role/level maps to the same model. + */ +function singleModelAssignment(model: string): ModelAssignment { + const result: ModelAssignment = {}; + for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { + result[roleId] = {}; + for (const level of config.levels) { + result[roleId][level] = model; + } + } + return result; +} /** * Intelligently assign available models to DevClaw roles using an LLM. @@ -42,12 +44,7 @@ export async function assignModels( // If only one model, use it for everything if (authenticated.length === 1) { - const model = authenticated[0].model; - return { - developer: { junior: model, medior: model, senior: model }, - tester: { junior: model, medior: model, senior: model }, - architect: { junior: model, senior: model }, - }; + return singleModelAssignment(authenticated[0].model); } // Multiple models: use LLM-based selection @@ -68,15 +65,16 @@ export function formatAssignment(assignment: ModelAssignment): string { const lines = [ "| Role | Level | Model |", "|-----------|----------|--------------------------|", - `| DEVELOPER | senior | ${assignment.developer.senior.padEnd(24)} |`, - `| DEVELOPER | medior | ${assignment.developer.medior.padEnd(24)} |`, - `| DEVELOPER | junior | ${assignment.developer.junior.padEnd(24)} |`, - `| TESTER | senior | ${assignment.tester.senior.padEnd(24)} |`, - `| TESTER | medior | ${assignment.tester.medior.padEnd(24)} |`, - `| TESTER | junior | ${assignment.tester.junior.padEnd(24)} |`, - `| ARCHITECT | senior | ${assignment.architect.senior.padEnd(24)} |`, - `| ARCHITECT | junior | ${assignment.architect.junior.padEnd(24)} |`, ]; + for (const roleId of getAllRoleIds()) { + const roleModels = assignment[roleId]; + if (!roleModels) continue; + const displayName = ROLE_REGISTRY[roleId]?.displayName ?? roleId.toUpperCase(); + for (const level of getLevelsForRole(roleId)) { + const model = roleModels[level] ?? ""; + lines.push(`| ${displayName.padEnd(9)} | ${level.padEnd(8)} | ${model.padEnd(24)} |`); + } + } return lines.join("\n"); } diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index 258c06e..e153634 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -27,7 +27,7 @@ describe("architect tiers", () => { }); it("should resolve default architect models", () => { - assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5"); + assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6"); assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); diff --git a/lib/tools/task-comment.ts b/lib/tools/task-comment.ts index b341f3d..be7baac 100644 --- a/lib/tools/task-comment.ts +++ b/lib/tools/task-comment.ts @@ -11,10 +11,11 @@ import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; +import { getAllRoleIds, getFallbackEmoji } from "../roles/index.js"; -/** Valid author roles for attribution */ -const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const; -type AuthorRole = (typeof AUTHOR_ROLES)[number]; +/** Valid author roles for attribution — all registry roles + orchestrator */ +const AUTHOR_ROLES = [...getAllRoleIds(), "orchestrator"]; +type AuthorRole = string; export function createTaskCommentTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -73,7 +74,7 @@ Examples: const issue = await provider.getIssue(issueId); const commentBody = authorRole - ? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}` + ? `${getRoleEmoji(authorRole)} **${authorRole.toUpperCase()}**: ${body}` : body; await provider.addComment(issueId, commentBody); @@ -99,8 +100,7 @@ Examples: // Private helpers // --------------------------------------------------------------------------- -const ROLE_EMOJI: Record = { - developer: "👨‍💻", - tester: "🔍", - orchestrator: "🎛️", -}; +function getRoleEmoji(role: string): string { + if (role === "orchestrator") return "🎛️"; + return getFallbackEmoji(role); +} From 89245f8ffaaca1cf233a6cf554dace569094d99f Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 18:56:10 +0800 Subject: [PATCH 04/13] refactor: update task creation to use dynamic initial label from workflow config --- lib/tools/task-create.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/tools/task-create.ts b/lib/tools/task-create.ts index ec68adc..196b9cd 100644 --- a/lib/tools/task-create.ts +++ b/lib/tools/task-create.ts @@ -17,16 +17,19 @@ import type { StateLabel } from "../providers/provider.js"; import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; +/** Derive the initial state label from the workflow config. */ +const INITIAL_LABEL = DEFAULT_WORKFLOW.states[DEFAULT_WORKFLOW.initial].label; + export function createTaskCreateTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "task_create", label: "Task Create", description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat. -**IMPORTANT:** Always creates in "Planning" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "Planning" issues require human review before entering the queue. +**IMPORTANT:** Always creates in "${INITIAL_LABEL}" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "${INITIAL_LABEL}" issues require human review before entering the queue. Examples: -- Default: { title: "Fix login bug" } → created in Planning +- Default: { title: "Fix login bug" } → created in ${INITIAL_LABEL} - User says "create and start working": { title: "Implement auth", description: "...", label: "To Do" }`, parameters: { type: "object", @@ -46,7 +49,7 @@ Examples: }, label: { 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 "${INITIAL_LABEL}" — only use "To Do" when the user explicitly asks to start work immediately.`, enum: getStateLabels(DEFAULT_WORKFLOW), }, assignees: { @@ -65,7 +68,7 @@ Examples: const groupId = params.projectGroupId as string; const title = params.title as string; const description = (params.description as string) ?? ""; - const label = (params.label as StateLabel) ?? "Planning"; + const label = (params.label as StateLabel) ?? INITIAL_LABEL; const assignees = (params.assignees as string[] | undefined) ?? []; const pickup = (params.pickup as boolean) ?? false; const workspaceDir = requireWorkspaceDir(ctx); From a359ffed34a44638e4315eed8f9351e166a95356 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 20:19:09 +0800 Subject: [PATCH 05/13] feat(migration): implement workspace layout migration and testing - Added `migrate-layout.ts` to handle migration from old workspace layouts to the new `devclaw/` structure. - Introduced `migrate-layout.test.ts` for comprehensive tests covering various migration scenarios. - Updated `workspace.ts` to ensure default files are created post-migration, including `workflow.yaml` and role-specific prompts. - Refactored role instruction handling to accommodate new directory structure. - Enhanced project registration to scaffold prompt files in the new `devclaw/projects//prompts/` directory. - Adjusted setup tool descriptions and logic to reflect changes in file structure. - Updated templates to align with the new workflow configuration and role instructions. --- index.ts | 23 -- lib/audit.ts | 3 +- lib/bootstrap-hook.test.ts | 36 ++- lib/bootstrap-hook.ts | 31 ++- lib/config/index.ts | 2 +- lib/config/loader.ts | 54 +++-- lib/config/types.ts | 6 +- lib/dispatch.ts | 2 +- lib/onboarding.ts | 24 +- lib/projects.test.ts | 26 +-- lib/projects.ts | 4 +- lib/roles/registry.test.ts | 32 ++- lib/roles/selectors.ts | 21 +- lib/services/heartbeat.ts | 16 +- lib/setup/config.ts | 16 +- lib/setup/index.ts | 46 +++- lib/setup/migrate-layout.test.ts | 312 ++++++++++++++++++++++++++ lib/setup/migrate-layout.ts | 365 +++++++++++++++++++++++++++++++ lib/setup/workspace.ts | 93 ++++---- lib/templates.ts | 108 ++++++++- lib/tools/design-task.test.ts | 6 +- lib/tools/design-task.ts | 5 +- lib/tools/onboard.ts | 2 +- lib/tools/project-register.ts | 7 +- lib/tools/setup.ts | 2 +- 25 files changed, 1035 insertions(+), 207 deletions(-) create mode 100644 lib/setup/migrate-layout.test.ts create mode 100644 lib/setup/migrate-layout.ts diff --git a/index.ts b/index.ts index d8eebb9..d646a1f 100644 --- a/index.ts +++ b/index.ts @@ -15,28 +15,6 @@ import { registerCli } from "./lib/cli.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js"; import { registerBootstrapHook } from "./lib/bootstrap-hook.js"; import { initRunCommand } from "./lib/run-command.js"; -import { ROLE_REGISTRY } from "./lib/roles/index.js"; - -/** Build the models config schema dynamically from the role registry. */ -function buildModelsSchema(): Record { - const properties: Record = {}; - for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { - const levelProps: Record = {}; - for (const level of config.levels) { - levelProps[level] = { type: "string" }; - } - properties[roleId] = { - type: "object", - description: `${config.displayName} level models`, - properties: levelProps, - }; - } - return { - type: "object", - description: "Model mapping per role and level", - properties, - }; -} const plugin = { id: "devclaw", @@ -46,7 +24,6 @@ const plugin = { configSchema: { type: "object", properties: { - models: buildModelsSchema(), projectExecution: { type: "string", enum: ["parallel", "sequential"], diff --git a/lib/audit.ts b/lib/audit.ts index 512dfee..64040dd 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -5,6 +5,7 @@ */ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { join, dirname } from "node:path"; +import { DATA_DIR } from "./setup/migrate-layout.js"; const MAX_LOG_LINES = 50; @@ -13,7 +14,7 @@ export async function log( event: string, data: Record, ): Promise { - const filePath = join(workspaceDir, "log", "audit.log"); + const filePath = join(workspaceDir, DATA_DIR, "log", "audit.log"); const entry = JSON.stringify({ ts: new Date().toISOString(), event, diff --git a/lib/bootstrap-hook.test.ts b/lib/bootstrap-hook.test.ts index d6ac418..faacee5 100644 --- a/lib/bootstrap-hook.test.ts +++ b/lib/bootstrap-hook.test.ts @@ -57,9 +57,9 @@ describe("parseDevClawSessionKey", () => { }); describe("loadRoleInstructions", () => { - it("should load project-specific instructions", async () => { + it("should load project-specific instructions from devclaw/projects//prompts/", async () => { 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, "devclaw", "projects", "test-project", "prompts"); await fs.mkdir(projectDir, { recursive: true }); await fs.writeFile(path.join(projectDir, "developer.md"), "# Developer Instructions\nDo the thing."); @@ -69,11 +69,11 @@ describe("loadRoleInstructions", () => { await fs.rm(tmpDir, { recursive: true }); }); - it("should fall back to default instructions", async () => { + it("should fall back to default instructions from devclaw/prompts/", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); - const defaultDir = path.join(tmpDir, "projects", "roles", "default"); - await fs.mkdir(defaultDir, { recursive: true }); - await fs.writeFile(path.join(defaultDir, "tester.md"), "# Tester Default\nReview carefully."); + const promptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(promptsDir, "tester.md"), "# Tester Default\nReview carefully."); const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester"); assert.strictEqual(result, "# Tester Default\nReview carefully."); @@ -92,16 +92,28 @@ describe("loadRoleInstructions", () => { it("should prefer project-specific over default", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); - const projectDir = path.join(tmpDir, "projects", "roles", "my-project"); - const defaultDir = path.join(tmpDir, "projects", "roles", "default"); - await fs.mkdir(projectDir, { recursive: true }); - await fs.mkdir(defaultDir, { recursive: true }); - await fs.writeFile(path.join(projectDir, "developer.md"), "Project-specific instructions"); - await fs.writeFile(path.join(defaultDir, "developer.md"), "Default instructions"); + const projectPromptsDir = path.join(tmpDir, "devclaw", "projects", "my-project", "prompts"); + const defaultPromptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(projectPromptsDir, { recursive: true }); + await fs.mkdir(defaultPromptsDir, { recursive: true }); + await fs.writeFile(path.join(projectPromptsDir, "developer.md"), "Project-specific instructions"); + await fs.writeFile(path.join(defaultPromptsDir, "developer.md"), "Default instructions"); const result = await loadRoleInstructions(tmpDir, "my-project", "developer"); assert.strictEqual(result, "Project-specific instructions"); await fs.rm(tmpDir, { recursive: true }); }); + + it("should fall back to old path for unmigrated workspaces", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + const oldDir = path.join(tmpDir, "projects", "roles", "old-project"); + await fs.mkdir(oldDir, { recursive: true }); + await fs.writeFile(path.join(oldDir, "developer.md"), "Old layout instructions"); + + const result = await loadRoleInstructions(tmpDir, "old-project", "developer"); + assert.strictEqual(result, "Old layout instructions"); + + await fs.rm(tmpDir, { recursive: true }); + }); }); diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index f81a118..d9f2806 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -11,6 +11,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { getSessionKeyRolePattern } from "./roles/index.js"; +import { DATA_DIR } from "./setup/migrate-layout.js"; /** * Parse a DevClaw subagent session key to extract project name and role. @@ -44,18 +45,24 @@ export async function loadRoleInstructions( projectName: string, role: string, ): Promise { - const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); - try { - return await fs.readFile(projectFile, "utf-8"); - } catch { - /* not found — try default */ - } - const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`); - try { - return await fs.readFile(defaultFile, "utf-8"); - } catch { - /* not found */ - } + const dataDir = path.join(workspaceDir, DATA_DIR); + + // Project-specific: devclaw/projects//prompts/.md + const projectFile = path.join(dataDir, "projects", projectName, "prompts", `${role}.md`); + try { return await fs.readFile(projectFile, "utf-8"); } catch { /* not found */ } + + // Fallback old path: projects/roles//.md + const oldProjectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); + try { return await fs.readFile(oldProjectFile, "utf-8"); } catch { /* not found */ } + + // Default: devclaw/prompts/.md + const defaultFile = path.join(dataDir, "prompts", `${role}.md`); + try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* not found */ } + + // Fallback old default: projects/roles/default/.md + const oldDefaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`); + try { return await fs.readFile(oldDefaultFile, "utf-8"); } catch { /* not found */ } + return ""; } diff --git a/lib/config/index.ts b/lib/config/index.ts index 3e9b285..94345be 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -1,7 +1,7 @@ /** * config/ — Unified DevClaw configuration. * - * Single config.yaml per workspace/project combining roles, models, and workflow. + * Single workflow.yaml per workspace/project combining roles, models, and workflow. */ export type { DevClawConfig, diff --git a/lib/config/loader.ts b/lib/config/loader.ts index c0c883c..60eabc2 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -3,10 +3,10 @@ * * Resolution order: * 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW) - * 2. Workspace: /projects/config.yaml - * 3. Project: /projects//config.yaml + * 2. Workspace: /devclaw/workflow.yaml + * 3. Project: /devclaw/projects//workflow.yaml * - * Also supports legacy workflow.yaml files (merged into the workflow section). + * Also supports legacy config.yaml and workflow.json for backward compat. */ import fs from "node:fs/promises"; import path from "node:path"; @@ -15,48 +15,52 @@ 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"; +import { DATA_DIR } from "../setup/migrate-layout.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. + * Merges: built-in → workspace workflow.yaml → project workflow.yaml. */ export async function loadConfig( workspaceDir: string, projectName?: string, ): Promise { - const projectsDir = path.join(workspaceDir, "projects"); + const dataDir = path.join(workspaceDir, DATA_DIR); + const projectsDir = path.join(dataDir, "projects"); // Layer 1: built-in defaults const builtIn = buildDefaultConfig(); - // Layer 2: workspace config.yaml + // Layer 2: workspace workflow.yaml (in devclaw/ data dir) let merged = builtIn; - const workspaceConfig = await readConfigFile(projectsDir); + const workspaceConfig = + await readWorkflowFile(dataDir) ?? + await readLegacyConfigFile(path.join(workspaceDir, "projects")); if (workspaceConfig) { merged = mergeConfig(merged, workspaceConfig); } - // Legacy: workspace workflow.yaml (only if no workflow in config.yaml) + // Legacy: standalone workflow.json (only if no workflow section found) if (!workspaceConfig?.workflow) { - const legacyWorkflow = await readWorkflowYaml(projectsDir); + const legacyWorkflow = await readLegacyWorkflowJson(projectsDir); if (legacyWorkflow) { merged = mergeConfig(merged, { workflow: legacyWorkflow }); } } - // Layer 3: project config.yaml + // Layer 3: project workflow.yaml if (projectName) { const projectDir = path.join(projectsDir, projectName); - const projectConfig = await readConfigFile(projectDir); + const projectConfig = + await readWorkflowFile(projectDir) ?? + await readLegacyConfigFile(projectDir); if (projectConfig) { merged = mergeConfig(merged, projectConfig); } - // Legacy: project workflow.yaml if (!projectConfig?.workflow) { - const legacyWorkflow = await readWorkflowYaml(projectDir); + const legacyWorkflow = await readLegacyWorkflowJson(projectDir); if (legacyWorkflow) { merged = mergeConfig(merged, { workflow: legacyWorkflow }); } @@ -143,7 +147,17 @@ function resolve(config: DevClawConfig): ResolvedConfig { // File reading helpers // --------------------------------------------------------------------------- -async function readConfigFile(dir: string): Promise { +/** Read workflow.yaml (new primary config file). */ +async function readWorkflowFile(dir: string): Promise { + try { + const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); + return YAML.parse(content) as DevClawConfig; + } catch { /* not found */ } + return null; +} + +/** Read config.yaml (old name, fallback for unmigrated workspaces). */ +async function readLegacyConfigFile(dir: string): Promise { try { const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8"); return YAML.parse(content) as DevClawConfig; @@ -151,13 +165,8 @@ async function readConfigFile(dir: string): Promise { return null; } -async function readWorkflowYaml(dir: string): Promise | null> { - try { - const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); - return YAML.parse(content) as Partial; - } catch { /* not found */ } - - // Legacy JSON fallback +/** Read legacy workflow.json (standalone workflow section only). */ +async function readLegacyWorkflowJson(dir: string): Promise | null> { try { const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8"); const parsed = JSON.parse(content) as @@ -165,6 +174,5 @@ async function readWorkflowYaml(dir: string): Promise | | { workflow?: Partial }; return (parsed as any).workflow ?? parsed; } catch { /* not found */ } - return null; } diff --git a/lib/config/types.ts b/lib/config/types.ts index da48d31..35ab24f 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -1,13 +1,13 @@ /** * config/types.ts — Types for the unified DevClaw configuration. * - * A single config.yaml combines roles, models, and workflow. + * A single workflow.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. + * Role override in workflow.yaml. All fields optional — only override what you need. * Set to `false` to disable a role entirely for a project. */ export type RoleOverride = { @@ -19,7 +19,7 @@ export type RoleOverride = { }; /** - * The full config.yaml shape. + * The full workflow.yaml shape. * All fields optional — missing fields inherit from the layer below. */ export type DevClawConfig = { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 08bacce..c5644b6 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -151,7 +151,7 @@ export async function dispatchTask( const resolvedConfig = await loadConfig(workspaceDir, project.name); const resolvedRole = resolvedConfig.roles[role]; - const model = resolveModel(role, level, pluginConfig, resolvedRole); + const model = resolveModel(role, level, resolvedRole); const worker = getWorker(project, role); const existingSessionKey = getSessionForLevel(worker, level); const sessionAction = existingSessionKey ? "send" : "spawn"; diff --git a/lib/onboarding.ts b/lib/onboarding.ts index 5676eea..1604f0e 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -14,8 +14,8 @@ import { getAllDefaultModels } from "./roles/index.js"; export function isPluginConfigured( pluginConfig?: Record, ): boolean { - const models = (pluginConfig as { models?: Record })?.models; - return !!models && Object.keys(models).length > 0; + // Models moved to workflow.yaml — check for any devclaw plugin config (heartbeat, notifications, etc.) + return !!pluginConfig && Object.keys(pluginConfig).length > 0; } export async function hasWorkspaceFiles( @@ -37,30 +37,26 @@ export async function hasWorkspaceFiles( // Context templates // --------------------------------------------------------------------------- -function buildModelTable(pluginConfig?: Record): string { - const cfg = (pluginConfig as { models?: Record> })?.models; +function buildModelTable(): string { const lines: string[] = []; for (const [role, levels] of Object.entries(getAllDefaultModels())) { - for (const [level, defaultModel] of Object.entries(levels)) { - const model = cfg?.[role]?.[level] || defaultModel; - lines.push( - ` - **${role} ${level}**: ${model} (default: ${defaultModel})`, - ); + for (const [level, model] of Object.entries(levels)) { + lines.push(` - **${role} ${level}**: ${model}`); } } return lines.join("\n"); } -export function buildReconfigContext( - pluginConfig?: Record, -): string { - const modelTable = buildModelTable(pluginConfig); +export function buildReconfigContext(): string { + const modelTable = buildModelTable(); return `# DevClaw Reconfiguration -The user wants to reconfigure DevClaw. Current model configuration: +The user wants to reconfigure DevClaw. Default model configuration: ${modelTable} +Models are configured in \`devclaw/workflow.yaml\`. Edit that file directly or call \`setup\` with a \`models\` object to update. + ## What can be changed 1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change 2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files) diff --git a/lib/projects.test.ts b/lib/projects.test.ts index 4a9b471..4f5e0cc 100644 --- a/lib/projects.test.ts +++ b/lib/projects.test.ts @@ -95,8 +95,8 @@ describe("readProjects migration", () => { 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 dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); const newFormat = { projects: { @@ -114,7 +114,7 @@ describe("readProjects migration", () => { }, }, }; - await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(newFormat), "utf-8"); + await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(newFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; @@ -129,8 +129,8 @@ describe("readProjects migration", () => { 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 }); + const dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); // Workers map but with old role keys const mixedFormat = { @@ -149,7 +149,7 @@ describe("readProjects migration", () => { }, }, }; - await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8"); + await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; @@ -165,11 +165,7 @@ describe("readProjects migration", () => { }); 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 }); - + it("should return worker from workers map", () => { const data: ProjectsData = { projects: { "g1": { @@ -189,11 +185,9 @@ describe("getWorker", () => { 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 () => { + it("should return empty worker for unknown role", () => { const data: ProjectsData = { projects: { "g1": { @@ -217,8 +211,8 @@ describe("getWorker", () => { 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 dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); const data: ProjectsData = { projects: { diff --git a/lib/projects.ts b/lib/projects.ts index 5eb99d9..bc3d7d8 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -6,6 +6,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { homedir } from "node:os"; import { migrateProject } from "./migrations.js"; +import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js"; export type WorkerState = { active: boolean; @@ -66,10 +67,11 @@ export function getSessionForLevel( } function projectsPath(workspaceDir: string): string { - return path.join(workspaceDir, "projects", "projects.json"); + return path.join(workspaceDir, DATA_DIR, "projects.json"); } export async function readProjects(workspaceDir: string): Promise { + await ensureWorkspaceMigrated(workspaceDir); const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8"); const data = JSON.parse(raw) as ProjectsData; diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index b1cd9c9..b8a6536 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -140,9 +140,9 @@ describe("models", () => { assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5"); }); - it("should resolve from config override", () => { - const config = { models: { developer: { junior: "custom/model" } } }; - assert.strictEqual(resolveModel("developer", "junior", config), "custom/model"); + it("should resolve from resolved role config override", () => { + const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true }; + assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model"); }); it("should fall back to default", () => { @@ -153,23 +153,19 @@ describe("models", () => { assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6"); }); - it("should resolve old config keys via aliases", () => { - // Old config uses "mid" key — should still resolve via alias - const config = { models: { developer: { mid: "custom/old-config-model" } } }; - assert.strictEqual(resolveModel("developer", "mid", config), "custom/old-config-model"); - // Also works when requesting the canonical name - assert.strictEqual(resolveModel("developer", "medior", {}), "anthropic/claude-sonnet-4-5"); + it("should resolve via level aliases", () => { + // "mid" alias maps to "medior" — should resolve to default medior model + assert.strictEqual(resolveModel("developer", "mid"), "anthropic/claude-sonnet-4-5"); + // With explicit override in resolved config + const resolvedRole = { models: { medior: "custom/old-config-model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true }; + assert.strictEqual(resolveModel("developer", "mid", resolvedRole), "custom/old-config-model"); }); - it("should resolve old role name config keys", () => { - // Old config uses "dev" role key — should still resolve via role alias - const config = { models: { dev: { junior: "custom/model" } } }; - assert.strictEqual(resolveModel("developer", "junior", config), "custom/model"); - }); - - it("should resolve old qa config keys", () => { - const config = { models: { qa: { reviewer: "custom/qa-model" } } }; - assert.strictEqual(resolveModel("tester", "reviewer", config), "custom/qa-model"); + it("should resolve with resolved role overriding defaults selectively", () => { + const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true }; + assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model"); + // Levels not overridden fall through to registry defaults + assert.strictEqual(resolveModel("developer", "medior", resolvedRole), "anthropic/claude-sonnet-4-5"); }); }); diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index aa6191b..ed853b6 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -98,32 +98,21 @@ export function getAllDefaultModels(): Record> { * Resolve a level to a full model ID. * * Resolution order: - * 1. Plugin config `models..` in openclaw.json (highest precedence) - * 2. Resolved config from config.yaml (if provided) - * 3. Registry default model - * 4. Passthrough (treat level as raw model ID) + * 1. Resolved config from workflow.yaml (three-layer merge) + * 2. Registry default model + * 3. Passthrough (treat level as raw model ID) */ export function resolveModel( role: string, level: string, - pluginConfig?: Record, resolvedRole?: ResolvedRoleConfig, ): string { const canonical = _canonicalLevel(role, level); - // 1. Plugin config override (openclaw.json) — check canonical role + old aliases - const models = (pluginConfig as { models?: Record })?.models; - if (models && typeof models === "object") { - // Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer") - const roleModels = (models[role] ?? models[Object.entries(_ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record | undefined; - if (roleModels?.[canonical]) return roleModels[canonical]; - if (roleModels?.[level]) return roleModels[level]; - } - - // 2. Resolved config (config.yaml) + // 1. Resolved config (workflow.yaml — includes workspace + project overrides) if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical]; - // 3. Built-in registry default + // 2. Built-in registry default return getDefaultModel(role, canonical) ?? canonical; } diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index 94d45f9..7083d70 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -15,6 +15,7 @@ import fs from "node:fs"; import path from "node:path"; import { readProjects } from "../projects.js"; import { log as auditLog } from "../audit.js"; +import { DATA_DIR } from "../setup/migrate-layout.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js"; import { projectTick } from "./tick.js"; import { createProvider } from "../providers/index.js"; @@ -115,7 +116,7 @@ export function registerHeartbeatService(api: OpenClawPluginApi) { /** * Discover DevClaw agents by scanning which agent workspaces have projects. - * Self-discovering: any agent whose workspace contains projects/projects.json is processed. + * Self-discovering: any agent whose workspace contains projects.json is processed. * Also checks the default workspace (agents.defaults.workspace) for projects. */ function discoverAgents(config: { @@ -131,7 +132,7 @@ function discoverAgents(config: { for (const a of config.agents?.list || []) { if (!a.workspace) continue; try { - if (fs.existsSync(path.join(a.workspace, "projects", "projects.json"))) { + if (hasProjects(a.workspace)) { agents.push({ agentId: a.id, workspace: a.workspace }); seen.add(a.workspace); } @@ -142,7 +143,7 @@ function discoverAgents(config: { const defaultWorkspace = config.agents?.defaults?.workspace; if (defaultWorkspace && !seen.has(defaultWorkspace)) { try { - if (fs.existsSync(path.join(defaultWorkspace, "projects", "projects.json"))) { + if (hasProjects(defaultWorkspace)) { agents.push({ agentId: "main", workspace: defaultWorkspace }); } } catch { /* skip */ } @@ -151,6 +152,15 @@ function discoverAgents(config: { return agents; } +/** Check if a workspace has a projects.json (new or old locations). */ +function hasProjects(workspace: string): boolean { + return ( + fs.existsSync(path.join(workspace, DATA_DIR, "projects.json")) || + fs.existsSync(path.join(workspace, "projects.json")) || + fs.existsSync(path.join(workspace, "projects", "projects.json")) + ); +} + /** * Run one heartbeat tick for all agents. */ diff --git a/lib/setup/config.ts b/lib/setup/config.ts index ab74ae4..a112f87 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -1,37 +1,39 @@ /** * setup/config.ts — Plugin config writer (openclaw.json). * - * Handles: model level config, tool restrictions, subagent cleanup. + * Handles: tool restrictions, subagent cleanup, heartbeat defaults. + * Models are stored in workflow.yaml (not openclaw.json). */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; -type ModelConfig = Record>; - /** - * Write DevClaw model level config to openclaw.json plugins section. + * Write DevClaw plugin config to openclaw.json plugins section. * - * Also configures: + * Configures: * - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents * - Subagent cleanup interval (30 days) to keep development sessions alive + * - Heartbeat defaults * * Read-modify-write to preserve existing config. + * Note: models are NOT stored here — they live in workflow.yaml. */ export async function writePluginConfig( api: OpenClawPluginApi, - models: ModelConfig, agentId?: string, projectExecution?: "parallel" | "sequential", ): Promise { const config = api.runtime.config.loadConfig() as Record; ensurePluginStructure(config); - (config as any).plugins.entries.devclaw.config.models = models; if (projectExecution) { (config as any).plugins.entries.devclaw.config.projectExecution = projectExecution; } + // Clean up legacy models from openclaw.json (moved to workflow.yaml) + delete (config as any).plugins.entries.devclaw.config.models; + ensureHeartbeatDefaults(config); configureSubagentCleanup(config); diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 08a541a..4766585 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -1,15 +1,19 @@ /** * setup/index.ts — DevClaw setup orchestrator. * - * Coordinates: agent creation → model config → workspace scaffolding. + * Coordinates: agent creation → plugin config → workspace scaffolding → model config. * Used by both the `setup` tool and the `openclaw devclaw setup` CLI command. */ +import fs from "node:fs/promises"; +import path from "node:path"; +import YAML from "yaml"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { getAllDefaultModels } from "../roles/index.js"; import { migrateChannelBinding } from "../binding-manager.js"; import { createAgent, resolveWorkspacePath } from "./agent.js"; import { writePluginConfig } from "./config.js"; import { scaffoldWorkspace } from "./workspace.js"; +import { DATA_DIR } from "./migrate-layout.js"; export type ModelConfig = Record>; @@ -49,8 +53,9 @@ export type SetupResult = { * Run the full DevClaw setup. * * 1. Create agent (optional) or resolve existing workspace - * 2. Merge model config and write to openclaw.json - * 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory) + * 2. Write plugin config to openclaw.json (heartbeat, tool restrictions — no models) + * 3. Write workspace files (AGENTS.md, HEARTBEAT.md, workflow.yaml, prompts) + * 4. Write model config to workflow.yaml (single source of truth) */ export async function runSetup(opts: SetupOpts): Promise { const warnings: string[] = []; @@ -58,11 +63,13 @@ export async function runSetup(opts: SetupOpts): Promise { const { agentId, workspacePath, agentCreated, bindingMigrated } = await resolveOrCreateAgent(opts, warnings); - const models = buildModelConfig(opts.models); - await writePluginConfig(opts.api, models, agentId, opts.projectExecution); + await writePluginConfig(opts.api, agentId, opts.projectExecution); const filesWritten = await scaffoldWorkspace(workspacePath); + const models = buildModelConfig(opts.models); + await writeModelsToWorkflow(workspacePath, models); + return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated }; } @@ -131,3 +138,32 @@ function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { return result; } + +/** + * Write model configuration to workflow.yaml (single source of truth). + * Reads the existing workflow.yaml, merges model overrides into the roles section, and writes back. + */ +async function writeModelsToWorkflow(workspacePath: string, models: ModelConfig): Promise { + const workflowPath = path.join(workspacePath, DATA_DIR, "workflow.yaml"); + + let doc: Record = {}; + try { + const content = await fs.readFile(workflowPath, "utf-8"); + doc = (YAML.parse(content) as Record) ?? {}; + } catch { /* file doesn't exist yet — start fresh */ } + + // Merge models into roles section + if (!doc.roles) doc.roles = {}; + const roles = doc.roles as Record; + + for (const [role, levels] of Object.entries(models)) { + if (!roles[role] || roles[role] === false) { + roles[role] = { models: levels }; + } else { + const roleObj = roles[role] as Record; + roleObj.models = levels; + } + } + + await fs.writeFile(workflowPath, YAML.stringify(doc, { lineWidth: 120 }), "utf-8"); +} diff --git a/lib/setup/migrate-layout.test.ts b/lib/setup/migrate-layout.test.ts new file mode 100644 index 0000000..8c25fb7 --- /dev/null +++ b/lib/setup/migrate-layout.test.ts @@ -0,0 +1,312 @@ +/** + * Tests for workspace layout migration. + * Run with: npx tsx --test lib/setup/migrate-layout.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 { migrateWorkspaceLayout } from "./migrate-layout.js"; + +async function fileExists(p: string): Promise { + try { await fs.access(p); return true; } catch { return false; } +} + +describe("migrateWorkspaceLayout — very old layout → devclaw/", () => { + it("should move projects/projects.json to devclaw/projects.json", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}'); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be at devclaw/"); + assert.ok(!await fileExists(path.join(projDir, "projects.json")), "old projects.json should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should rename projects/config.yaml to devclaw/workflow.yaml", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projDir = path.join(tmpDir, "projects"); + await fs.mkdir(projDir, { recursive: true }); + await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(projDir, "config.yaml"), "roles:\n dev:\n defaultLevel: medior\n"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be at devclaw/"); + assert.ok(!await fileExists(path.join(projDir, "config.yaml")), "old config.yaml should be removed"); + const content = await fs.readFile(path.join(tmpDir, "devclaw", "workflow.yaml"), "utf-8"); + assert.ok(content.includes("defaultLevel: medior"), "content should be preserved"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should move roles/default/* to devclaw/prompts/ with renames", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projDir = path.join(tmpDir, "projects"); + const defaultDir = path.join(projDir, "roles", "default"); + await fs.mkdir(defaultDir, { recursive: true }); + await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(defaultDir, "dev.md"), "# Dev instructions"); + await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA instructions"); + await fs.writeFile(path.join(defaultDir, "architect.md"), "# Architect instructions"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md"); + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md"); + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "architect.md")), "architect.md should stay"); + + const devContent = await fs.readFile(path.join(tmpDir, "devclaw", "prompts", "developer.md"), "utf-8"); + assert.strictEqual(devContent, "# Dev instructions"); + + assert.ok(!await fileExists(defaultDir), "old default dir should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should move roles//* to devclaw/projects//prompts/ with renames", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projDir = path.join(tmpDir, "projects"); + const roleDir = path.join(projDir, "roles", "my-app"); + await fs.mkdir(roleDir, { recursive: true }); + await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(roleDir, "dev.md"), "# My App Developer"); + await fs.writeFile(path.join(roleDir, "qa.md"), "# My App Tester"); + await fs.writeFile(path.join(roleDir, "architect.md"), "# My App Architect"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md"); + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md"); + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "architect.md")), "architect.md should be in prompts/"); + + const content = await fs.readFile(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md"), "utf-8"); + assert.strictEqual(content, "# My App Developer"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should rename projects//config.yaml to devclaw/projects//workflow.yaml", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projDir = path.join(tmpDir, "projects"); + const appDir = path.join(projDir, "my-app"); + await fs.mkdir(appDir, { recursive: true }); + await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(appDir, "config.yaml"), "roles:\n dev:\n defaultLevel: senior\n"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should exist"); + assert.ok(!await fileExists(path.join(appDir, "config.yaml")), "old config.yaml should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should move log/ to devclaw/log/", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projDir = path.join(tmpDir, "projects"); + const logDir = path.join(tmpDir, "log"); + await fs.mkdir(projDir, { recursive: true }); + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}'); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/"); + assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old audit.log should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); + +describe("migrateWorkspaceLayout — intermediate layout → devclaw/", () => { + it("should move projects.json from root to devclaw/", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}'); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be in devclaw/"); + assert.ok(!await fileExists(path.join(tmpDir, "projects.json")), "root projects.json should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should move workflow.yaml from root to devclaw/", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(tmpDir, "workflow.yaml"), "roles:\n dev:\n defaultLevel: medior\n"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be in devclaw/"); + assert.ok(!await fileExists(path.join(tmpDir, "workflow.yaml")), "root workflow.yaml should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should move prompts/ from root to devclaw/prompts/", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const promptsDir = path.join(tmpDir, "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "developer.md should be in devclaw/prompts/"); + assert.ok(!await fileExists(path.join(promptsDir, "developer.md")), "old prompts/developer.md should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should move project .md files into prompts/ subdir", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projectDir = path.join(tmpDir, "projects", "my-app"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(projectDir, "developer.md"), "# My App Dev"); + await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "developer.md should be in prompts/ subdir"); + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should stay at project root"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should rename old role files (dev.md, qa.md) in prompts/", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const promptsDir = path.join(tmpDir, "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(promptsDir, "dev.md"), "# Old Dev"); + await fs.writeFile(path.join(promptsDir, "qa.md"), "# Old QA"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md"); + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should rename old role files in project prompts/", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const projectDir = path.join(tmpDir, "projects", "my-app"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(projectDir, "dev.md"), "# My App Dev"); + await fs.writeFile(path.join(projectDir, "qa.md"), "# My App QA"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md"); + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should move log/ from root to devclaw/log/", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const logDir = path.join(tmpDir, "log"); + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}'); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/"); + assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old log/audit.log should be removed"); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); + +describe("migrateWorkspaceLayout — flat project prompts → prompts/ subdir", () => { + it("should move flat .md files into prompts/ subdir", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const dataDir = path.join(tmpDir, "devclaw"); + const projectDir = path.join(dataDir, "projects", "my-app"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(projectDir, "developer.md"), "# Dev"); + await fs.writeFile(path.join(projectDir, "tester.md"), "# Tester"); + await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "developer.md should be in prompts/"); + assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "tester.md should be in prompts/"); + assert.ok(!await fileExists(path.join(projectDir, "developer.md")), "flat developer.md should be removed"); + assert.ok(await fileExists(path.join(projectDir, "workflow.yaml")), "workflow.yaml should stay"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should rename old role files during subdir migration", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const dataDir = path.join(tmpDir, "devclaw"); + const projectDir = path.join(dataDir, "projects", "my-app"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(projectDir, "dev.md"), "# Old Dev"); + await fs.writeFile(path.join(projectDir, "qa.md"), "# Old QA"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "dev.md should become prompts/developer.md"); + assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "qa.md should become prompts/tester.md"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should skip projects that already have prompts/ subdir", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const dataDir = path.join(tmpDir, "devclaw"); + const projectDir = path.join(dataDir, "projects", "my-app"); + const promptsDir = path.join(projectDir, "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(promptsDir, "developer.md"), "# Already migrated"); + + await migrateWorkspaceLayout(tmpDir); + + const content = await fs.readFile(path.join(promptsDir, "developer.md"), "utf-8"); + assert.strictEqual(content, "# Already migrated", "existing prompts/ should not be touched"); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); + +describe("migrateWorkspaceLayout — no-op cases", () => { + it("should no-op when already fully migrated", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + const dataDir = path.join(tmpDir, "devclaw"); + const promptsDir = path.join(dataDir, "projects", "app", "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}'); + await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev"); + + await migrateWorkspaceLayout(tmpDir); + + assert.ok(await fileExists(path.join(promptsDir, "developer.md")), "prompts should still exist"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should no-op when workspace is empty", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-")); + + await migrateWorkspaceLayout(tmpDir); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); diff --git a/lib/setup/migrate-layout.ts b/lib/setup/migrate-layout.ts new file mode 100644 index 0000000..1b03071 --- /dev/null +++ b/lib/setup/migrate-layout.ts @@ -0,0 +1,365 @@ +/** + * setup/migrate-layout.ts — One-time workspace layout migration. + * + * Migrates from old layouts to the current devclaw/ data directory: + * + * Very old layout (pre-restructure): + * projects/projects.json → devclaw/projects.json + * projects/config.yaml → devclaw/workflow.yaml + * projects/roles/default/* → devclaw/prompts/* (with dev.md→developer.md, qa.md→tester.md) + * projects/roles//* → devclaw/projects//prompts/* + * projects//config.yaml → devclaw/projects//workflow.yaml + * + * Intermediate layout (post-restructure, pre-devclaw/): + * projects.json → devclaw/projects.json + * workflow.yaml → devclaw/workflow.yaml + * prompts/* → devclaw/prompts/* + * projects//*.md → devclaw/projects//prompts/* + * projects//workflow.yaml→ devclaw/projects//workflow.yaml + * log/* → devclaw/log/* + * + * Flat project layout (early devclaw/ without prompts subdir): + * devclaw/projects//*.md → devclaw/projects//prompts/* + * + * This file can be removed once all workspaces have been migrated. + */ +import fs from "node:fs/promises"; +import path from "node:path"; + +/** Role file renames: old filename → new filename. */ +const ROLE_FILE_RENAMES: Record = { + "dev.md": "developer.md", + "qa.md": "tester.md", +}; + +/** The data directory name inside the workspace. */ +export const DATA_DIR = "devclaw"; + +/** Track which workspaces have been migrated this process. */ +const migrated = new Set(); + +/** + * Ensure a workspace has been migrated and default files exist (at most once per process). + * Safe to call from any code path — no-ops if already run this process. + */ +export async function ensureWorkspaceMigrated(workspaceDir: string): Promise { + if (migrated.has(workspaceDir)) return; + migrated.add(workspaceDir); + await migrateWorkspaceLayout(workspaceDir); + // Lazy import to avoid circular dependency (workspace.ts imports from this file) + const { ensureDefaultFiles } = await import("./workspace.js"); + await ensureDefaultFiles(workspaceDir); +} + +/** + * Migrate workspace from old layouts to new devclaw/ data directory. + * + * Detects four states: + * 1. Already migrated: devclaw/projects.json exists → check prompt subdir migration + * 2. Intermediate layout: projects.json at workspace root → move into devclaw/ + * 3. Very old layout: projects/projects.json → full migration into devclaw/ + * 4. Empty workspace → no-op + */ +export async function migrateWorkspaceLayout(workspaceDir: string): Promise { + const dataDir = path.join(workspaceDir, DATA_DIR); + const newProjectsJson = path.join(dataDir, "projects.json"); + + // Already migrated — but may need prompt subdir migration + if (await fileExists(newProjectsJson)) { + await migratePromptSubdirs(dataDir); + return; + } + + // Check for intermediate layout (post-restructure, pre-devclaw/) + const rootProjectsJson = path.join(workspaceDir, "projects.json"); + if (await fileExists(rootProjectsJson)) { + await migrateFromIntermediate(workspaceDir, dataDir); + return; + } + + // Check for very old layout (projects/projects.json) + const oldProjectsJson = path.join(workspaceDir, "projects", "projects.json"); + if (await fileExists(oldProjectsJson)) { + await migrateFromOldLayout(workspaceDir, dataDir); + return; + } +} + +/** + * Move flat prompt files in project dirs into prompts/ subdirs. + * Handles: devclaw/projects//.md → devclaw/projects//prompts/.md + */ +async function migratePromptSubdirs(dataDir: string): Promise { + const projectsDir = path.join(dataDir, "projects"); + if (!await dirExists(projectsDir)) return; + + const entries = await fs.readdir(projectsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const projectDir = path.join(projectsDir, entry.name); + + // Skip if already has prompts/ subdir + const promptsDir = path.join(projectDir, "prompts"); + if (await dirExists(promptsDir)) continue; + + // Check if there are .md files at project root + const files = await fs.readdir(projectDir); + const mdFiles = files.filter(f => f.endsWith(".md")); + if (mdFiles.length === 0) continue; + + // Move .md files into prompts/ subdir (with renames) + await fs.mkdir(promptsDir, { recursive: true }); + for (const file of mdFiles) { + const newName = ROLE_FILE_RENAMES[file] ?? file; + const dest = path.join(promptsDir, newName); + if (!await fileExists(dest)) { + await safeCopy(path.join(projectDir, file), dest); + } + await fs.unlink(path.join(projectDir, file)); + } + } +} + +/** + * Migrate from intermediate layout (files at workspace root) into devclaw/. + */ +async function migrateFromIntermediate(workspaceDir: string, dataDir: string): Promise { + await fs.mkdir(dataDir, { recursive: true }); + + // Move projects.json + await moveIfExists( + path.join(workspaceDir, "projects.json"), + path.join(dataDir, "projects.json"), + ); + + // Move workflow.yaml + await moveIfExists( + path.join(workspaceDir, "workflow.yaml"), + path.join(dataDir, "workflow.yaml"), + ); + + // Move prompts/ directory (with role file renames) + await moveDirWithRenames( + path.join(workspaceDir, "prompts"), + path.join(dataDir, "prompts"), + ); + + // Move projects/ directory — prompt files go into prompts/ subdir + await moveProjectDirs( + path.join(workspaceDir, "projects"), + path.join(dataDir, "projects"), + ); + + // Move log/ directory + await moveDirIfExists( + path.join(workspaceDir, "log"), + path.join(dataDir, "log"), + ); +} + +/** + * Migrate from very old layout (projects/projects.json) directly into devclaw/. + */ +async function migrateFromOldLayout(workspaceDir: string, dataDir: string): Promise { + await fs.mkdir(dataDir, { recursive: true }); + + // 1. Move projects/projects.json → devclaw/projects.json + const oldProjectsJson = path.join(workspaceDir, "projects", "projects.json"); + await safeCopy(oldProjectsJson, path.join(dataDir, "projects.json")); + await fs.unlink(oldProjectsJson); + + // 2. Move projects/config.yaml → devclaw/workflow.yaml + const oldConfig = path.join(workspaceDir, "projects", "config.yaml"); + const newConfig = path.join(dataDir, "workflow.yaml"); + if (await fileExists(oldConfig) && !await fileExists(newConfig)) { + await safeCopy(oldConfig, newConfig); + await fs.unlink(oldConfig); + } + + // 3. Move projects/roles/default/* → devclaw/prompts/* (with renames) + const oldDefaultsDir = path.join(workspaceDir, "projects", "roles", "default"); + const newPromptsDir = path.join(dataDir, "prompts"); + if (await dirExists(oldDefaultsDir)) { + await fs.mkdir(newPromptsDir, { recursive: true }); + const files = await fs.readdir(oldDefaultsDir); + for (const file of files) { + const newName = ROLE_FILE_RENAMES[file] ?? file; + const dest = path.join(newPromptsDir, newName); + if (!await fileExists(dest)) { + await safeCopy(path.join(oldDefaultsDir, file), dest); + } + await fs.unlink(path.join(oldDefaultsDir, file)); + } + await rmEmptyDir(oldDefaultsDir); + } + + // 4. Move projects/roles//* → devclaw/projects//prompts/* (with renames) + const oldRolesDir = path.join(workspaceDir, "projects", "roles"); + if (await dirExists(oldRolesDir)) { + const entries = await fs.readdir(oldRolesDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const projectName = entry.name; + const srcDir = path.join(oldRolesDir, projectName); + const destDir = path.join(dataDir, "projects", projectName, "prompts"); + await fs.mkdir(destDir, { recursive: true }); + + const roleFiles = await fs.readdir(srcDir); + for (const file of roleFiles) { + const newName = ROLE_FILE_RENAMES[file] ?? file; + const dest = path.join(destDir, newName); + if (!await fileExists(dest)) { + await safeCopy(path.join(srcDir, file), dest); + } + await fs.unlink(path.join(srcDir, file)); + } + await rmEmptyDir(srcDir); + } + await rmEmptyDir(oldRolesDir); + } + + // 5. Rename projects//config.yaml → devclaw/projects//workflow.yaml + const oldProjectsDir = path.join(workspaceDir, "projects"); + if (await dirExists(oldProjectsDir)) { + const entries = await fs.readdir(oldProjectsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const oldCfg = path.join(oldProjectsDir, entry.name, "config.yaml"); + const newCfg = path.join(dataDir, "projects", entry.name, "workflow.yaml"); + if (await fileExists(oldCfg) && !await fileExists(newCfg)) { + await safeCopy(oldCfg, newCfg); + await fs.unlink(oldCfg); + } + } + } + + // 6. Move log/ directory + await moveDirIfExists( + path.join(workspaceDir, "log"), + path.join(dataDir, "log"), + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function fileExists(p: string): Promise { + try { await fs.access(p); return true; } catch { return false; } +} + +async function dirExists(p: string): Promise { + try { + const stat = await fs.stat(p); + return stat.isDirectory(); + } catch { return false; } +} + +async function safeCopy(src: string, dest: string): Promise { + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.copyFile(src, dest); +} + +async function rmEmptyDir(dir: string): Promise { + try { + const entries = await fs.readdir(dir); + if (entries.length === 0) await fs.rmdir(dir); + } catch { /* ignore */ } +} + +/** Move a file if it exists and dest doesn't. */ +async function moveIfExists(src: string, dest: string): Promise { + if (await fileExists(src) && !await fileExists(dest)) { + await safeCopy(src, dest); + await fs.unlink(src); + } +} + +/** Move an entire directory's contents if it exists. */ +async function moveDirIfExists(srcDir: string, destDir: string): Promise { + if (!await dirExists(srcDir)) return; + await fs.mkdir(destDir, { recursive: true }); + const entries = await fs.readdir(srcDir, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(srcDir, entry.name); + const destPath = path.join(destDir, entry.name); + if (entry.isDirectory()) { + await moveDirIfExists(srcPath, destPath); + } else { + if (!await fileExists(destPath)) { + await safeCopy(srcPath, destPath); + } + await fs.unlink(srcPath); + } + } + await rmEmptyDir(srcDir); +} + +/** Move a directory, applying ROLE_FILE_RENAMES to files and recursing into subdirs. */ +async function moveDirWithRenames(srcDir: string, destDir: string): Promise { + if (!await dirExists(srcDir)) return; + await fs.mkdir(destDir, { recursive: true }); + const entries = await fs.readdir(srcDir, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(srcDir, entry.name); + if (entry.isDirectory()) { + await moveDirWithRenames(srcPath, path.join(destDir, entry.name)); + } else { + const newName = ROLE_FILE_RENAMES[entry.name] ?? entry.name; + const destPath = path.join(destDir, newName); + if (!await fileExists(destPath)) { + await safeCopy(srcPath, destPath); + } + await fs.unlink(srcPath); + } + } + await rmEmptyDir(srcDir); +} + +/** + * Move project directories: .md files go into prompts/ subdir (with renames), + * other files (workflow.yaml) stay at project root. + */ +async function moveProjectDirs(srcDir: string, destDir: string): Promise { + if (!await dirExists(srcDir)) return; + await fs.mkdir(destDir, { recursive: true }); + + const entries = await fs.readdir(srcDir, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(srcDir, entry.name); + if (entry.isDirectory()) { + // Each subdirectory is a project — move its contents with prompt separation + const destProjectDir = path.join(destDir, entry.name); + await fs.mkdir(destProjectDir, { recursive: true }); + + const projectFiles = await fs.readdir(srcPath); + for (const file of projectFiles) { + const fileSrc = path.join(srcPath, file); + if (file.endsWith(".md")) { + // Prompt file → prompts/ subdir (with renames) + const newName = ROLE_FILE_RENAMES[file] ?? file; + const promptsDest = path.join(destProjectDir, "prompts", newName); + if (!await fileExists(promptsDest)) { + await safeCopy(fileSrc, promptsDest); + } + } else { + // Config file → project root + const fileDest = path.join(destProjectDir, file); + if (!await fileExists(fileDest)) { + await safeCopy(fileSrc, fileDest); + } + } + await fs.unlink(fileSrc); + } + await rmEmptyDir(srcPath); + } else { + // Top-level file in projects/ dir — just move + const destPath = path.join(destDir, entry.name); + if (!await fileExists(destPath)) { + await safeCopy(srcPath, destPath); + } + await fs.unlink(srcPath); + } + } + await rmEmptyDir(srcDir); +} diff --git a/lib/setup/workspace.ts b/lib/setup/workspace.ts index ce86b61..dbbf651 100644 --- a/lib/setup/workspace.ts +++ b/lib/setup/workspace.ts @@ -1,66 +1,77 @@ /** * setup/workspace.ts — Workspace file scaffolding. * - * Writes AGENTS.md, HEARTBEAT.md, default role instructions, and projects.json. + * Writes AGENTS.md, HEARTBEAT.md, default role prompts, and projects.json. */ import fs from "node:fs/promises"; import path from "node:path"; import { AGENTS_MD_TEMPLATE, HEARTBEAT_MD_TEMPLATE, - DEFAULT_DEV_INSTRUCTIONS, - DEFAULT_QA_INSTRUCTIONS, - DEFAULT_ARCHITECT_INSTRUCTIONS, + WORKFLOW_YAML_TEMPLATE, + DEFAULT_ROLE_INSTRUCTIONS, } from "../templates.js"; +import { getAllRoleIds } from "../roles/index.js"; +import { migrateWorkspaceLayout, DATA_DIR } from "./migrate-layout.js"; + +/** + * Ensure default data files exist in the workspace. + * Only creates files that are missing — never overwrites existing ones. + * Called automatically after migration (via ensureWorkspaceMigrated). + */ +export async function ensureDefaultFiles(workspacePath: string): Promise { + const dataDir = path.join(workspacePath, DATA_DIR); + + // devclaw/workflow.yaml + const workflowPath = path.join(dataDir, "workflow.yaml"); + if (!await fileExists(workflowPath)) { + await fs.mkdir(dataDir, { recursive: true }); + await fs.writeFile(workflowPath, WORKFLOW_YAML_TEMPLATE, "utf-8"); + } + + // devclaw/projects.json + const projectsJsonPath = path.join(dataDir, "projects.json"); + if (!await fileExists(projectsJsonPath)) { + await fs.mkdir(dataDir, { recursive: true }); + await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8"); + } + + // devclaw/projects/ directory + await fs.mkdir(path.join(dataDir, "projects"), { recursive: true }); + + // devclaw/prompts/ — default role instructions + const promptsDir = path.join(dataDir, "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + for (const role of getAllRoleIds()) { + const rolePath = path.join(promptsDir, `${role}.md`); + if (!await fileExists(rolePath)) { + const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`; + await fs.writeFile(rolePath, content, "utf-8"); + } + } + + // devclaw/log/ directory (audit.log created on first write) + await fs.mkdir(path.join(dataDir, "log"), { recursive: true }); +} /** * Write all workspace files for a DevClaw agent. * Returns the list of files that were written (skips files that already exist). */ export async function scaffoldWorkspace(workspacePath: string): Promise { - const filesWritten: string[] = []; + // Migrate old layout if detected + await migrateWorkspaceLayout(workspacePath); - // AGENTS.md (backup existing) + // AGENTS.md (backup existing — stays at workspace root) await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE); - filesWritten.push("AGENTS.md"); - // HEARTBEAT.md + // HEARTBEAT.md (stays at workspace root) await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE); - filesWritten.push("HEARTBEAT.md"); - // projects/projects.json - const projectsDir = path.join(workspacePath, "projects"); - await fs.mkdir(projectsDir, { recursive: true }); - const projectsJsonPath = path.join(projectsDir, "projects.json"); - if (!await fileExists(projectsJsonPath)) { - await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8"); - filesWritten.push("projects/projects.json"); - } + // Ensure all data-dir defaults (workflow.yaml, prompts, etc.) + await ensureDefaultFiles(workspacePath); - // projects/roles/default/ (fallback role instructions) - const defaultRolesDir = path.join(projectsDir, "roles", "default"); - await fs.mkdir(defaultRolesDir, { recursive: true }); - const devRolePath = path.join(defaultRolesDir, "dev.md"); - if (!await fileExists(devRolePath)) { - await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8"); - filesWritten.push("projects/roles/default/dev.md"); - } - const qaRolePath = path.join(defaultRolesDir, "qa.md"); - if (!await fileExists(qaRolePath)) { - await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8"); - filesWritten.push("projects/roles/default/qa.md"); - } - const architectRolePath = path.join(defaultRolesDir, "architect.md"); - if (!await fileExists(architectRolePath)) { - await fs.writeFile(architectRolePath, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8"); - filesWritten.push("projects/roles/default/architect.md"); - } - - // log/ directory (audit.log created on first write) - const logDir = path.join(workspacePath, "log"); - await fs.mkdir(logDir, { recursive: true }); - - return filesWritten; + return ["AGENTS.md", "HEARTBEAT.md"]; } // --------------------------------------------------------------------------- diff --git a/lib/templates.ts b/lib/templates.ts index e15eaf9..8bf5d80 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -266,7 +266,7 @@ Workers call \`work_finish\` themselves — the label transition, state update, ### Prompt Instructions -Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/roles//.md\` in the workspace, falling back to \`projects/roles/default/.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project. +Workers receive role-specific instructions appended to their task message. These are loaded from \`devclaw/projects//prompts/.md\` in the workspace, falling back to \`devclaw/prompts/.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project. ### Heartbeats @@ -285,3 +285,109 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically. `; + +export const WORKFLOW_YAML_TEMPLATE = `# DevClaw workflow configuration +# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides. + +roles: + developer: + models: + junior: anthropic/claude-haiku-4-5 + medior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + tester: + models: + junior: anthropic/claude-haiku-4-5 + medior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + architect: + models: + junior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + # Disable a role entirely: + # architect: false + +workflow: + initial: planning + states: + planning: + type: hold + label: Planning + color: "#95a5a6" + on: + APPROVE: todo + todo: + type: queue + role: developer + label: To Do + color: "#428bca" + priority: 1 + on: + PICKUP: doing + doing: + type: active + role: developer + label: Doing + color: "#f0ad4e" + on: + COMPLETE: + target: toTest + actions: [gitPull, detectPr] + BLOCKED: refining + toTest: + type: queue + role: tester + label: To Test + color: "#5bc0de" + priority: 2 + on: + PICKUP: testing + testing: + type: active + role: tester + label: Testing + color: "#9b59b6" + on: + PASS: + target: done + actions: [closeIssue] + FAIL: + target: toImprove + actions: [reopenIssue] + REFINE: refining + BLOCKED: refining + toImprove: + type: queue + role: developer + label: To Improve + color: "#d9534f" + priority: 3 + on: + PICKUP: doing + refining: + type: hold + label: Refining + color: "#f39c12" + on: + APPROVE: todo + done: + type: terminal + label: Done + color: "#5cb85c" + toDesign: + type: queue + role: architect + label: To Design + color: "#0075ca" + priority: 1 + on: + PICKUP: designing + designing: + type: active + role: architect + label: Designing + color: "#d4c5f9" + on: + COMPLETE: planning + BLOCKED: refining +`; diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index e153634..be6491b 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -31,9 +31,9 @@ describe("architect tiers", () => { assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); - it("should resolve architect model from config", () => { - const config = { models: { architect: { senior: "custom/model" } } }; - assert.strictEqual(resolveModel("architect", "senior", config), "custom/model"); + it("should resolve architect model from resolved role config", () => { + const resolvedRole = { models: { senior: "custom/model" }, levels: ["junior", "senior"], defaultLevel: "junior", emoji: {}, completionResults: [] as string[], enabled: true }; + assert.strictEqual(resolveModel("architect", "senior", resolvedRole), "custom/model"); }); it("should have architect emoji", () => { diff --git a/lib/tools/design-task.ts b/lib/tools/design-task.ts index 0b1dcce..37ec7ca 100644 --- a/lib/tools/design-task.ts +++ b/lib/tools/design-task.ts @@ -14,6 +14,7 @@ import { dispatchTask } from "../dispatch.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js"; +import { loadConfig } from "../config/index.js"; import { selectLevel } from "../model-selector.js"; import { resolveModel } from "../roles/index.js"; @@ -123,7 +124,9 @@ Example: const level = complexity === "complex" ? selectLevel(title, "system-wide " + description, role).level : selectLevel(title, description, role).level; - const model = resolveModel(role, level, pluginConfig); + const resolvedConfig = await loadConfig(workspaceDir, project.name); + const resolvedRole = resolvedConfig.roles[role]; + const model = resolveModel(role, level, resolvedRole); if (dryRun) { return jsonResult({ diff --git a/lib/tools/onboard.ts b/lib/tools/onboard.ts index d6296f6..b451687 100644 --- a/lib/tools/onboard.ts +++ b/lib/tools/onboard.ts @@ -26,7 +26,7 @@ export function createOnboardTool(api: OpenClawPluginApi) { const mode = params.mode ? (params.mode as "first-run" | "reconfigure") : configured && hasWorkspace ? "reconfigure" : "first-run"; - const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(api.pluginConfig as Record); + const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(); return jsonResult({ success: true, mode, configured, instructions, diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index a3219bb..1d83d75 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -16,18 +16,19 @@ import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; +import { DATA_DIR } from "../setup/migrate-layout.js"; /** * Scaffold project-specific prompt files for all registered roles. * Returns true if files were created, false if they already existed. */ async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise { - const projectDir = path.join(workspaceDir, "projects", "roles", projectName); - await fs.mkdir(projectDir, { recursive: true }); + const promptsDir = path.join(workspaceDir, DATA_DIR, "projects", projectName, "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); let created = false; for (const role of getAllRoleIds()) { - const filePath = path.join(projectDir, `${role}.md`); + const filePath = path.join(promptsDir, `${role}.md`); try { await fs.access(filePath); } catch { diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index ff3ce4b..63ca82e 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -14,7 +14,7 @@ export function createSetupTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "setup", label: "Setup", - description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`, + description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, devclaw/projects.json, devclaw/prompts/, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`, parameters: { type: "object", properties: { From 371e760d94dcfc5812da03aae204136ceebc9579 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 13:27:14 +0800 Subject: [PATCH 06/13] feat: enhance workflow and testing infrastructure - Introduced ExecutionMode type for project execution modes (parallel, sequential). - Updated SetupOpts to use ExecutionMode instead of string literals. - Enhanced workflow states to include a new "In Review" state with appropriate transitions. - Implemented TestHarness for end-to-end testing, including command interception and workspace setup. - Created TestProvider for in-memory issue tracking during tests. - Refactored project registration and setup tools to utilize ExecutionMode. - Updated various tools to ensure compatibility with new workflow and execution modes. - Added new dependencies: cockatiel for resilience and zod for schema validation. --- lib/bootstrap-hook.ts | 68 ++- lib/config/index.ts | 3 + lib/config/loader.ts | 35 +- lib/config/merge.ts | 5 + lib/config/schema.ts | 114 +++++ lib/config/types.ts | 25 + lib/dispatch.ts | 40 +- lib/projects.ts | 87 +++- lib/providers/github.ts | 31 +- lib/providers/gitlab.ts | 31 +- lib/providers/provider.ts | 15 + lib/providers/resilience.ts | 49 ++ lib/roles/registry.test.ts | 3 +- lib/roles/registry.ts | 2 +- lib/services/bootstrap.e2e.test.ts | 253 ++++++++++ lib/services/health.ts | 9 +- lib/services/heartbeat.ts | 103 ++-- lib/services/pipeline.e2e.test.ts | 747 +++++++++++++++++++++++++++++ lib/services/pipeline.ts | 50 +- lib/services/queue-scan.ts | 88 ++++ lib/services/queue.ts | 3 +- lib/services/review.ts | 98 ++++ lib/services/tick.ts | 79 +-- lib/setup/config.ts | 3 +- lib/setup/index.ts | 3 +- lib/templates.ts | 37 +- lib/testing/harness.ts | 292 +++++++++++ lib/testing/index.ts | 16 + lib/testing/test-provider.ts | 224 +++++++++ lib/tools/project-register.ts | 5 +- lib/tools/setup.ts | 6 +- lib/tools/status.ts | 6 +- lib/tools/task-update.test.ts | 5 +- lib/tools/work-start.ts | 6 +- lib/workflow.ts | 148 ++++-- package-lock.json | 14 +- package.json | 4 +- 37 files changed, 2444 insertions(+), 263 deletions(-) create mode 100644 lib/config/schema.ts create mode 100644 lib/providers/resilience.ts create mode 100644 lib/services/bootstrap.e2e.test.ts create mode 100644 lib/services/pipeline.e2e.test.ts create mode 100644 lib/services/queue-scan.ts create mode 100644 lib/services/review.ts create mode 100644 lib/testing/harness.ts create mode 100644 lib/testing/index.ts create mode 100644 lib/testing/test-provider.ts diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index d9f2806..57943dc 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -33,36 +33,61 @@ export function parseDevClawSessionKey( return { projectName: match[1], role: match[2] }; } +/** + * Result of loading role instructions — includes the source for traceability. + */ +export type RoleInstructionsResult = { + content: string; + /** Which file the instructions were loaded from, or null if none found. */ + source: string | null; +}; + /** * Load role-specific instructions from workspace. * Tries project-specific file first, then falls back to default. + * Returns both the content and the source path for logging/traceability. * - * This is the same logic previously in dispatch.ts loadRoleInstructions(), - * now called from the bootstrap hook instead of during dispatch. + * Resolution order: + * 1. devclaw/projects//prompts/.md (project-specific) + * 2. projects/roles//.md (old project-specific) + * 3. devclaw/prompts/.md (workspace default) + * 4. projects/roles/default/.md (old default) */ export async function loadRoleInstructions( workspaceDir: string, projectName: string, role: string, -): Promise { +): Promise; +export async function loadRoleInstructions( + workspaceDir: string, + projectName: string, + role: string, + opts: { withSource: true }, +): Promise; +export async function loadRoleInstructions( + workspaceDir: string, + projectName: string, + role: string, + opts?: { withSource: true }, +): Promise { const dataDir = path.join(workspaceDir, DATA_DIR); - // Project-specific: devclaw/projects//prompts/.md - const projectFile = path.join(dataDir, "projects", projectName, "prompts", `${role}.md`); - try { return await fs.readFile(projectFile, "utf-8"); } catch { /* not found */ } + const candidates = [ + path.join(dataDir, "projects", projectName, "prompts", `${role}.md`), + path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`), + path.join(dataDir, "prompts", `${role}.md`), + path.join(workspaceDir, "projects", "roles", "default", `${role}.md`), + ]; - // Fallback old path: projects/roles//.md - const oldProjectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); - try { return await fs.readFile(oldProjectFile, "utf-8"); } catch { /* not found */ } - - // Default: devclaw/prompts/.md - const defaultFile = path.join(dataDir, "prompts", `${role}.md`); - try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* not found */ } - - // Fallback old default: projects/roles/default/.md - const oldDefaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`); - try { return await fs.readFile(oldDefaultFile, "utf-8"); } catch { /* not found */ } + for (const filePath of candidates) { + try { + const content = await fs.readFile(filePath, "utf-8"); + if (opts?.withSource) return { content, source: filePath }; + return content; + } catch { /* not found, try next */ } + } + if (opts?.withSource) return { content: "", source: null }; return ""; } @@ -102,25 +127,26 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void { const bootstrapFiles = context.bootstrapFiles; if (!Array.isArray(bootstrapFiles)) return; - const instructions = await loadRoleInstructions( + const { content, source } = await loadRoleInstructions( workspaceDir, parsed.projectName, parsed.role, + { withSource: true }, ); - if (!instructions) return; + if (!content) return; // Inject as a virtual bootstrap file. OpenClaw includes these in the // agent's system prompt automatically (via buildBootstrapContextFiles). bootstrapFiles.push({ name: "WORKER_INSTRUCTIONS.md" as any, path: ``, - content: instructions.trim(), + content: content.trim(), missing: false, }); api.logger.info( - `Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`, + `Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}" from ${source}`, ); }); } diff --git a/lib/config/index.ts b/lib/config/index.ts index 94345be..f4f4257 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -8,7 +8,10 @@ export type { RoleOverride, ResolvedConfig, ResolvedRoleConfig, + ResolvedTimeouts, + TimeoutConfig, } from "./types.js"; export { loadConfig } from "./loader.js"; export { mergeConfig } from "./merge.js"; +export { validateConfig, validateWorkflowIntegrity } from "./schema.js"; diff --git a/lib/config/loader.ts b/lib/config/loader.ts index 60eabc2..f2d356f 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -14,7 +14,8 @@ 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"; +import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, ResolvedTimeouts, RoleOverride } from "./types.js"; +import { validateConfig, validateWorkflowIntegrity } from "./schema.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; /** @@ -140,20 +141,42 @@ function resolve(config: DevClawConfig): ResolvedConfig { states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states }, }; - return { roles, workflow }; + // Validate structural integrity (cross-references between states) + const integrityErrors = validateWorkflowIntegrity(workflow); + if (integrityErrors.length > 0) { + throw new Error(`Workflow config integrity errors:\n - ${integrityErrors.join("\n - ")}`); + } + + const timeouts: ResolvedTimeouts = { + gitPullMs: config.timeouts?.gitPullMs ?? 30_000, + gatewayMs: config.timeouts?.gatewayMs ?? 15_000, + sessionPatchMs: config.timeouts?.sessionPatchMs ?? 30_000, + dispatchMs: config.timeouts?.dispatchMs ?? 600_000, + staleWorkerHours: config.timeouts?.staleWorkerHours ?? 2, + }; + + return { roles, workflow, timeouts }; } // --------------------------------------------------------------------------- // File reading helpers // --------------------------------------------------------------------------- -/** Read workflow.yaml (new primary config file). */ +/** Read workflow.yaml (new primary config file). Validates structure via Zod. */ async function readWorkflowFile(dir: string): Promise { try { const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); - return YAML.parse(content) as DevClawConfig; - } catch { /* not found */ } - return null; + const parsed = YAML.parse(content); + if (parsed) validateConfig(parsed); + return parsed as DevClawConfig; + } catch (err: any) { + if (err?.code === "ENOENT") return null; + // Re-throw validation errors with file context + if (err?.name === "ZodError") { + throw new Error(`Invalid workflow.yaml in ${dir}: ${err.message}`); + } + return null; + } } /** Read config.yaml (old name, fallback for unmigrated workspaces). */ diff --git a/lib/config/merge.ts b/lib/config/merge.ts index 5fad7bb..8a2a090 100644 --- a/lib/config/merge.ts +++ b/lib/config/merge.ts @@ -57,6 +57,11 @@ export function mergeConfig( } } + // Merge timeouts + if (base.timeouts || overlay.timeouts) { + merged.timeouts = { ...base.timeouts, ...overlay.timeouts }; + } + return merged; } diff --git a/lib/config/schema.ts b/lib/config/schema.ts new file mode 100644 index 0000000..aa6449d --- /dev/null +++ b/lib/config/schema.ts @@ -0,0 +1,114 @@ +/** + * config/schema.ts — Zod validation for DevClaw workflow config. + * + * Validates workflow YAML at load time with clear error messages. + * Enforces: transition targets exist, queue states have roles, + * terminal states have no outgoing transitions. + */ +import { z } from "zod"; +import { StateType } from "../workflow.js"; + +const STATE_TYPES = Object.values(StateType) as [string, ...string[]]; + +const TransitionTargetSchema = z.union([ + z.string(), + z.object({ + target: z.string(), + actions: z.array(z.string()).optional(), + description: z.string().optional(), + }), +]); + +const StateConfigSchema = z.object({ + type: z.enum(STATE_TYPES), + role: z.string().optional(), + label: z.string(), + color: z.string(), + priority: z.number().optional(), + description: z.string().optional(), + check: z.string().optional(), + on: z.record(z.string(), TransitionTargetSchema).optional(), +}); + +const WorkflowConfigSchema = z.object({ + initial: z.string(), + states: z.record(z.string(), StateConfigSchema), +}); + +const RoleOverrideSchema = z.union([ + z.literal(false), + z.object({ + levels: z.array(z.string()).optional(), + defaultLevel: z.string().optional(), + models: z.record(z.string(), z.string()).optional(), + emoji: z.record(z.string(), z.string()).optional(), + completionResults: z.array(z.string()).optional(), + }), +]); + +const TimeoutConfigSchema = z.object({ + gitPullMs: z.number().positive().optional(), + gatewayMs: z.number().positive().optional(), + sessionPatchMs: z.number().positive().optional(), + dispatchMs: z.number().positive().optional(), + staleWorkerHours: z.number().positive().optional(), +}).optional(); + +export const DevClawConfigSchema = z.object({ + roles: z.record(z.string(), RoleOverrideSchema).optional(), + workflow: WorkflowConfigSchema.partial().optional(), + timeouts: TimeoutConfigSchema, +}); + +/** + * Validate a raw parsed config object. + * Returns the validated config or throws with a descriptive error. + */ +export function validateConfig(raw: unknown): void { + DevClawConfigSchema.parse(raw); +} + +/** + * Validate structural integrity of a fully-resolved workflow config. + * Checks cross-references that Zod schema alone can't enforce: + * - All transition targets point to existing states + * - Queue states have a role assigned + * - Terminal states have no outgoing transitions + */ +export function validateWorkflowIntegrity( + workflow: { initial: string; states: Record }> }, +): string[] { + const errors: string[] = []; + const stateKeys = new Set(Object.keys(workflow.states)); + + if (!stateKeys.has(workflow.initial)) { + errors.push(`Initial state "${workflow.initial}" does not exist in states`); + } + + for (const [key, state] of Object.entries(workflow.states)) { + if (state.type === StateType.QUEUE && !state.role) { + errors.push(`Queue state "${key}" must have a role assigned`); + } + + if (state.type === StateType.ACTIVE && !state.role) { + errors.push(`Active state "${key}" must have a role assigned`); + } + + if (state.type === StateType.TERMINAL && state.on && Object.keys(state.on).length > 0) { + errors.push(`Terminal state "${key}" should not have outgoing transitions`); + } + + if (state.on) { + for (const [event, transition] of Object.entries(state.on)) { + const target = typeof transition === "string" + ? transition + : (transition as { target: string }).target; + if (!stateKeys.has(target)) { + errors.push(`State "${key}" transition "${event}" targets non-existent state "${target}"`); + } + } + } + } + + return errors; +} diff --git a/lib/config/types.ts b/lib/config/types.ts index 35ab24f..4b48cd3 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -18,6 +18,18 @@ export type RoleOverride = { completionResults?: string[]; }; +/** + * Configurable timeout values (in milliseconds). + * All fields optional — defaults applied at resolution time. + */ +export type TimeoutConfig = { + gitPullMs?: number; + gatewayMs?: number; + sessionPatchMs?: number; + dispatchMs?: number; + staleWorkerHours?: number; +}; + /** * The full workflow.yaml shape. * All fields optional — missing fields inherit from the layer below. @@ -25,6 +37,18 @@ export type RoleOverride = { export type DevClawConfig = { roles?: Record; workflow?: Partial; + timeouts?: TimeoutConfig; +}; + +/** + * Fully resolved timeout config — all fields present with defaults. + */ +export type ResolvedTimeouts = { + gitPullMs: number; + gatewayMs: number; + sessionPatchMs: number; + dispatchMs: number; + staleWorkerHours: number; }; /** @@ -34,6 +58,7 @@ export type DevClawConfig = { export type ResolvedConfig = { roles: Record; workflow: WorkflowConfig; + timeouts: ResolvedTimeouts; }; /** diff --git a/lib/dispatch.ts b/lib/dispatch.ts index c5644b6..6aa94c3 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -151,6 +151,7 @@ export async function dispatchTask( const resolvedConfig = await loadConfig(workspaceDir, project.name); const resolvedRole = resolvedConfig.roles[role]; + const { timeouts } = resolvedConfig; const model = resolveModel(role, level, resolvedRole); const worker = getWorker(project, role); const existingSessionKey = getSessionForLevel(worker, level); @@ -194,16 +195,22 @@ export async function dispatchTask( channel: opts.channel ?? "telegram", runtime, }, - ).catch(() => { /* non-fatal */ }); + ).catch((err) => { + auditLog(workspaceDir, "dispatch_warning", { + step: "notify", issue: issueId, role, + error: (err as Error).message ?? String(err), + }).catch(() => {}); + }); // Step 3: Ensure session exists (fire-and-forget — don't wait for gateway) // Session key is deterministic, so we can proceed immediately - ensureSessionFireAndForget(sessionKey, model); + ensureSessionFireAndForget(sessionKey, model, workspaceDir, timeouts.sessionPatchMs); // Step 4: Send task to agent (fire-and-forget) sendToAgent(sessionKey, taskMessage, { - agentId, projectName: project.name, issueId, role, - orchestratorSessionKey: opts.sessionKey, + agentId, projectName: project.name, issueId, role, level, + orchestratorSessionKey: opts.sessionKey, workspaceDir, + dispatchTimeoutMs: timeouts.dispatchMs, }); // Step 5: Update worker state @@ -241,19 +248,24 @@ export async function dispatchTask( * Session key is deterministic, so we don't need to wait for confirmation. * If this fails, health check will catch orphaned state later. */ -function ensureSessionFireAndForget(sessionKey: string, model: string): void { +function ensureSessionFireAndForget(sessionKey: string, model: string, workspaceDir: string, timeoutMs = 30_000): void { runCommand( ["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })], - { timeoutMs: 30_000 }, - ).catch(() => { /* fire-and-forget */ }); + { timeoutMs }, + ).catch((err) => { + auditLog(workspaceDir, "dispatch_warning", { + step: "ensureSession", sessionKey, + error: (err as Error).message ?? String(err), + }).catch(() => {}); + }); } function sendToAgent( sessionKey: string, taskMessage: string, - opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string }, + opts: { agentId?: string; projectName: string; issueId: number; role: string; level?: string; orchestratorSessionKey?: string; workspaceDir: string; dispatchTimeoutMs?: number }, ): void { const gatewayParams = JSON.stringify({ - idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`, + idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${sessionKey}`, agentId: opts.agentId ?? "devclaw", sessionKey, message: taskMessage, @@ -264,8 +276,14 @@ function sendToAgent( // Fire-and-forget: long-running agent turn, don't await runCommand( ["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"], - { timeoutMs: 600_000 }, - ).catch(() => { /* fire-and-forget */ }); + { timeoutMs: opts.dispatchTimeoutMs ?? 600_000 }, + ).catch((err) => { + auditLog(opts.workspaceDir, "dispatch_warning", { + step: "sendToAgent", sessionKey, + issue: opts.issueId, role: opts.role, + error: (err as Error).message ?? String(err), + }).catch(() => {}); + }); } async function recordWorkerState( diff --git a/lib/projects.ts b/lib/projects.ts index bc3d7d8..02aeefd 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -1,12 +1,61 @@ /** * Atomic projects.json read/write operations. * All state mutations go through this module to prevent corruption. + * + * Uses file-level locking to prevent concurrent read-modify-write races. */ import fs from "node:fs/promises"; import path from "node:path"; import { homedir } from "node:os"; import { migrateProject } from "./migrations.js"; import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js"; +import type { ExecutionMode } from "./workflow.js"; + +// --------------------------------------------------------------------------- +// File locking — prevents concurrent read-modify-write races +// --------------------------------------------------------------------------- + +const LOCK_STALE_MS = 30_000; +const LOCK_RETRY_MS = 50; +const LOCK_TIMEOUT_MS = 10_000; + +function lockPath(workspaceDir: string): string { + return projectsPath(workspaceDir) + ".lock"; +} + +async function acquireLock(workspaceDir: string): Promise { + const lock = lockPath(workspaceDir); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + while (Date.now() < deadline) { + try { + await fs.writeFile(lock, String(Date.now()), { flag: "wx" }); + return; + } catch (err: any) { + if (err.code !== "EEXIST") throw err; + + // Check for stale lock + try { + const content = await fs.readFile(lock, "utf-8"); + const lockTime = Number(content); + if (Date.now() - lockTime > LOCK_STALE_MS) { + try { await fs.unlink(lock); } catch { /* race */ } + continue; + } + } catch { /* lock disappeared — retry */ } + + await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); + } + } + + // Last resort: force remove potentially stale lock + try { await fs.unlink(lockPath(workspaceDir)); } catch { /* ignore */ } + await fs.writeFile(lock, String(Date.now()), { flag: "wx" }); +} + +async function releaseLock(workspaceDir: string): Promise { + try { await fs.unlink(lockPath(workspaceDir)); } catch { /* already removed */ } +} export type WorkerState = { active: boolean; @@ -28,7 +77,7 @@ export type Project = { /** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */ provider?: "github" | "gitlab"; /** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */ - roleExecution?: "parallel" | "sequential"; + roleExecution?: ExecutionMode; maxDevWorkers?: number; maxQaWorkers?: number; /** Worker state per role (developer, tester, architect, or custom roles). */ @@ -109,6 +158,7 @@ export function getWorker( /** * Update worker state for a project. Only provided fields are updated. * Sessions are merged (not replaced) when both existing and new sessions are present. + * Uses file locking to prevent concurrent read-modify-write races. */ export async function updateWorker( workspaceDir: string, @@ -116,22 +166,27 @@ export async function updateWorker( role: string, updates: Partial, ): Promise { - const data = await readProjects(workspaceDir); - const project = data.projects[groupId]; - if (!project) { - throw new Error(`Project not found for groupId: ${groupId}`); + await acquireLock(workspaceDir); + try { + const data = await readProjects(workspaceDir); + const project = data.projects[groupId]; + if (!project) { + throw new Error(`Project not found for groupId: ${groupId}`); + } + + const worker = project.workers[role] ?? emptyWorkerState([]); + + if (updates.sessions && worker.sessions) { + updates.sessions = { ...worker.sessions, ...updates.sessions }; + } + + project.workers[role] = { ...worker, ...updates }; + + await writeProjects(workspaceDir, data); + return data; + } finally { + await releaseLock(workspaceDir); } - - const worker = project.workers[role] ?? emptyWorkerState([]); - - if (updates.sessions && worker.sessions) { - updates.sessions = { ...worker.sessions, ...updates.sessions }; - } - - project.workers[role] = { ...worker, ...updates }; - - await writeProjects(workspaceDir, data); - return data; } /** diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 6166b76..7677476 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -6,8 +6,11 @@ import { type Issue, type StateLabel, type IssueComment, + type PrStatus, + PrState, } from "./provider.js"; import { runCommand } from "../run-command.js"; +import { withResilience } from "./resilience.js"; import { DEFAULT_WORKFLOW, getStateLabels, @@ -41,8 +44,10 @@ export class GitHubProvider implements IssueProvider { } private async gh(args: string[]): Promise { - const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); - return result.stdout.trim(); + return withResilience(async () => { + const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); + return result.stdout.trim(); + }); } async ensureLabel(name: string, color: string): Promise { @@ -125,6 +130,28 @@ export class GitHubProvider implements IssueProvider { } catch { return null; } } + async getPrStatus(issueId: number): Promise { + const pat = `#${issueId}`; + // Check open PRs first + try { + const raw = await this.gh(["pr", "list", "--state", "open", "--json", "title,body,url,reviewDecision", "--limit", "20"]); + const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string; reviewDecision: string }>; + const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); + if (pr) { + const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN; + return { state, url: pr.url }; + } + } catch { /* continue to merged check */ } + // Check merged PRs + try { + const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body,url", "--limit", "20"]); + const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string }>; + const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); + if (pr) return { state: PrState.MERGED, url: pr.url }; + } catch { /* ignore */ } + return { state: PrState.CLOSED, url: null }; + } + async addComment(issueId: number, body: string): Promise { await this.gh(["issue", "comment", String(issueId), "--body", body]); } diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index fc59466..603a7dc 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -6,8 +6,11 @@ import { type Issue, type StateLabel, type IssueComment, + type PrStatus, + PrState, } from "./provider.js"; import { runCommand } from "../run-command.js"; +import { withResilience } from "./resilience.js"; import { DEFAULT_WORKFLOW, getStateLabels, @@ -25,8 +28,10 @@ export class GitLabProvider implements IssueProvider { } private async glab(args: string[]): Promise { - const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); - return result.stdout.trim(); + return withResilience(async () => { + const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); + return result.stdout.trim(); + }); } async ensureLabel(name: string, color: string): Promise { @@ -122,6 +127,28 @@ export class GitLabProvider implements IssueProvider { } catch { return null; } } + async getPrStatus(issueId: number): Promise { + const pat = `#${issueId}`; + // Check open MRs first + try { + const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); + const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string; approved_by?: Array }>; + const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); + if (mr) { + const state = mr.approved_by && mr.approved_by.length > 0 ? PrState.APPROVED : PrState.OPEN; + return { state, url: mr.web_url }; + } + } catch { /* continue to merged check */ } + // Check merged MRs + try { + const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); + const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string }>; + const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); + if (mr) return { state: PrState.MERGED, url: mr.web_url }; + } catch { /* ignore */ } + return { state: PrState.CLOSED, url: null }; + } + async addComment(issueId: number, body: string): Promise { // Pass message directly as argv — no shell escaping needed with spawn await this.glab(["issue", "note", String(issueId), "--message", body]); diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index 9eedb74..d404371 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -28,6 +28,20 @@ export type IssueComment = { created_at: string; }; +/** Built-in PR states. */ +export const PrState = { + OPEN: "open", + APPROVED: "approved", + MERGED: "merged", + CLOSED: "closed", +} as const; +export type PrState = (typeof PrState)[keyof typeof PrState]; + +export type PrStatus = { + state: PrState; + url: string | null; +}; + // --------------------------------------------------------------------------- // Provider interface // --------------------------------------------------------------------------- @@ -46,6 +60,7 @@ export interface IssueProvider { getCurrentStateLabel(issue: Issue): StateLabel | null; hasMergedMR(issueId: number): Promise; getMergedMRUrl(issueId: number): Promise; + getPrStatus(issueId: number): Promise; addComment(issueId: number, body: string): Promise; healthCheck(): Promise; } diff --git a/lib/providers/resilience.ts b/lib/providers/resilience.ts new file mode 100644 index 0000000..52e75f5 --- /dev/null +++ b/lib/providers/resilience.ts @@ -0,0 +1,49 @@ +/** + * providers/resilience.ts — Retry and circuit breaker policies for provider calls. + * + * Uses cockatiel for lightweight resilience without heavyweight orchestration. + * Applied to GitHub/GitLab CLI calls that can fail due to network, rate limits, or timeouts. + */ +import { + ExponentialBackoff, + retry, + circuitBreaker, + ConsecutiveBreaker, + handleAll, + wrap, + type IPolicy, +} from "cockatiel"; + +/** + * Default retry policy: 3 attempts with exponential backoff. + * Handles all errors (network, timeout, CLI failure). + */ +const retryPolicy = retry(handleAll, { + maxAttempts: 3, + backoff: new ExponentialBackoff({ + initialDelay: 500, + maxDelay: 5_000, + }), +}); + +/** + * Circuit breaker: opens after 5 consecutive failures, half-opens after 30s. + * Prevents hammering a provider that's down. + */ +const breakerPolicy = circuitBreaker(handleAll, { + halfOpenAfter: 30_000, + breaker: new ConsecutiveBreaker(5), +}); + +/** + * Combined policy: circuit breaker wrapping retry. + * If circuit is open, calls fail fast without retrying. + */ +export const providerPolicy: IPolicy = wrap(breakerPolicy, retryPolicy); + +/** + * Execute a provider call with retry + circuit breaker. + */ +export function withResilience(fn: () => Promise): Promise { + return providerPolicy.execute(() => fn()); +} diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index b8a6536..f1eb542 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -185,13 +185,14 @@ describe("emoji", () => { describe("completion results", () => { it("should return valid results per role", () => { - assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "review", "blocked"]); assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); }); it("should validate results", () => { assert.strictEqual(isValidResult("developer", "done"), true); + assert.strictEqual(isValidResult("developer", "review"), true); assert.strictEqual(isValidResult("developer", "pass"), false); assert.strictEqual(isValidResult("tester", "pass"), true); assert.strictEqual(isValidResult("tester", "done"), false); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index cd1fbee..7e016d4 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -30,7 +30,7 @@ export const ROLE_REGISTRY: Record = { senior: "🧠", }, fallbackEmoji: "🔧", - completionResults: ["done", "blocked"], + completionResults: ["done", "review", "blocked"], sessionKeyPattern: "developer", notifications: { onStart: true, onComplete: true }, }, diff --git a/lib/services/bootstrap.e2e.test.ts b/lib/services/bootstrap.e2e.test.ts new file mode 100644 index 0000000..0015109 --- /dev/null +++ b/lib/services/bootstrap.e2e.test.ts @@ -0,0 +1,253 @@ +/** + * E2E bootstrap tests — verifies the full bootstrap hook chain: + * dispatchTask() → session key → registerBootstrapHook fires → bootstrapFiles injected + * + * Uses simulateBootstrap() which registers the real hook with a mock API, + * fires it with the session key from dispatch, and returns the resulting + * bootstrapFiles array — proving instructions actually reach the worker. + * + * Run: npx tsx --test lib/services/bootstrap.e2e.test.ts + */ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { dispatchTask } from "../dispatch.js"; + +describe("E2E bootstrap — hook injection", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + it("should inject project-specific instructions into bootstrapFiles", async () => { + h = await createTestHarness({ projectName: "my-app" }); + h.provider.seedIssue({ iid: 1, title: "Add feature", labels: ["To Do"] }); + + // Write both default and project-specific prompts + await h.writePrompt("developer", "# Default Developer\nGeneric instructions."); + await h.writePrompt("developer", "# My App Developer\nUse React. Follow our design system.", "my-app"); + + // Dispatch to get the session key + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 1, + issueTitle: "Add feature", + issueDescription: "", + issueUrl: "https://example.com/issues/1", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // Fire the actual bootstrap hook with the dispatch session key + const files = await h.simulateBootstrap(result.sessionKey); + + // Should have exactly one injected file + assert.strictEqual(files.length, 1, `Expected 1 bootstrap file, got ${files.length}`); + assert.strictEqual(files[0].name, "WORKER_INSTRUCTIONS.md"); + assert.strictEqual(files[0].missing, false); + assert.ok(files[0].path.includes("my-app"), `Path should reference project: ${files[0].path}`); + assert.ok(files[0].path.includes("developer"), `Path should reference role: ${files[0].path}`); + + // Content should be project-specific, NOT default + const content = files[0].content!; + assert.ok(content.includes("My App Developer"), `Got: ${content}`); + assert.ok(content.includes("Use React")); + assert.ok(!content.includes("Generic instructions")); + }); + + it("should fall back to default instructions when no project override exists", async () => { + h = await createTestHarness({ projectName: "other-app" }); + h.provider.seedIssue({ iid: 2, title: "Fix bug", labels: ["To Do"] }); + + // Only write default prompt — no project-specific + await h.writePrompt("developer", "# Default Developer\nFollow coding standards."); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 2, + issueTitle: "Fix bug", + issueDescription: "", + issueUrl: "https://example.com/issues/2", + role: "developer", + level: "junior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("Default Developer")); + assert.ok(files[0].content!.includes("Follow coding standards")); + }); + + it("should inject scaffolded default instructions when no overrides exist", async () => { + h = await createTestHarness({ projectName: "bare-app" }); + h.provider.seedIssue({ iid: 3, title: "Chore", labels: ["To Do"] }); + + // Don't write any custom prompts — ensureWorkspaceMigrated scaffolds defaults + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 3, + issueTitle: "Chore", + issueDescription: "", + issueUrl: "https://example.com/issues/3", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + // Default developer instructions are scaffolded by ensureDefaultFiles + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading"); + assert.ok(files[0].content!.includes("work_finish"), "Should reference work_finish"); + }); + + it("should NOT inject anything for unknown custom roles", async () => { + h = await createTestHarness({ projectName: "custom-app" }); + + // Simulate a session key for a custom role that has no prompt file + // This key won't parse because "reviewer" isn't in the role registry + const files = await h.simulateBootstrap( + "agent:main:subagent:custom-app-reviewer-medior", + ); + + assert.strictEqual(files.length, 0, "Should not inject files for unknown roles"); + }); + + it("should resolve tester instructions independently from developer", async () => { + h = await createTestHarness({ projectName: "multi-role" }); + h.provider.seedIssue({ iid: 4, title: "Test thing", labels: ["To Test"] }); + + // Write project-specific for developer, default for tester + await h.writePrompt("developer", "# Dev for multi-role\nSpecific dev rules.", "multi-role"); + await h.writePrompt("tester", "# Default Tester\nRun integration tests."); + + // Dispatch as tester + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 4, + issueTitle: "Test thing", + issueDescription: "", + issueUrl: "https://example.com/issues/4", + role: "tester", + level: "medior", + fromLabel: "To Test", + toLabel: "Testing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // Simulate bootstrap for the tester session + const testerFiles = await h.simulateBootstrap(result.sessionKey); + assert.strictEqual(testerFiles.length, 1); + assert.ok(testerFiles[0].content!.includes("Default Tester")); + assert.ok(!testerFiles[0].content!.includes("Dev for multi-role")); + + // Simulate bootstrap for a developer session on the same project + const devKey = result.sessionKey.replace("-tester-", "-developer-"); + const devFiles = await h.simulateBootstrap(devKey); + assert.strictEqual(devFiles.length, 1); + assert.ok(devFiles[0].content!.includes("Dev for multi-role")); + assert.ok(devFiles[0].content!.includes("Specific dev rules")); + }); + + it("should handle project names with hyphens correctly", async () => { + h = await createTestHarness({ projectName: "my-cool-project" }); + h.provider.seedIssue({ iid: 5, title: "Hyphen test", labels: ["To Do"] }); + + await h.writePrompt( + "developer", + "# Hyphenated Project\nThis project has hyphens in the name.", + "my-cool-project", + ); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 5, + issueTitle: "Hyphen test", + issueDescription: "", + issueUrl: "https://example.com/issues/5", + role: "developer", + level: "senior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("Hyphenated Project")); + assert.ok(files[0].path.includes("my-cool-project")); + }); + + it("should resolve architect instructions with project override", async () => { + h = await createTestHarness({ projectName: "arch-proj" }); + h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["To Design"] }); + + await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines."); + await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj"); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 6, + issueTitle: "Design API", + issueDescription: "", + issueUrl: "https://example.com/issues/6", + role: "architect", + level: "senior", + fromLabel: "To Design", + toLabel: "Designing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("Arch Proj Architect")); + assert.ok(files[0].content!.includes("event-driven")); + assert.ok(!files[0].content!.includes("General design guidelines")); + }); + + it("should not inject when session key is not a DevClaw subagent", async () => { + h = await createTestHarness(); + + // Non-DevClaw session key — hook should no-op + const files = await h.simulateBootstrap("agent:main:orchestrator"); + assert.strictEqual(files.length, 0); + }); +}); diff --git a/lib/services/health.ts b/lib/services/health.ts index eb41649..68167ba 100644 --- a/lib/services/health.ts +++ b/lib/services/health.ts @@ -83,13 +83,13 @@ export type SessionLookup = Map; * Returns null if gateway is unavailable (timeout, error, etc). * Callers should skip session liveness checks if null — unknown ≠ dead. */ -export async function fetchGatewaySessions(): Promise { +export async function fetchGatewaySessions(gatewayTimeoutMs = 15_000): Promise { const lookup: SessionLookup = new Map(); try { const result = await runCommand( ["openclaw", "gateway", "call", "status", "--json"], - { timeoutMs: 15_000 }, + { timeoutMs: gatewayTimeoutMs }, ); const jsonStart = result.stdout.indexOf("{"); @@ -151,10 +151,13 @@ export async function checkWorkerHealth(opts: { sessions: SessionLookup | null; /** Workflow config (defaults to DEFAULT_WORKFLOW) */ workflow?: WorkflowConfig; + /** Hours after which an active worker is considered stale (default: 2) */ + staleWorkerHours?: number; }): Promise { const { workspaceDir, groupId, project, role, autoFix, provider, sessions, workflow = DEFAULT_WORKFLOW, + staleWorkerHours = 2, } = opts; const fixes: HealthFix[] = []; @@ -316,7 +319,7 @@ export async function checkWorkerHealth(opts: { // --------------------------------------------------------------------------- if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) { const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000; - if (hours > 2) { + if (hours > staleWorkerHours) { const fix: HealthFix = { issue: { type: "stale_worker", diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index 7083d70..307df3f 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -18,7 +18,10 @@ import { log as auditLog } from "../audit.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js"; import { projectTick } from "./tick.js"; +import { reviewPass } from "./review.js"; import { createProvider } from "../providers/index.js"; +import { loadConfig } from "../config/index.js"; +import { ExecutionMode } from "../workflow.js"; // --------------------------------------------------------------------------- // Types @@ -39,6 +42,7 @@ type TickResult = { totalPickups: number; totalHealthFixes: number; totalSkipped: number; + totalReviewTransitions: number; }; type ServiceContext = { @@ -191,6 +195,7 @@ async function processAllAgents( totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, + totalReviewTransitions: 0, }; // Fetch gateway sessions once for all agents/projects @@ -209,6 +214,7 @@ async function processAllAgents( result.totalPickups += agentResult.totalPickups; result.totalHealthFixes += agentResult.totalHealthFixes; result.totalSkipped += agentResult.totalSkipped; + result.totalReviewTransitions += agentResult.totalReviewTransitions; } return result; @@ -218,9 +224,9 @@ async function processAllAgents( * Log tick results if anything happened. */ function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void { - if (result.totalPickups > 0 || result.totalHealthFixes > 0) { + if (result.totalPickups > 0 || result.totalHealthFixes > 0 || result.totalReviewTransitions > 0) { logger.info( - `work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalSkipped} skipped`, + `work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalSkipped} skipped`, ); } } @@ -243,60 +249,83 @@ export async function tick(opts: { const projectIds = Object.keys(data.projects); if (projectIds.length === 0) { - return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0 }; + return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, totalReviewTransitions: 0 }; } const result: TickResult = { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, + totalReviewTransitions: 0, }; - const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel"; + const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL; let activeProjects = 0; for (const groupId of projectIds) { - const project = data.projects[groupId]; - if (!project) continue; + try { + const project = data.projects[groupId]; + if (!project) continue; - // Health pass: auto-fix zombies and stale workers - result.totalHealthFixes += await performHealthPass( - workspaceDir, - groupId, - project, - sessions, - ); + const { provider } = await createProvider({ repo: project.repo, provider: project.provider }); + const resolvedConfig = await loadConfig(workspaceDir, project.name); - // Budget check: stop if we've hit the limit - const remaining = config.maxPickupsPerTick - result.totalPickups; - if (remaining <= 0) break; + // Health pass: auto-fix zombies and stale workers + result.totalHealthFixes += await performHealthPass( + workspaceDir, + groupId, + project, + sessions, + provider, + resolvedConfig.timeouts.staleWorkerHours, + ); - // Sequential project guard: don't start new projects if one is active - const isProjectActive = await checkProjectActive(workspaceDir, groupId); - if (projectExecution === "sequential" && !isProjectActive && activeProjects >= 1) { + // Review pass: transition issues whose PR check condition is met + result.totalReviewTransitions += await reviewPass({ + workspaceDir, + groupId, + workflow: resolvedConfig.workflow, + provider, + repoPath: project.repo, + gitPullTimeoutMs: resolvedConfig.timeouts.gitPullMs, + }); + + // Budget check: stop if we've hit the limit + const remaining = config.maxPickupsPerTick - result.totalPickups; + if (remaining <= 0) break; + + // Sequential project guard: don't start new projects if one is active + const isProjectActive = await checkProjectActive(workspaceDir, groupId); + if (projectExecution === ExecutionMode.SEQUENTIAL && !isProjectActive && activeProjects >= 1) { + result.totalSkipped++; + continue; + } + + // Tick pass: fill free worker slots + const tickResult = await projectTick({ + workspaceDir, + groupId, + agentId, + pluginConfig, + maxPickups: remaining, + }); + + result.totalPickups += tickResult.pickups.length; + result.totalSkipped += tickResult.skipped.length; + + // Notifications now handled by dispatchTask + if (isProjectActive || tickResult.pickups.length > 0) activeProjects++; + } catch (err) { + // Per-project isolation: one failing project doesn't crash the entire tick + opts.logger.warn(`Heartbeat tick failed for project ${groupId}: ${(err as Error).message}`); result.totalSkipped++; - continue; } - - // Tick pass: fill free worker slots - const tickResult = await projectTick({ - workspaceDir, - groupId, - agentId, - pluginConfig, - maxPickups: remaining, - }); - - result.totalPickups += tickResult.pickups.length; - result.totalSkipped += tickResult.skipped.length; - - // Notifications now handled by dispatchTask - if (isProjectActive || tickResult.pickups.length > 0) activeProjects++; } await auditLog(workspaceDir, "heartbeat_tick", { projectsScanned: projectIds.length, healthFixes: result.totalHealthFixes, + reviewTransitions: result.totalReviewTransitions, pickups: result.totalPickups, skipped: result.totalSkipped, }); @@ -312,8 +341,9 @@ async function performHealthPass( groupId: string, project: any, sessions: SessionLookup | null, + provider: import("../providers/provider.js").IssueProvider, + staleWorkerHours?: number, ): Promise { - const { provider } = await createProvider({ repo: project.repo, provider: project.provider }); let fixedCount = 0; for (const role of Object.keys(project.workers)) { @@ -326,6 +356,7 @@ async function performHealthPass( sessions, autoFix: true, provider, + staleWorkerHours, }); fixedCount += healthFixes.filter((f) => f.fixed).length; diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts new file mode 100644 index 0000000..0689291 --- /dev/null +++ b/lib/services/pipeline.e2e.test.ts @@ -0,0 +1,747 @@ +/** + * E2E pipeline tests — exercises the full workflow lifecycle. + * + * Tests dispatch → completion → review pass using: + * - TestProvider (in-memory issues, call tracking) + * - Mock runCommand (captures gateway calls, task messages) + * - Real projects.json on disk (temp workspace) + * + * Run: npx tsx --test lib/services/pipeline.e2e.test.ts + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { dispatchTask } from "../dispatch.js"; +import { executeCompletion } from "./pipeline.js"; +import { reviewPass } from "./review.js"; +import { DEFAULT_WORKFLOW } from "../workflow.js"; +import { readProjects, getWorker } from "../projects.js"; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("E2E pipeline", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + // ========================================================================= + // Dispatch + // ========================================================================= + + describe("dispatchTask", () => { + beforeEach(async () => { + h = await createTestHarness(); + // Seed a "To Do" issue + h.provider.seedIssue({ iid: 42, title: "Add login page", labels: ["To Do"] }); + }); + + it("should transition label, update worker state, and fire gateway calls", async () => { + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "test-agent", + groupId: h.groupId, + project: h.project, + issueId: 42, + issueTitle: "Add login page", + issueDescription: "Build the login page", + issueUrl: "https://example.com/issues/42", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // Verify dispatch result + assert.strictEqual(result.sessionAction, "spawn"); + assert.ok(result.sessionKey.includes("test-project-developer-medior")); + assert.ok(result.announcement.includes("#42")); + assert.ok(result.announcement.includes("Add login page")); + + // Verify label transitioned on the issue + const issue = await h.provider.getIssue(42); + assert.ok(issue.labels.includes("Doing"), `Expected "Doing" label, got: ${issue.labels}`); + assert.ok(!issue.labels.includes("To Do"), "Should not have 'To Do' label"); + + // Verify worker state updated in projects.json + const data = await readProjects(h.workspaceDir); + const worker = getWorker(data.projects[h.groupId], "developer"); + assert.strictEqual(worker.active, true); + assert.strictEqual(worker.issueId, "42"); + assert.strictEqual(worker.level, "medior"); + + // Verify gateway commands were fired + assert.ok(h.commands.sessionPatches().length > 0, "Should have patched session"); + assert.ok(h.commands.taskMessages().length > 0, "Should have sent task message"); + + // Verify task message contains issue context + const taskMsg = h.commands.taskMessages()[0]; + assert.ok(taskMsg.includes("Add login page"), "Task message should include title"); + assert.ok(taskMsg.includes(h.groupId), "Task message should include groupId"); + assert.ok(taskMsg.includes("work_finish"), "Task message should reference work_finish"); + }); + + it("should include comments in task message", async () => { + h.provider.comments.set(42, [ + { author: "alice", body: "Please use OAuth", created_at: "2026-01-01T00:00:00Z" }, + { author: "bob", body: "Agreed, OAuth2 flow", created_at: "2026-01-02T00:00:00Z" }, + ]); + + await dispatchTask({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + project: h.project, + issueId: 42, + issueTitle: "Add login page", + issueDescription: "", + issueUrl: "https://example.com/issues/42", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const taskMsg = h.commands.taskMessages()[0]; + assert.ok(taskMsg.includes("alice"), "Should include comment author"); + assert.ok(taskMsg.includes("Please use OAuth"), "Should include comment body"); + assert.ok(taskMsg.includes("bob"), "Should include second comment author"); + }); + + it("should reuse existing session when available", async () => { + // Set up worker with existing session + h = await createTestHarness({ + workers: { + developer: { + sessions: { medior: "agent:test-agent:subagent:test-project-developer-medior" }, + }, + }, + }); + h.provider.seedIssue({ iid: 42, title: "Quick fix", labels: ["To Do"] }); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "test-agent", + groupId: h.groupId, + project: h.project, + issueId: 42, + issueTitle: "Quick fix", + issueDescription: "", + issueUrl: "https://example.com/issues/42", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + assert.strictEqual(result.sessionAction, "send"); + }); + }); + + // ========================================================================= + // Completion — developer:done + // ========================================================================= + + describe("executeCompletion — developer:done", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + developer: { active: true, issueId: "10", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 10, title: "Build feature X", labels: ["Doing"] }); + }); + + it("should transition Doing → To Test, deactivate worker, run gitPull+detectPr actions", async () => { + h.provider.mergedMrUrls.set(10, "https://example.com/mr/5"); + + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 10, + summary: "Built feature X", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + // Label transition + assert.strictEqual(output.labelTransition, "Doing → To Test"); + assert.ok(output.announcement.includes("#10")); + + // Issue state + const issue = await h.provider.getIssue(10); + assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); + assert.ok(!issue.labels.includes("Doing")); + + // Worker deactivated + const data = await readProjects(h.workspaceDir); + const worker = getWorker(data.projects[h.groupId], "developer"); + assert.strictEqual(worker.active, false); + + // PR URL detected + assert.strictEqual(output.prUrl, "https://example.com/mr/5"); + + // gitPull action was executed + const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); + assert.ok(gitCmds.length > 0, "Should have run git pull"); + assert.deepStrictEqual(gitCmds[0].argv, ["git", "pull"]); + + // Issue NOT closed (done goes to To Test, not Done) + assert.strictEqual(output.issueClosed, false); + }); + }); + + // ========================================================================= + // Completion — developer:review + // ========================================================================= + + describe("executeCompletion — developer:review", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + developer: { active: true, issueId: "20", level: "senior" }, + }, + }); + h.provider.seedIssue({ iid: 20, title: "Refactor auth", labels: ["Doing"] }); + }); + + it("should transition Doing → In Review, deactivate worker", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "review", + issueId: 20, + summary: "PR open for review", + prUrl: "https://example.com/pr/3", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Doing → In Review"); + assert.ok(output.nextState.includes("review"), `nextState: ${output.nextState}`); + + const issue = await h.provider.getIssue(20); + assert.ok(issue.labels.includes("In Review"), `Labels: ${issue.labels}`); + + // Worker should be deactivated + const data = await readProjects(h.workspaceDir); + assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false); + + // Issue should NOT be closed + assert.strictEqual(output.issueClosed, false); + }); + }); + + // ========================================================================= + // Completion — tester:pass + // ========================================================================= + + describe("executeCompletion — tester:pass", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + tester: { active: true, issueId: "30", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 30, title: "Verify login", labels: ["Testing"] }); + }); + + it("should transition Testing → Done, close issue", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 30, + summary: "All tests pass", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Testing → Done"); + assert.strictEqual(output.issueClosed, true); + + const issue = await h.provider.getIssue(30); + assert.ok(issue.labels.includes("Done")); + assert.strictEqual(issue.state, "closed"); + + // Verify closeIssue was called + const closeCalls = h.provider.callsTo("closeIssue"); + assert.strictEqual(closeCalls.length, 1); + assert.strictEqual(closeCalls[0].args.issueId, 30); + }); + }); + + // ========================================================================= + // Completion — tester:fail + // ========================================================================= + + describe("executeCompletion — tester:fail", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + tester: { active: true, issueId: "40", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 40, title: "Check signup", labels: ["Testing"] }); + }); + + it("should transition Testing → To Improve, reopen issue", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "fail", + issueId: 40, + summary: "Signup form validation broken", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Testing → To Improve"); + assert.strictEqual(output.issueReopened, true); + + const issue = await h.provider.getIssue(40); + assert.ok(issue.labels.includes("To Improve")); + assert.strictEqual(issue.state, "opened"); + + const reopenCalls = h.provider.callsTo("reopenIssue"); + assert.strictEqual(reopenCalls.length, 1); + }); + }); + + // ========================================================================= + // Completion — developer:blocked + // ========================================================================= + + describe("executeCompletion — developer:blocked", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + developer: { active: true, issueId: "50", level: "junior" }, + }, + }); + h.provider.seedIssue({ iid: 50, title: "Fix CSS", labels: ["Doing"] }); + }); + + it("should transition Doing → Refining, no close/reopen", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "blocked", + issueId: 50, + summary: "Need design decision", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Doing → Refining"); + assert.strictEqual(output.issueClosed, false); + assert.strictEqual(output.issueReopened, false); + + const issue = await h.provider.getIssue(50); + assert.ok(issue.labels.includes("Refining")); + }); + }); + + // ========================================================================= + // Review pass + // ========================================================================= + + describe("reviewPass", () => { + beforeEach(async () => { + h = await createTestHarness(); + }); + + it("should transition In Review → To Test when PR is merged", async () => { + // Seed issue in "In Review" state + h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] }); + h.provider.setPrStatus(60, { state: "merged", url: "https://example.com/pr/10" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 1); + + // Issue should now have "To Test" label + const issue = await h.provider.getIssue(60); + assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); + assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + + // gitPull action should have been attempted + const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); + assert.ok(gitCmds.length > 0, "Should have run git pull"); + }); + + it("should NOT transition when PR is still open", async () => { + h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["In Review"] }); + h.provider.setPrStatus(61, { state: "open", url: "https://example.com/pr/11" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 0); + + // Issue should still have "In Review" + const issue = await h.provider.getIssue(61); + assert.ok(issue.labels.includes("In Review")); + }); + + it("should handle multiple review issues in one pass", async () => { + h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] }); + h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] }); + h.provider.setPrStatus(70, { state: "merged", url: "https://example.com/pr/20" }); + h.provider.setPrStatus(71, { state: "merged", url: "https://example.com/pr/21" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 2); + + const issue70 = await h.provider.getIssue(70); + const issue71 = await h.provider.getIssue(71); + assert.ok(issue70.labels.includes("To Test")); + assert.ok(issue71.labels.includes("To Test")); + }); + }); + + // ========================================================================= + // Full lifecycle: dispatch → complete → review → test → done + // ========================================================================= + + describe("full lifecycle", () => { + it("developer:done → tester:pass (direct path)", async () => { + h = await createTestHarness(); + + // 1. Seed issue in To Do + h.provider.seedIssue({ iid: 100, title: "Build dashboard", labels: ["To Do"] }); + + // 2. Dispatch developer + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 100, + issueTitle: "Build dashboard", + issueDescription: "Create the main dashboard view", + issueUrl: "https://example.com/issues/100", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + let issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("Doing")); + + // 3. Developer completes → To Test + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 100, + summary: "Dashboard built", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("To Test"), `After dev done: ${issue.labels}`); + + // 4. Simulate tester dispatch (activate worker manually for completion) + const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "100", level: "medior", + }); + await h.provider.transitionLabel(100, "To Test", "Testing"); + + // 5. Tester passes → Done + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 100, + summary: "All checks passed", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`); + assert.strictEqual(issue.state, "closed"); + }); + + it("developer:review → review pass → tester:pass (review path)", async () => { + h = await createTestHarness(); + + // 1. Seed issue in To Do + h.provider.seedIssue({ iid: 200, title: "Auth refactor", labels: ["To Do"] }); + + // 2. Dispatch developer + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 200, + issueTitle: "Auth refactor", + issueDescription: "Refactor authentication system", + issueUrl: "https://example.com/issues/200", + role: "developer", + level: "senior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // 3. Developer finishes with "review" → In Review + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "review", + issueId: 200, + summary: "PR ready for review", + prUrl: "https://example.com/pr/50", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + let issue = await h.provider.getIssue(200); + assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`); + + // 4. PR gets merged — review pass picks it up + h.provider.setPrStatus(200, { state: "merged", url: "https://example.com/pr/50" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 1); + issue = await h.provider.getIssue(200); + assert.ok(issue.labels.includes("To Test"), `After review pass: ${issue.labels}`); + + // 5. Tester passes → Done + const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "200", level: "medior", + }); + await h.provider.transitionLabel(200, "To Test", "Testing"); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 200, + summary: "Auth refactor verified", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(200); + assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`); + assert.strictEqual(issue.state, "closed"); + }); + + it("developer:done → tester:fail → developer:done → tester:pass (fail cycle)", async () => { + h = await createTestHarness(); + + h.provider.seedIssue({ iid: 300, title: "Payment flow", labels: ["To Do"] }); + + // 1. Dispatch developer + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 300, + issueTitle: "Payment flow", + issueDescription: "Implement payment", + issueUrl: "https://example.com/issues/300", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // 2. Developer done → To Test + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 300, + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + // 3. Activate tester + transition + const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "300", level: "medior", + }); + await h.provider.transitionLabel(300, "To Test", "Testing"); + + // 4. Tester FAILS → To Improve + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "fail", + issueId: 300, + summary: "Validation broken", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + let issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("To Improve"), `After fail: ${issue.labels}`); + assert.strictEqual(issue.state, "opened"); // reopened + + // 5. Developer picks up again (To Improve → Doing) + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: (await readProjects(h.workspaceDir)).projects[h.groupId], + issueId: 300, + issueTitle: "Payment flow", + issueDescription: "Implement payment", + issueUrl: "https://example.com/issues/300", + role: "developer", + level: "medior", + fromLabel: "To Improve", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // 6. Developer fixes it → To Test + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 300, + summary: "Fixed validation", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("To Test"), `After fix: ${issue.labels}`); + + // 7. Tester passes → Done + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "300", level: "medior", + }); + await h.provider.transitionLabel(300, "To Test", "Testing"); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 300, + summary: "All good now", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`); + assert.strictEqual(issue.state, "closed"); + }); + }); + + // ========================================================================= + // Provider call tracking + // ========================================================================= + + describe("provider call tracking", () => { + it("should track all provider interactions during completion", async () => { + h = await createTestHarness({ + workers: { + tester: { active: true, issueId: "90", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 90, title: "Test tracking", labels: ["Testing"] }); + h.provider.resetCalls(); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 90, + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + // Should have: getIssue (for URL), transitionLabel, closeIssue + assert.ok(h.provider.callsTo("getIssue").length >= 1, "Should call getIssue"); + assert.strictEqual(h.provider.callsTo("transitionLabel").length, 1); + assert.strictEqual(h.provider.callsTo("closeIssue").length, 1); + + // Verify transition args + const transition = h.provider.callsTo("transitionLabel")[0]; + assert.strictEqual(transition.args.issueId, 90); + assert.strictEqual(transition.args.from, "Testing"); + assert.strictEqual(transition.args.to, "Done"); + }); + }); +}); diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 3c9ae53..a15bc0a 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -8,8 +8,11 @@ import type { StateLabel, IssueProvider } from "../providers/provider.js"; import { deactivateWorker } from "../projects.js"; import { runCommand } from "../run-command.js"; import { notify, getNotificationConfig } from "../notify.js"; +import { log as auditLog } from "../audit.js"; +import { loadConfig } from "../config/index.js"; import { DEFAULT_WORKFLOW, + Action, getCompletionRule, getNextStateDescription, getCompletionEmoji, @@ -72,18 +75,23 @@ export async function executeCompletion(opts: { const rule = getCompletionRule(workflow, role, result); if (!rule) throw new Error(`No completion rule for ${key}`); + const { timeouts } = await loadConfig(workspaceDir, projectName); let prUrl = opts.prUrl; - // Git pull (dev:done) - if (rule.gitPull) { - try { - await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath }); - } catch { /* best-effort */ } - } - - // Auto-detect PR URL (dev:done) - if (rule.detectPr && !prUrl) { - try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch { /* ignore */ } + // Execute pre-notification actions + for (const action of rule.actions) { + switch (action) { + case Action.GIT_PULL: + try { await runCommand(["git", "pull"], { timeoutMs: timeouts.gitPullMs, cwd: repoPath }); } catch (err) { + auditLog(workspaceDir, "pipeline_warning", { step: "gitPull", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + } + break; + case Action.DETECT_PR: + if (!prUrl) { try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch (err) { + auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + } } + break; + } } // Get issue early (for URL in notification) @@ -113,15 +121,25 @@ export async function executeCompletion(opts: { channel: channel ?? "telegram", runtime, }, - ).catch(() => { /* non-fatal */ }); + ).catch((err) => { + auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + }); // Deactivate worker + transition label await deactivateWorker(workspaceDir, groupId, role); await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel); - // Close/reopen - if (rule.closeIssue) await provider.closeIssue(issueId); - if (rule.reopenIssue) await provider.reopenIssue(issueId); + // Execute post-transition actions + for (const action of rule.actions) { + switch (action) { + case Action.CLOSE_ISSUE: + await provider.closeIssue(issueId); + break; + case Action.REOPEN_ISSUE: + await provider.reopenIssue(issueId); + break; + } + } // Build announcement using workflow-derived emoji const emoji = getCompletionEmoji(role, result); @@ -138,7 +156,7 @@ export async function executeCompletion(opts: { nextState, prUrl, issueUrl: issue.web_url, - issueClosed: rule.closeIssue, - issueReopened: rule.reopenIssue, + issueClosed: rule.actions.includes(Action.CLOSE_ISSUE), + issueReopened: rule.actions.includes(Action.REOPEN_ISSUE), }; } diff --git a/lib/services/queue-scan.ts b/lib/services/queue-scan.ts new file mode 100644 index 0000000..aef88af --- /dev/null +++ b/lib/services/queue-scan.ts @@ -0,0 +1,88 @@ +/** + * queue-scan.ts — Issue queue scanning helpers. + * + * Shared by: tick (projectTick), work-start (auto-pickup), and other consumers + * that need to find queued issues or detect roles/levels from labels. + */ +import type { Issue, StateLabel } from "../providers/provider.js"; +import type { IssueProvider } from "../providers/provider.js"; +import { getLevelsForRole, getAllLevels } from "../roles/index.js"; +import { + getQueueLabels, + getAllQueueLabels, + detectRoleFromLabel as workflowDetectRole, + type WorkflowConfig, + type Role, +} from "../workflow.js"; + +// --------------------------------------------------------------------------- +// Label detection +// --------------------------------------------------------------------------- + +export function detectLevelFromLabels(labels: string[]): string | null { + const lower = labels.map((l) => l.toLowerCase()); + + // Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior") + for (const l of lower) { + const dot = l.indexOf("."); + if (dot === -1) continue; + const role = l.slice(0, dot); + const level = l.slice(dot + 1); + const roleLevels = getLevelsForRole(role); + if (roleLevels.includes(level)) return level; + } + + // Fallback: plain level name + const all = getAllLevels(); + return all.find((l) => lower.includes(l)) ?? null; +} + +/** + * Detect role from a label using workflow config. + */ +export function detectRoleFromLabel( + label: StateLabel, + workflow: WorkflowConfig, +): Role | null { + return workflowDetectRole(workflow, label); +} + +// --------------------------------------------------------------------------- +// Issue queue queries +// --------------------------------------------------------------------------- + +export async function findNextIssueForRole( + provider: Pick, + role: Role, + workflow: WorkflowConfig, +): Promise<{ issue: Issue; label: StateLabel } | null> { + const labels = getQueueLabels(workflow, role); + for (const label of labels) { + try { + const issues = await provider.listIssuesByLabel(label); + if (issues.length > 0) return { issue: issues[issues.length - 1], label }; + } catch { /* continue */ } + } + return null; +} + +/** + * Find next issue for any role (optional filter). Used by work_start for auto-detection. + */ +export async function findNextIssue( + provider: Pick, + role: Role | undefined, + workflow: WorkflowConfig, +): Promise<{ issue: Issue; label: StateLabel } | null> { + const labels = role + ? getQueueLabels(workflow, role) + : getAllQueueLabels(workflow); + + for (const label of labels) { + try { + const issues = await provider.listIssuesByLabel(label); + if (issues.length > 0) return { issue: issues[issues.length - 1], label }; + } catch { /* continue */ } + } + return null; +} diff --git a/lib/services/queue.ts b/lib/services/queue.ts index cceaac6..d2b2764 100644 --- a/lib/services/queue.ts +++ b/lib/services/queue.ts @@ -9,6 +9,7 @@ import { createProvider } from "../providers/index.js"; import type { Project } from "../projects.js"; import { DEFAULT_WORKFLOW, + StateType, type WorkflowConfig, type Role, } from "../workflow.js"; @@ -27,7 +28,7 @@ export function getQueueLabelsWithPriority( const labels: Array<{ label: string; priority: number; role?: Role }> = []; for (const state of Object.values(workflow.states)) { - if (state.type === "queue") { + if (state.type === StateType.QUEUE) { labels.push({ label: state.label, priority: state.priority ?? 0, diff --git a/lib/services/review.ts b/lib/services/review.ts new file mode 100644 index 0000000..d5a0e98 --- /dev/null +++ b/lib/services/review.ts @@ -0,0 +1,98 @@ +/** + * review.ts — Poll review-type states for PR status changes. + * + * Scans review states in the workflow and transitions issues + * whose PR check condition (merged/approved) is met. + * Called by the heartbeat service during its periodic sweep. + */ +import type { IssueProvider } from "../providers/provider.js"; +import { PrState } from "../providers/provider.js"; +import { + Action, + ReviewCheck, + WorkflowEvent, + StateType, + type WorkflowConfig, + type StateConfig, +} from "../workflow.js"; +import { runCommand } from "../run-command.js"; +import { log as auditLog } from "../audit.js"; + +/** + * Scan review-type states and transition issues whose PR check condition is met. + * Returns the number of transitions made. + */ +export async function reviewPass(opts: { + workspaceDir: string; + groupId: string; + workflow: WorkflowConfig; + provider: IssueProvider; + repoPath: string; + gitPullTimeoutMs?: number; +}): Promise { + const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts; + let transitions = 0; + + // Find all review-type states + const reviewStates = Object.entries(workflow.states) + .filter(([, s]) => s.type === StateType.REVIEW) as [string, StateConfig][]; + + for (const [stateKey, state] of reviewStates) { + if (!state.on || !state.check) continue; + + const issues = await provider.listIssuesByLabel(state.label); + for (const issue of issues) { + const status = await provider.getPrStatus(issue.iid); + + const conditionMet = + (state.check === ReviewCheck.PR_MERGED && status.state === PrState.MERGED) || + (state.check === ReviewCheck.PR_APPROVED && (status.state === PrState.APPROVED || status.state === PrState.MERGED)); + + if (!conditionMet) continue; + + // Find the success transition (first non-BLOCKED event) + const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED); + if (!successEvent) continue; + + const transition = state.on[successEvent]; + const targetKey = typeof transition === "string" ? transition : transition.target; + const actions = typeof transition === "object" ? transition.actions : undefined; + const targetState = workflow.states[targetKey]; + if (!targetState) continue; + + // Execute transition actions + if (actions) { + for (const action of actions) { + switch (action) { + case Action.GIT_PULL: + try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ } + break; + case Action.CLOSE_ISSUE: + await provider.closeIssue(issue.iid); + break; + case Action.REOPEN_ISSUE: + await provider.reopenIssue(issue.iid); + break; + } + } + } + + // Transition label + await provider.transitionLabel(issue.iid, state.label, targetState.label); + + await auditLog(workspaceDir, "review_transition", { + groupId, + issueId: issue.iid, + from: state.label, + to: targetState.label, + check: state.check, + prState: status.state, + prUrl: status.url, + }); + + transitions++; + } + } + + return transitions; +} diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 55d7b7f..d334ac2 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -11,84 +11,15 @@ import { createProvider } from "../providers/index.js"; import { selectLevel } from "../model-selector.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js"; +import { roleForLevel } from "../roles/index.js"; import { loadConfig } from "../config/index.js"; import { - getQueueLabels, - getAllQueueLabels, + ExecutionMode, getActiveLabel, - detectRoleFromLabel as workflowDetectRole, type WorkflowConfig, type Role, } from "../workflow.js"; - -// --------------------------------------------------------------------------- -// Shared helpers (used by tick, work-start, auto-pickup) -// --------------------------------------------------------------------------- - -export function detectLevelFromLabels(labels: string[]): string | null { - const lower = labels.map((l) => l.toLowerCase()); - - // Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior") - for (const l of lower) { - const dot = l.indexOf("."); - if (dot === -1) continue; - const role = l.slice(0, dot); - const level = l.slice(dot + 1); - const roleLevels = getLevelsForRole(role); - if (roleLevels.includes(level)) return level; - } - - // Fallback: plain level name - const all = getAllLevels(); - return all.find((l) => lower.includes(l)) ?? null; -} - -/** - * Detect role from a label using workflow config. - */ -export function detectRoleFromLabel( - label: StateLabel, - workflow: WorkflowConfig, -): Role | null { - return workflowDetectRole(workflow, label); -} - -export async function findNextIssueForRole( - provider: Pick, - role: Role, - workflow: WorkflowConfig, -): Promise<{ issue: Issue; label: StateLabel } | null> { - const labels = getQueueLabels(workflow, role); - for (const label of labels) { - try { - const issues = await provider.listIssuesByLabel(label); - if (issues.length > 0) return { issue: issues[issues.length - 1], label }; - } catch { /* continue */ } - } - return null; -} - -/** - * Find next issue for any role (optional filter). Used by work_start for auto-detection. - */ -export async function findNextIssue( - provider: Pick, - role: Role | undefined, - workflow: WorkflowConfig, -): Promise<{ issue: Issue; label: StateLabel } | null> { - const labels = role - ? getQueueLabels(workflow, role) - : getAllQueueLabels(workflow); - - for (const label of labels) { - try { - const issues = await provider.listIssuesByLabel(label); - if (issues.length > 0) return { issue: issues[issues.length - 1], label }; - } catch { /* continue */ } - } - return null; -} +import { detectLevelFromLabels, findNextIssueForRole } from "./queue-scan.js"; // --------------------------------------------------------------------------- // projectTick @@ -146,7 +77,7 @@ export async function projectTick(opts: { const workflow = opts.workflow ?? resolvedConfig.workflow; const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider; - const roleExecution = project.roleExecution ?? "parallel"; + const roleExecution = project.roleExecution ?? ExecutionMode.PARALLEL; const enabledRoles = Object.entries(resolvedConfig.roles) .filter(([, r]) => r.enabled) .map(([id]) => id); @@ -173,7 +104,7 @@ export async function projectTick(opts: { } // Check sequential role execution: any other role must be inactive const otherRoles = enabledRoles.filter((r: string) => r !== role); - if (roleExecution === "sequential" && otherRoles.some((r: string) => getWorker(fresh, r).active)) { + if (roleExecution === ExecutionMode.SEQUENTIAL && otherRoles.some((r: string) => getWorker(fresh, r).active)) { skipped.push({ role, reason: "Sequential: other role active" }); continue; } diff --git a/lib/setup/config.ts b/lib/setup/config.ts index a112f87..a18f020 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -6,6 +6,7 @@ */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; +import type { ExecutionMode } from "../workflow.js"; /** * Write DevClaw plugin config to openclaw.json plugins section. @@ -21,7 +22,7 @@ import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; export async function writePluginConfig( api: OpenClawPluginApi, agentId?: string, - projectExecution?: "parallel" | "sequential", + projectExecution?: ExecutionMode, ): Promise { const config = api.runtime.config.loadConfig() as Record; diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 4766585..7817b9e 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -14,6 +14,7 @@ import { createAgent, resolveWorkspacePath } from "./agent.js"; import { writePluginConfig } from "./config.js"; import { scaffoldWorkspace } from "./workspace.js"; import { DATA_DIR } from "./migrate-layout.js"; +import type { ExecutionMode } from "../workflow.js"; export type ModelConfig = Record>; @@ -33,7 +34,7 @@ export type SetupOpts = { /** Model overrides per role.level. Missing levels use defaults. */ models?: Record>>; /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ - projectExecution?: "parallel" | "sequential"; + projectExecution?: ExecutionMode; }; export type SetupResult = { diff --git a/lib/templates.ts b/lib/templates.ts index 8bf5d80..acebd23 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -21,10 +21,12 @@ Read the comments carefully — they often contain clarifications, decisions, or - Work in a git worktree (never switch branches in the main repo) - Run tests before completing -- Create an MR/PR to the base branch and merge it +- Create an MR/PR to the base branch - **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA. -- Clean up the worktree after merging -- When done, call work_finish with role "developer", result "done", and a brief summary +- **Merge or request review:** + - Merge the PR yourself → call work_finish with result "done" + - Leave the PR open for human review → call work_finish with result "review" (the heartbeat will auto-advance when the PR is merged) +- Clean up the worktree after merging (if you merged) - If you discover unrelated bugs, call task_create to file them - Do NOT call work_start, status, health, or project_register `; @@ -144,7 +146,8 @@ Skip the orchestrator section. Follow your task message and role instructions (a When you are done, **call \`work_finish\` yourself** — do not just announce in text. -- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` +- **DEVELOPER done (merged):** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` +- **DEVELOPER review (PR open):** \`work_finish({ role: "developer", result: "review", projectGroupId: "", summary: "" })\` - **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "", summary: "" })\` - **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "", summary: "" })\` - **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "", summary: "" })\` @@ -224,10 +227,12 @@ All orchestration goes through these tools. You do NOT manually manage sessions, \`\`\` Planning → To Do → Doing → To Test → Testing → Done - ↓ - To Improve → Doing (fix cycle) - ↓ - Refining (human decision) + ↓ ↑ + In Review ─────┘ (auto-advances when PR merged) + ↓ + To Improve → Doing (fix cycle) + ↓ + Refining (human decision) To Design → Designing → Planning (design complete) \`\`\` @@ -257,6 +262,7 @@ All roles (Developer, Tester, Architect) use the same level scheme. Levels descr 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: - Developer "done" → issue moves to "To Test" → scheduler dispatches Tester +- Developer "review" → issue moves to "In Review" → heartbeat polls PR status → auto-advances to "To Test" when merged - Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer - Tester "pass" → Done, no further dispatch - Tester "refine" / blocked → needs human input @@ -270,7 +276,7 @@ Workers receive role-specific instructions appended to their task message. These ### Heartbeats -**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. +**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "In Review" issues when PRs are merged), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. ### Safety @@ -333,6 +339,9 @@ workflow: COMPLETE: target: toTest actions: [gitPull, detectPr] + REVIEW: + target: reviewing + actions: [detectPr] BLOCKED: refining toTest: type: queue @@ -370,6 +379,16 @@ workflow: color: "#f39c12" on: APPROVE: todo + reviewing: + type: review + label: In Review + color: "#c5def5" + check: prMerged + on: + APPROVED: + target: toTest + actions: [gitPull] + BLOCKED: refining done: type: terminal label: Done diff --git a/lib/testing/harness.ts b/lib/testing/harness.ts new file mode 100644 index 0000000..4807cf2 --- /dev/null +++ b/lib/testing/harness.ts @@ -0,0 +1,292 @@ +/** + * Test harness — scaffolds a temporary workspace with projects.json, + * installs a mock runCommand, and provides helpers for E2E pipeline tests. + * + * Usage: + * const h = await createTestHarness({ ... }); + * try { ... } finally { await h.cleanup(); } + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { initRunCommand } from "../run-command.js"; +import { writeProjects, type ProjectsData, type Project, emptyWorkerState } from "../projects.js"; +import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; +import { registerBootstrapHook } from "../bootstrap-hook.js"; +import { TestProvider } from "./test-provider.js"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +// --------------------------------------------------------------------------- +// Bootstrap file type (mirrors OpenClaw's internal type) +// --------------------------------------------------------------------------- + +export type BootstrapFile = { + name: string; + path: string; + content?: string; + missing: boolean; +}; + +// --------------------------------------------------------------------------- +// Command interceptor +// --------------------------------------------------------------------------- + +export type CapturedCommand = { + argv: string[]; + opts: { timeoutMs: number; cwd?: string }; + /** Extracted from gateway `agent` call params, if applicable. */ + taskMessage?: string; + /** Extracted from gateway `sessions.patch` params, if applicable. */ + sessionPatch?: { key: string; model: string }; +}; + +export type CommandInterceptor = { + /** All captured commands, in order. */ + commands: CapturedCommand[]; + /** Filter commands by first argv element. */ + commandsFor(cmd: string): CapturedCommand[]; + /** Get all task messages sent via `openclaw gateway call agent`. */ + taskMessages(): string[]; + /** Get all session patches. */ + sessionPatches(): Array<{ key: string; model: string }>; + /** Reset captured commands. */ + reset(): void; +}; + +function createCommandInterceptor(): { + interceptor: CommandInterceptor; + handler: (argv: string[], opts: number | { timeoutMs: number; cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number | null; signal: null; killed: false }>; +} { + const commands: CapturedCommand[] = []; + + const handler = async ( + argv: string[], + optsOrTimeout: number | { timeoutMs: number; cwd?: string }, + ) => { + const opts = typeof optsOrTimeout === "number" + ? { timeoutMs: optsOrTimeout } + : optsOrTimeout; + + const captured: CapturedCommand = { argv, opts }; + + // Parse gateway agent calls to extract task message + if (argv[0] === "openclaw" && argv[1] === "gateway" && argv[2] === "call") { + const rpcMethod = argv[3]; + const paramsIdx = argv.indexOf("--params"); + if (paramsIdx !== -1 && argv[paramsIdx + 1]) { + try { + const params = JSON.parse(argv[paramsIdx + 1]); + if (rpcMethod === "agent" && params.message) { + captured.taskMessage = params.message; + } + if (rpcMethod === "sessions.patch") { + captured.sessionPatch = { key: params.key, model: params.model }; + } + } catch { /* ignore parse errors */ } + } + } + + commands.push(captured); + + return { stdout: "{}", stderr: "", code: 0, signal: null as null, killed: false as const }; + }; + + const interceptor: CommandInterceptor = { + commands, + commandsFor(cmd: string) { + return commands.filter((c) => c.argv[0] === cmd); + }, + taskMessages() { + return commands + .filter((c) => c.taskMessage !== undefined) + .map((c) => c.taskMessage!); + }, + sessionPatches() { + return commands + .filter((c) => c.sessionPatch !== undefined) + .map((c) => c.sessionPatch!); + }, + reset() { + commands.length = 0; + }, + }; + + return { interceptor, handler }; +} + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +export type TestHarness = { + /** Temporary workspace directory. */ + workspaceDir: string; + /** In-memory issue provider. */ + provider: TestProvider; + /** Command interceptor — captures all runCommand calls. */ + commands: CommandInterceptor; + /** The project group ID used for test data. */ + groupId: string; + /** The project data. */ + project: Project; + /** Workflow config. */ + workflow: WorkflowConfig; + /** Write updated projects data to disk. */ + writeProjects(data: ProjectsData): Promise; + /** Read current projects data from disk. */ + readProjects(): Promise; + /** + * Write a role prompt file to the workspace. + * @param role - Role name (e.g. "developer", "tester") + * @param content - Prompt file content + * @param projectName - If provided, writes project-specific prompt; otherwise writes default. + */ + writePrompt(role: string, content: string, projectName?: string): Promise; + /** + * Simulate the agent:bootstrap hook firing for a session key. + * Registers the real hook with a mock API, fires it, returns the injected bootstrap files. + * This tests the full hook chain: session key → parse → load instructions → inject. + */ + simulateBootstrap(sessionKey: string): Promise; + /** Clean up temp directory. */ + cleanup(): Promise; +}; + +export type HarnessOptions = { + /** Project name (default: "test-project"). */ + projectName?: string; + /** Group ID (default: "-1234567890"). */ + groupId?: string; + /** Repo path (default: "/tmp/test-repo"). */ + repo?: string; + /** Base branch (default: "main"). */ + baseBranch?: string; + /** Workflow config (default: DEFAULT_WORKFLOW). */ + workflow?: WorkflowConfig; + /** Initial worker state overrides. */ + workers?: Record>; + /** Additional projects to seed. */ + extraProjects?: Record; +}; + +export async function createTestHarness(opts?: HarnessOptions): Promise { + const { + projectName = "test-project", + groupId = "-1234567890", + repo = "/tmp/test-repo", + baseBranch = "main", + workflow = DEFAULT_WORKFLOW, + workers: workerOverrides, + extraProjects, + } = opts ?? {}; + + // Create temp workspace + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-e2e-")); + const dataDir = path.join(workspaceDir, "devclaw"); + const logDir = path.join(dataDir, "log"); + await fs.mkdir(logDir, { recursive: true }); + + // Build project + const defaultWorkers: Record = { + developer: emptyWorkerState(["junior", "medior", "senior"]), + tester: emptyWorkerState(["junior", "medior", "senior"]), + architect: emptyWorkerState(["junior", "senior"]), + }; + + // Apply worker overrides + if (workerOverrides) { + for (const [role, overrides] of Object.entries(workerOverrides)) { + if (defaultWorkers[role]) { + defaultWorkers[role] = { ...defaultWorkers[role], ...overrides }; + } else { + defaultWorkers[role] = { ...emptyWorkerState([]), ...overrides }; + } + } + } + + const project: Project = { + name: projectName, + repo, + groupName: "Test Group", + deployUrl: "", + baseBranch, + deployBranch: baseBranch, + provider: "github", + workers: defaultWorkers, + }; + + const projectsData: ProjectsData = { + projects: { + [groupId]: project, + ...extraProjects, + }, + }; + + await writeProjects(workspaceDir, projectsData); + + // Install mock runCommand + const { interceptor, handler } = createCommandInterceptor(); + initRunCommand({ + runtime: { + system: { runCommandWithTimeout: handler }, + }, + } as unknown as OpenClawPluginApi); + + // Create test provider + const provider = new TestProvider({ workflow }); + + return { + workspaceDir, + provider, + commands: interceptor, + groupId, + project, + workflow, + async writeProjects(data: ProjectsData) { + await writeProjects(workspaceDir, data); + }, + async readProjects() { + const { readProjects } = await import("../projects.js"); + return readProjects(workspaceDir); + }, + async writePrompt(role: string, content: string, forProject?: string) { + const dir = forProject + ? path.join(dataDir, "projects", forProject, "prompts") + : path.join(dataDir, "prompts"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, `${role}.md`), content, "utf-8"); + }, + async simulateBootstrap(sessionKey: string) { + // Capture the hook callback by mocking api.registerHook + let hookCallback: ((event: any) => Promise) | null = null; + const mockApi = { + registerHook(_name: string, cb: (event: any) => Promise) { + hookCallback = cb; + }, + logger: { + info() {}, + warn() {}, + error() {}, + }, + } as unknown as OpenClawPluginApi; + + registerBootstrapHook(mockApi); + if (!hookCallback) throw new Error("registerBootstrapHook did not register a callback"); + + // Build a bootstrap event matching what OpenClaw sends + const bootstrapFiles: BootstrapFile[] = []; + await hookCallback({ + sessionKey, + context: { + workspaceDir, + bootstrapFiles, + }, + }); + + return bootstrapFiles; + }, + async cleanup() { + await fs.rm(workspaceDir, { recursive: true, force: true }); + }, + }; +} diff --git a/lib/testing/index.ts b/lib/testing/index.ts new file mode 100644 index 0000000..b5c4f94 --- /dev/null +++ b/lib/testing/index.ts @@ -0,0 +1,16 @@ +/** + * testing/ — Test infrastructure for DevClaw integration tests. + * + * Exports: + * - TestProvider: In-memory IssueProvider with call tracking + * - createTestHarness: Scaffolds temp workspace + mock runCommand + */ +export { TestProvider, type ProviderCall } from "./test-provider.js"; +export { + createTestHarness, + type TestHarness, + type HarnessOptions, + type CommandInterceptor, + type CapturedCommand, + type BootstrapFile, +} from "./harness.js"; diff --git a/lib/testing/test-provider.ts b/lib/testing/test-provider.ts new file mode 100644 index 0000000..17a5331 --- /dev/null +++ b/lib/testing/test-provider.ts @@ -0,0 +1,224 @@ +/** + * TestProvider — In-memory IssueProvider for integration tests. + * + * Tracks all method calls for assertion. Issues are stored in a simple map. + * No external dependencies — pure TypeScript. + */ +import type { + IssueProvider, + Issue, + StateLabel, + IssueComment, + PrStatus, +} from "../providers/provider.js"; +import { getStateLabels } from "../workflow.js"; +import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; + +// --------------------------------------------------------------------------- +// Call tracking +// --------------------------------------------------------------------------- + +export type ProviderCall = + | { method: "ensureLabel"; args: { name: string; color: string } } + | { method: "ensureAllStateLabels"; args: {} } + | { method: "createIssue"; args: { title: string; description: string; label: StateLabel; assignees?: string[] } } + | { method: "listIssuesByLabel"; args: { label: StateLabel } } + | { method: "getIssue"; args: { issueId: number } } + | { method: "listComments"; args: { issueId: number } } + | { method: "transitionLabel"; args: { issueId: number; from: StateLabel; to: StateLabel } } + | { method: "closeIssue"; args: { issueId: number } } + | { method: "reopenIssue"; args: { issueId: number } } + | { method: "hasMergedMR"; args: { issueId: number } } + | { method: "getMergedMRUrl"; args: { issueId: number } } + | { method: "getPrStatus"; args: { issueId: number } } + | { method: "addComment"; args: { issueId: number; body: string } } + | { method: "healthCheck"; args: {} }; + +// --------------------------------------------------------------------------- +// TestProvider +// --------------------------------------------------------------------------- + +export class TestProvider implements IssueProvider { + /** All issues keyed by iid. */ + issues = new Map(); + /** Comments per issue. */ + comments = new Map(); + /** Labels that have been ensured. */ + labels = new Map(); + /** PR status overrides per issue. Default: { state: "closed", url: null }. */ + prStatuses = new Map(); + /** Merged MR URLs per issue. */ + mergedMrUrls = new Map(); + /** All calls, in order. */ + calls: ProviderCall[] = []; + + private nextIssueId = 1; + private workflow: WorkflowConfig; + + constructor(opts?: { workflow?: WorkflowConfig }) { + this.workflow = opts?.workflow ?? DEFAULT_WORKFLOW; + } + + // ------------------------------------------------------------------------- + // Test helpers + // ------------------------------------------------------------------------- + + /** Create an issue directly in the store (bypasses createIssue tracking). */ + seedIssue(overrides: Partial & { iid: number }): Issue { + const issue: Issue = { + iid: overrides.iid, + title: overrides.title ?? `Issue #${overrides.iid}`, + description: overrides.description ?? "", + labels: overrides.labels ?? [], + state: overrides.state ?? "opened", + web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`, + }; + this.issues.set(issue.iid, issue); + if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1; + return issue; + } + + /** Set PR status for an issue (used by review pass tests). */ + setPrStatus(issueId: number, status: PrStatus): void { + this.prStatuses.set(issueId, status); + } + + /** Get calls filtered by method name. */ + callsTo( + method: M, + ): Extract[] { + return this.calls.filter((c) => c.method === method) as any; + } + + /** Reset call tracking (keeps issue state). */ + resetCalls(): void { + this.calls = []; + } + + /** Full reset — clear everything. */ + reset(): void { + this.issues.clear(); + this.comments.clear(); + this.labels.clear(); + this.prStatuses.clear(); + this.mergedMrUrls.clear(); + this.calls = []; + this.nextIssueId = 1; + } + + // ------------------------------------------------------------------------- + // IssueProvider implementation + // ------------------------------------------------------------------------- + + async ensureLabel(name: string, color: string): Promise { + this.calls.push({ method: "ensureLabel", args: { name, color } }); + this.labels.set(name, color); + } + + async ensureAllStateLabels(): Promise { + this.calls.push({ method: "ensureAllStateLabels", args: {} }); + const stateLabels = getStateLabels(this.workflow); + for (const label of stateLabels) { + this.labels.set(label, "#000000"); + } + } + + async createIssue( + title: string, + description: string, + label: StateLabel, + assignees?: string[], + ): Promise { + this.calls.push({ method: "createIssue", args: { title, description, label, assignees } }); + const iid = this.nextIssueId++; + const issue: Issue = { + iid, + title, + description, + labels: [label], + state: "opened", + web_url: `https://example.com/issues/${iid}`, + }; + this.issues.set(iid, issue); + return issue; + } + + async listIssuesByLabel(label: StateLabel): Promise { + this.calls.push({ method: "listIssuesByLabel", args: { label } }); + return [...this.issues.values()].filter((i) => i.labels.includes(label)); + } + + async getIssue(issueId: number): Promise { + this.calls.push({ method: "getIssue", args: { issueId } }); + const issue = this.issues.get(issueId); + if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`); + return issue; + } + + async listComments(issueId: number): Promise { + this.calls.push({ method: "listComments", args: { issueId } }); + return this.comments.get(issueId) ?? []; + } + + async transitionLabel( + issueId: number, + from: StateLabel, + to: StateLabel, + ): Promise { + this.calls.push({ method: "transitionLabel", args: { issueId, from, to } }); + const issue = this.issues.get(issueId); + if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`); + // Remove all state labels, add the new one + const stateLabels = getStateLabels(this.workflow); + issue.labels = issue.labels.filter((l) => !stateLabels.includes(l)); + issue.labels.push(to); + } + + async closeIssue(issueId: number): Promise { + this.calls.push({ method: "closeIssue", args: { issueId } }); + const issue = this.issues.get(issueId); + if (issue) issue.state = "closed"; + } + + async reopenIssue(issueId: number): Promise { + this.calls.push({ method: "reopenIssue", args: { issueId } }); + const issue = this.issues.get(issueId); + if (issue) issue.state = "opened"; + } + + hasStateLabel(issue: Issue, expected: StateLabel): boolean { + return issue.labels.includes(expected); + } + + getCurrentStateLabel(issue: Issue): StateLabel | null { + const stateLabels = getStateLabels(this.workflow); + return stateLabels.find((l) => issue.labels.includes(l)) ?? null; + } + + async hasMergedMR(issueId: number): Promise { + this.calls.push({ method: "hasMergedMR", args: { issueId } }); + return this.mergedMrUrls.has(issueId); + } + + async getMergedMRUrl(issueId: number): Promise { + this.calls.push({ method: "getMergedMRUrl", args: { issueId } }); + return this.mergedMrUrls.get(issueId) ?? null; + } + + async getPrStatus(issueId: number): Promise { + this.calls.push({ method: "getPrStatus", args: { issueId } }); + return this.prStatuses.get(issueId) ?? { state: "closed", url: null }; + } + + async addComment(issueId: number, body: string): Promise { + this.calls.push({ method: "addComment", args: { issueId, body } }); + const existing = this.comments.get(issueId) ?? []; + existing.push({ author: "test", body, created_at: new Date().toISOString() }); + this.comments.set(issueId, existing); + } + + async healthCheck(): Promise { + this.calls.push({ method: "healthCheck", args: {} }); + return true; + } +} diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 1d83d75..650627c 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -15,6 +15,7 @@ import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; +import { ExecutionMode } from "../workflow.js"; import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; @@ -84,7 +85,7 @@ export function createProjectRegisterTool() { }, roleExecution: { type: "string", - enum: ["parallel", "sequential"], + enum: Object.values(ExecutionMode), description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.", }, }, @@ -99,7 +100,7 @@ export function createProjectRegisterTool() { const baseBranch = params.baseBranch as string; const deployBranch = (params.deployBranch as string) ?? baseBranch; const deployUrl = (params.deployUrl as string) ?? ""; - const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel"; + const roleExecution = (params.roleExecution as ExecutionMode) ?? ExecutionMode.PARALLEL; const workspaceDir = ctx.workspaceDir; if (!workspaceDir) { diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 63ca82e..18b908e 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -9,6 +9,7 @@ import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { runSetup, type SetupOpts } from "../setup/index.js"; import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js"; +import { ExecutionMode } from "../workflow.js"; export function createSetupTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -51,7 +52,7 @@ export function createSetupTool(api: OpenClawPluginApi) { }, projectExecution: { type: "string", - enum: ["parallel", "sequential"], + enum: Object.values(ExecutionMode), description: "Project execution mode. Default: parallel.", }, }, @@ -68,8 +69,7 @@ export function createSetupTool(api: OpenClawPluginApi) { workspacePath: params.newAgentName ? undefined : ctx.workspaceDir, models: params.models as SetupOpts["models"], projectExecution: params.projectExecution as - | "parallel" - | "sequential" + | ExecutionMode | undefined, }); diff --git a/lib/tools/status.ts b/lib/tools/status.ts index 88aa31f..e76c10b 100644 --- a/lib/tools/status.ts +++ b/lib/tools/status.ts @@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js"; import { log as auditLog } from "../audit.js"; import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js"; import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js"; -import { loadWorkflow } from "../workflow.js"; +import { loadWorkflow, ExecutionMode } from "../workflow.js"; export function createStatusTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -30,7 +30,7 @@ export function createStatusTool(api: OpenClawPluginApi) { const groupId = params.projectGroupId as string | undefined; const pluginConfig = getPluginConfig(api); - const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel"; + const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL; // Load workspace-level workflow (per-project loaded inside map) const workflow = await loadWorkflow(workspaceDir); @@ -66,7 +66,7 @@ export function createStatusTool(api: OpenClawPluginApi) { return { name: project.name, groupId: pid, - roleExecution: project.roleExecution ?? "parallel", + roleExecution: project.roleExecution ?? ExecutionMode.PARALLEL, workers, queue: queueCounts, }; diff --git a/lib/tools/task-update.test.ts b/lib/tools/task-update.test.ts index 5eeb50a..7137b8f 100644 --- a/lib/tools/task-update.test.ts +++ b/lib/tools/task-update.test.ts @@ -27,10 +27,11 @@ describe("task_update tool", () => { "Done", "To Improve", "Refining", + "In Review", ]; - + // In a real test, we'd verify these against the tool's enum - assert.strictEqual(validStates.length, 8); + assert.strictEqual(validStates.length, 9); }); it("validates required parameters", () => { diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index df44cab..2fa708b 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -12,10 +12,10 @@ import type { StateLabel } from "../providers/provider.js"; import { selectLevel } from "../model-selector.js"; import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; +import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/queue-scan.js"; import { getAllRoleIds, isLevelForRole } from "../roles/index.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; -import { loadWorkflow, getActiveLabel } from "../workflow.js"; +import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js"; export function createWorkStartTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -70,7 +70,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { // Check worker availability const worker = getWorker(project, role); if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`); - if ((project.roleExecution ?? "parallel") === "sequential") { + if ((project.roleExecution ?? ExecutionMode.PARALLEL) === ExecutionMode.SEQUENTIAL) { for (const [otherRole, otherWorker] of Object.entries(project.workers)) { if (otherRole !== role && otherWorker.active) { throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`); diff --git a/lib/workflow.ts b/lib/workflow.ts index 46882f7..c8ef085 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -13,14 +13,60 @@ // Types // --------------------------------------------------------------------------- -export type StateType = "queue" | "active" | "hold" | "terminal"; +/** Built-in state types. */ +export const StateType = { + QUEUE: "queue", + ACTIVE: "active", + HOLD: "hold", + TERMINAL: "terminal", + REVIEW: "review", +} as const; +export type StateType = (typeof StateType)[keyof typeof StateType]; + +/** Built-in execution modes for role and project parallelism. */ +export const ExecutionMode = { + PARALLEL: "parallel", + SEQUENTIAL: "sequential", +} as const; +export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode]; + /** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ export type Role = string; -export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue"; +/** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */ +export type TransitionAction = string; + +/** Built-in transition actions. Custom actions are also valid — these are just the ones with built-in handlers. */ +export const Action = { + GIT_PULL: "gitPull", + DETECT_PR: "detectPr", + CLOSE_ISSUE: "closeIssue", + REOPEN_ISSUE: "reopenIssue", +} as const; + +/** Built-in review check types for review states. */ +export const ReviewCheck = { + PR_APPROVED: "prApproved", + PR_MERGED: "prMerged", +} as const; +export type ReviewCheckType = (typeof ReviewCheck)[keyof typeof ReviewCheck]; + +/** Built-in workflow events. */ +export const WorkflowEvent = { + PICKUP: "PICKUP", + COMPLETE: "COMPLETE", + REVIEW: "REVIEW", + APPROVED: "APPROVED", + PASS: "PASS", + FAIL: "FAIL", + REFINE: "REFINE", + BLOCKED: "BLOCKED", + APPROVE: "APPROVE", +} as const; export type TransitionTarget = string | { target: string; actions?: TransitionAction[]; + description?: string; }; export type StateConfig = { @@ -29,6 +75,8 @@ export type StateConfig = { label: string; color: string; priority?: number; + description?: string; + check?: ReviewCheckType; on?: Record; }; @@ -40,10 +88,7 @@ export type WorkflowConfig = { export type CompletionRule = { from: string; to: string; - gitPull?: boolean; - detectPr?: boolean; - closeIssue?: boolean; - reopenIssue?: boolean; + actions: string[]; }; // --------------------------------------------------------------------------- @@ -54,84 +99,95 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { initial: "planning", states: { planning: { - type: "hold", + type: StateType.HOLD, label: "Planning", color: "#95a5a6", - on: { APPROVE: "todo" }, + on: { [WorkflowEvent.APPROVE]: "todo" }, }, todo: { - type: "queue", + type: StateType.QUEUE, role: "developer", label: "To Do", color: "#428bca", priority: 1, - on: { PICKUP: "doing" }, + on: { [WorkflowEvent.PICKUP]: "doing" }, }, doing: { - type: "active", + type: StateType.ACTIVE, role: "developer", label: "Doing", color: "#f0ad4e", on: { - COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] }, - BLOCKED: "refining", + [WorkflowEvent.COMPLETE]: { target: "toTest", actions: [Action.GIT_PULL, Action.DETECT_PR] }, + [WorkflowEvent.REVIEW]: { target: "reviewing", actions: [Action.DETECT_PR] }, + [WorkflowEvent.BLOCKED]: "refining", }, }, toTest: { - type: "queue", + type: StateType.QUEUE, role: "tester", label: "To Test", color: "#5bc0de", priority: 2, - on: { PICKUP: "testing" }, + on: { [WorkflowEvent.PICKUP]: "testing" }, }, testing: { - type: "active", + type: StateType.ACTIVE, role: "tester", label: "Testing", color: "#9b59b6", on: { - PASS: { target: "done", actions: ["closeIssue"] }, - FAIL: { target: "toImprove", actions: ["reopenIssue"] }, - REFINE: "refining", - BLOCKED: "refining", + [WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, + [WorkflowEvent.REFINE]: "refining", + [WorkflowEvent.BLOCKED]: "refining", }, }, toImprove: { - type: "queue", + type: StateType.QUEUE, role: "developer", label: "To Improve", color: "#d9534f", priority: 3, - on: { PICKUP: "doing" }, + on: { [WorkflowEvent.PICKUP]: "doing" }, }, refining: { - type: "hold", + type: StateType.HOLD, label: "Refining", color: "#f39c12", - on: { APPROVE: "todo" }, + on: { [WorkflowEvent.APPROVE]: "todo" }, + }, + reviewing: { + type: StateType.REVIEW, + label: "In Review", + color: "#c5def5", + check: ReviewCheck.PR_MERGED, + on: { + [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.GIT_PULL] }, + [WorkflowEvent.BLOCKED]: "refining", + }, }, done: { - type: "terminal", + type: StateType.TERMINAL, label: "Done", color: "#5cb85c", }, toDesign: { - type: "queue", + type: StateType.QUEUE, role: "architect", label: "To Design", color: "#0075ca", priority: 1, - on: { PICKUP: "designing" }, + on: { [WorkflowEvent.PICKUP]: "designing" }, }, designing: { - type: "active", + type: StateType.ACTIVE, role: "architect", label: "Designing", color: "#d4c5f9", on: { - COMPLETE: "planning", - BLOCKED: "refining", + [WorkflowEvent.COMPLETE]: "planning", + [WorkflowEvent.BLOCKED]: "refining", }, }, }, @@ -181,7 +237,7 @@ export function getLabelColors(workflow: WorkflowConfig): Record */ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] { return Object.values(workflow.states) - .filter((s) => s.type === "queue" && s.role === role) + .filter((s) => s.type === StateType.QUEUE && s.role === role) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) .map((s) => s.label); } @@ -191,7 +247,7 @@ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] { */ export function getAllQueueLabels(workflow: WorkflowConfig): string[] { return Object.values(workflow.states) - .filter((s) => s.type === "queue") + .filter((s) => s.type === StateType.QUEUE) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) .map((s) => s.label); } @@ -201,7 +257,7 @@ export function getAllQueueLabels(workflow: WorkflowConfig): string[] { */ export function getActiveLabel(workflow: WorkflowConfig, role: Role): string { const state = Object.values(workflow.states).find( - (s) => s.type === "active" && s.role === role, + (s) => s.type === StateType.ACTIVE && s.role === role, ); if (!state) throw new Error(`No active state for role "${role}"`); return state.label; @@ -219,8 +275,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { // Find queue states that transition to this active state for (const [, state] of Object.entries(workflow.states)) { - if (state.type !== "queue" || state.role !== role) continue; - const pickup = state.on?.PICKUP; + if (state.type !== StateType.QUEUE || state.role !== role) continue; + const pickup = state.on?.[WorkflowEvent.PICKUP]; if (pickup === activeStateKey) { return state.label; } @@ -235,7 +291,7 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { */ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null { for (const state of Object.values(workflow.states)) { - if (state.label === label && state.type === "queue" && state.role) { + if (state.label === label && state.type === StateType.QUEUE && state.role) { return state.role; } } @@ -247,7 +303,7 @@ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Ro */ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean { return Object.values(workflow.states).some( - (s) => s.label === label && s.type === "queue", + (s) => s.label === label && s.type === StateType.QUEUE, ); } @@ -256,7 +312,7 @@ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean { */ export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean { return Object.values(workflow.states).some( - (s) => s.label === label && s.type === "active", + (s) => s.label === label && s.type === StateType.ACTIVE, ); } @@ -283,7 +339,8 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st * Convention: "done" → COMPLETE, others → uppercase. */ function resultToEvent(result: string): string { - if (result === "done") return "COMPLETE"; + if (result === "done") return WorkflowEvent.COMPLETE; + if (result === "review") return WorkflowEvent.REVIEW; return result.toUpperCase(); } @@ -320,10 +377,7 @@ export function getCompletionRule( return { from: activeLabel, to: targetState.label, - gitPull: actions?.includes("gitPull"), - detectPr: actions?.includes("detectPr"), - closeIssue: actions?.includes("closeIssue"), - reopenIssue: actions?.includes("reopenIssue"), + actions: actions ?? [], }; } @@ -342,9 +396,10 @@ export function getNextStateDescription( const targetState = findStateByLabel(workflow, rule.to); if (!targetState) return ""; - if (targetState.type === "terminal") return "Done!"; - if (targetState.type === "hold") return "awaiting human decision"; - if (targetState.type === "queue" && targetState.role) { + if (targetState.type === StateType.TERMINAL) return "Done!"; + if (targetState.type === StateType.REVIEW) return "awaiting PR review"; + if (targetState.type === StateType.HOLD) return "awaiting human decision"; + if (targetState.type === StateType.QUEUE && targetState.role) { return `${targetState.role.toUpperCase()} queue`; } @@ -357,6 +412,7 @@ export function getNextStateDescription( */ const RESULT_EMOJI: Record = { done: "✅", + review: "👀", pass: "🎉", fail: "❌", refine: "🤔", diff --git a/package-lock.json b/package-lock.json index 54fe18b..7d31ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.2.2", "license": "MIT", "dependencies": { - "yaml": "^2.8.2" + "cockatiel": "^3.2.1", + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.2.3", @@ -3769,6 +3771,15 @@ "node": ">= 8" } }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8789,7 +8800,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 01a15d3..f063e05 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "typescript": "^5.9.3" }, "dependencies": { - "yaml": "^2.8.2" + "cockatiel": "^3.2.1", + "yaml": "^2.8.2", + "zod": "^4.3.6" } } From f7aa47102f60825c04d710dd1cc15e6c02f47a60 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 13:55:38 +0800 Subject: [PATCH 07/13] refactor: rename QA role to Tester and update related documentation - Updated role references from "QA" to "Tester" in workflow and code comments. - Revised documentation to reflect the new role structure, including role instructions and completion rules. - Enhanced the testing guide with clearer instructions and examples for unit and E2E tests. - Improved tools reference to align with the new role definitions and completion rules. - Adjusted the roadmap to highlight recent changes in role configuration and workflow state machine. --- docs/ARCHITECTURE.md | 339 ++++++++++++++++++++----------- docs/CONFIGURATION.md | 382 ++++++++++++++++++++++++++--------- docs/MANAGEMENT.md | 43 ++-- docs/ONBOARDING.md | 102 ++++++---- docs/QA_WORKFLOW.md | 20 +- docs/ROADMAP.md | 97 +++++---- docs/TESTING.md | 459 ++++++++++++++++++------------------------ docs/TOOLS.md | 120 ++++++----- 8 files changed, 928 insertions(+), 634 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 14ec94a..5968d98 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,22 +10,22 @@ graph TB direction TB A_O["Orchestrator"] A_GL[GitHub/GitLab Issues] - A_DEV["DEV (worker session)"] - A_QA["QA (worker session)"] + A_DEV["DEVELOPER (worker session)"] + A_TST["TESTER (worker session)"] A_O -->|work_start| A_GL A_O -->|dispatches| A_DEV - A_O -->|dispatches| A_QA + A_O -->|dispatches| A_TST end subgraph "Group Chat B" direction TB B_O["Orchestrator"] B_GL[GitHub/GitLab Issues] - B_DEV["DEV (worker session)"] - B_QA["QA (worker session)"] + B_DEV["DEVELOPER (worker session)"] + B_TST["TESTER (worker session)"] B_O -->|work_start| B_GL B_O -->|dispatches| B_DEV - B_O -->|dispatches| B_QA + B_O -->|dispatches| B_TST end AGENT["Single OpenClaw Agent"] @@ -33,7 +33,7 @@ graph TB AGENT --- B_O ``` -Worker sessions are expensive to start — each new spawn reads the full codebase (~50K tokens). DevClaw maintains **separate sessions per level per role** ([session-per-level design](#session-per-level-design)). When a medior dev finishes task A and picks up task B on the same project, the accumulated context carries over — no re-reading the repo. The plugin handles all session dispatch internally via OpenClaw CLI; the orchestrator agent never calls `sessions_spawn` or `sessions_send`. +Worker sessions are expensive to start — each new spawn reads the full codebase (~50K tokens). DevClaw maintains **separate sessions per level per role** ([session-per-level design](#session-per-level-design)). When a medior developer finishes task A and picks up task B on the same project, the accumulated context carries over — no re-reading the repo. The plugin handles all session dispatch internally via OpenClaw CLI; the orchestrator agent never calls `sessions_spawn` or `sessions_send`. ```mermaid sequenceDiagram @@ -42,7 +42,7 @@ sequenceDiagram participant IT as Issue Tracker participant S as Worker Session - O->>DC: work_start({ issueId: 42, role: "dev" }) + O->>DC: work_start({ issueId: 42, role: "developer" }) DC->>IT: Fetch issue, verify label DC->>DC: Assign level (junior/medior/senior) DC->>DC: Check existing session for assigned level @@ -62,19 +62,20 @@ Understanding the OpenClaw model is key to understanding how DevClaw works: ### Session-per-level design -Each project maintains **separate sessions per developer level per role**. A project's DEV might have a junior session, a medior session, and a senior session — each accumulating its own codebase context over time. +Each project maintains **separate sessions per developer level per role**. A project's DEVELOPER might have a junior session, a medior session, and a senior session — each accumulating its own codebase context over time. ``` Orchestrator Agent (configured in openclaw.json) └─ Main session (long-lived, handles all projects) │ ├─ Project A - │ ├─ DEV sessions: { junior: , medior: , senior: null } - │ └─ QA sessions: { reviewer: , tester: null } + │ ├─ DEVELOPER sessions: { junior: , medior: , senior: null } + │ ├─ TESTER sessions: { junior: null, medior: , senior: null } + │ └─ ARCHITECT sessions: { junior: , senior: null } │ └─ Project B - ├─ DEV sessions: { junior: null, medior: , senior: null } - └─ QA sessions: { reviewer: , tester: null } + ├─ DEVELOPER sessions: { junior: null, medior: , senior: null } + └─ TESTER sessions: { junior: null, medior: , senior: null } ``` Why per-level instead of switching models on one session: @@ -114,6 +115,18 @@ The agent's only job after `work_start` returns is to post the announcement to T DevClaw provides equivalent guardrails for everything except auto-reporting, which the heartbeat handles. +## Roles + +DevClaw ships with three built-in roles, defined in `lib/roles/registry.ts`. All roles use the same level scheme (junior/medior/senior) — levels describe task complexity, not the role. + +| Role | ID | Levels | Default Level | Completion Results | +|---|---|---|---|---| +| Developer | `developer` | junior, medior, senior | medior | done, review, blocked | +| Tester | `tester` | junior, medior, senior | medior | pass, fail, refine, blocked | +| Architect | `architect` | junior, senior | junior | done, blocked | + +Roles are extensible — add a new entry to `ROLE_REGISTRY` and corresponding workflow states to get a new role. The `workflow.yaml` config can also override levels, models, and emoji per role, or disable a role entirely (`architect: false`). + ## System overview ```mermaid @@ -127,10 +140,11 @@ graph TB MS[Main Session
orchestrator agent] GW[Gateway RPC
sessions.patch / sessions.list] CLI[openclaw gateway call agent] - DEV_J[DEV session
junior] - DEV_M[DEV session
medior] - DEV_S[DEV session
senior] - QA_R[QA session
reviewer] + DEV_J[DEVELOPER session
junior] + DEV_M[DEVELOPER session
medior] + DEV_S[DEVELOPER session
senior] + TST_M[TESTER session
medior] + ARCH[ARCHITECT session
junior] end subgraph "DevClaw Plugin" @@ -196,12 +210,13 @@ graph TB CLI -->|sends task| DEV_J CLI -->|sends task| DEV_M CLI -->|sends task| DEV_S - CLI -->|sends task| QA_R + CLI -->|sends task| TST_M + CLI -->|sends task| ARCH - DEV_J -->|writes code, creates MRs| REPO - DEV_M -->|writes code, creates MRs| REPO - DEV_S -->|writes code, creates MRs| REPO - QA_R -->|reviews code, tests| REPO + DEV_J -->|writes code, creates PRs| REPO + DEV_M -->|writes code, creates PRs| REPO + DEV_S -->|writes code, creates PRs| REPO + TST_M -->|reviews code, tests| REPO ``` ## End-to-end flow: human to sub-agent @@ -216,7 +231,7 @@ sequenceDiagram participant DC as DevClaw Plugin participant GW as Gateway RPC participant CLI as openclaw gateway call agent - participant DEV as DEV Session
(medior) + participant DEV as DEVELOPER Session
(medior) participant GL as Issue Tracker Note over H,GL: Issue exists in queue (To Do) @@ -225,51 +240,51 @@ sequenceDiagram TG->>MS: delivers message MS->>DC: status() DC->>GL: list issues by label "To Do" - DC-->>MS: { toDo: [#42], dev: idle } + DC-->>MS: { toDo: [#42], developer: idle } - Note over MS: Decides to pick up #42 for DEV as medior + Note over MS: Decides to pick up #42 for DEVELOPER as medior - MS->>DC: work_start({ issueId: 42, role: "dev", level: "medior", ... }) + MS->>DC: work_start({ issueId: 42, role: "developer", level: "medior", ... }) DC->>DC: resolve level "medior" → model ID - DC->>DC: lookup dev.sessions.medior → null (first time) + DC->>DC: lookup developer.sessions.medior → null (first time) DC->>GL: transition label "To Do" → "Doing" DC->>GW: sessions.patch({ key: new-session-key, model: "anthropic/claude-sonnet-4-5" }) DC->>CLI: openclaw gateway call agent --params { sessionKey, message } CLI->>DEV: creates session, delivers task DC->>DC: store session key in projects.json + append audit.log - DC-->>MS: { success: true, announcement: "🔧 Spawning DEV (medior) for #42" } + DC-->>MS: { success: true, announcement: "🔧 Spawning DEVELOPER (medior) for #42" } - MS->>TG: "🔧 Spawning DEV (medior) for #42: Add login page" + MS->>TG: "🔧 Spawning DEVELOPER (medior) for #42: Add login page" TG->>H: sees announcement - Note over DEV: Works autonomously — reads code, writes code, creates MR + Note over DEV: Works autonomously — reads code, writes code, creates PR Note over DEV: Calls work_finish when done - DEV->>DC: work_finish({ role: "dev", result: "done", ... }) + DEV->>DC: work_finish({ role: "developer", result: "done", ... }) DC->>GL: transition label "Doing" → "To Test" DC->>DC: deactivate worker (sessions preserved) - DC-->>DEV: { announcement: "✅ DEV DONE #42" } + DC-->>DEV: { announcement: "✅ DEVELOPER DONE #42" } - MS->>TG: "✅ DEV DONE #42 — moved to QA queue" + MS->>TG: "✅ DEVELOPER DONE #42 — moved to TESTER queue" TG->>H: sees announcement ``` -On the **next DEV task** for this project that also assigns medior: +On the **next DEVELOPER task** for this project that also assigns medior: ```mermaid sequenceDiagram participant MS as Main Session participant DC as DevClaw Plugin participant CLI as openclaw gateway call agent - participant DEV as DEV Session
(medior, existing) + participant DEV as DEVELOPER Session
(medior, existing) - MS->>DC: work_start({ issueId: 57, role: "dev", level: "medior", ... }) + MS->>DC: work_start({ issueId: 57, role: "developer", level: "medior", ... }) DC->>DC: resolve level "medior" → model ID - DC->>DC: lookup dev.sessions.medior → existing key! + DC->>DC: lookup developer.sessions.medior → existing key! Note over DC: No sessions.patch needed — session already exists DC->>CLI: openclaw gateway call agent --params { sessionKey, message } CLI->>DEV: delivers task to existing session (has full codebase context) - DC-->>MS: { success: true, announcement: "⚡ Sending DEV (medior) for #57" } + DC-->>MS: { success: true, announcement: "⚡ Sending DEVELOPER (medior) for #57" } ``` Session reuse saves ~50K tokens per task by not re-reading the codebase. @@ -304,7 +319,7 @@ sequenceDiagram A->>QS: status({ projectGroupId: "-123" }) QS->>PJ: readProjects() - PJ-->>QS: { dev: idle, qa: idle } + PJ-->>QS: { developer: idle, tester: idle } QS->>GL: list issues by label "To Do" GL-->>QS: [{ id: 42, title: "Add login page" }] QS->>GL: list issues by label "To Test" @@ -312,12 +327,12 @@ sequenceDiagram QS->>GL: list issues by label "To Improve" GL-->>QS: [] QS->>AL: append { event: "status", ... } - QS-->>A: { dev: idle, queue: { toDo: [#42] } } + QS-->>A: { developer: idle, queue: { toDo: [#42] } } ``` -**Orchestrator decides:** DEV is idle, issue #42 is in To Do → pick it up. Evaluates complexity → assigns medior level. +**Orchestrator decides:** DEVELOPER is idle, issue #42 is in To Do → pick it up. Evaluates complexity → assigns medior level. -### Phase 3: DEV pickup +### Phase 3: DEVELOPER pickup The plugin handles everything end-to-end — level resolution, session lookup, label transition, state update, **and** task dispatch to the worker session. The agent's only job after is to post the announcement. @@ -332,13 +347,13 @@ sequenceDiagram participant PJ as projects.json participant AL as audit.log - A->>WS: work_start({ issueId: 42, role: "dev", projectGroupId: "-123", level: "medior" }) + A->>WS: work_start({ issueId: 42, role: "developer", projectGroupId: "-123", level: "medior" }) WS->>PJ: readProjects() WS->>GL: getIssue(42) GL-->>WS: { title: "Add login page", labels: ["To Do"] } WS->>WS: Verify label is "To Do" WS->>TIER: resolve "medior" → "anthropic/claude-sonnet-4-5" - WS->>PJ: lookup dev.sessions.medior + WS->>PJ: lookup developer.sessions.medior WS->>GL: transitionLabel(42, "To Do", "Doing") alt New session WS->>GW: sessions.patch({ key: new-key, model: "anthropic/claude-sonnet-4-5" }) @@ -351,98 +366,116 @@ sequenceDiagram **Writes:** - `Issue Tracker`: label "To Do" → "Doing" -- `projects.json`: dev.active=true, dev.issueId="42", dev.level="medior", dev.sessions.medior=key +- `projects.json`: workers.developer.active=true, issueId="42", level="medior", sessions.medior=key - `audit.log`: 2 entries (work_start, model_selection) - `Session`: task message delivered to worker session via CLI -### Phase 4: DEV works +### Phase 4: DEVELOPER works ``` -DEV sub-agent session → reads codebase, writes code, creates MR -DEV sub-agent session → calls work_finish({ role: "dev", result: "done", ... }) +DEVELOPER sub-agent session → reads codebase, writes code, creates PR +DEVELOPER sub-agent session → calls work_finish({ role: "developer", result: "done", ... }) ``` This happens inside the OpenClaw session. The worker calls `work_finish` directly for atomic state updates. If the worker discovers unrelated bugs, it calls `task_create` to file them. -### Phase 5: DEV complete (worker self-reports) +### Phase 5: DEVELOPER complete (worker self-reports) ```mermaid sequenceDiagram - participant DEV as DEV Session + participant DEV as DEVELOPER Session participant WF as work_finish participant GL as Issue Tracker participant PJ as projects.json participant AL as audit.log participant REPO as Git Repo - participant QA as QA Session - DEV->>WF: work_finish({ role: "dev", result: "done", projectGroupId: "-123", summary: "Login page with OAuth" }) + DEV->>WF: work_finish({ role: "developer", result: "done", projectGroupId: "-123", summary: "Login page with OAuth" }) WF->>PJ: readProjects() - PJ-->>WF: { dev: { active: true, issueId: "42" } } + PJ-->>WF: { developer: { active: true, issueId: "42" } } WF->>REPO: git pull - WF->>PJ: deactivateWorker(-123, dev) + WF->>PJ: deactivateWorker(-123, developer) Note over PJ: active→false, issueId→null
sessions map PRESERVED WF->>GL: transitionLabel "Doing" → "To Test" - WF->>AL: append { event: "work_finish", role: "dev", result: "done" } + WF->>AL: append { event: "work_finish", role: "developer", result: "done" } WF->>WF: tick queue (fill free slots) - Note over WF: Scheduler sees "To Test" issue, QA slot free → dispatches QA - WF-->>DEV: { announcement: "✅ DEV DONE #42", tickPickups: [...] } + Note over WF: Scheduler sees "To Test" issue, TESTER slot free → dispatches TESTER + WF-->>DEV: { announcement: "✅ DEVELOPER DONE #42", tickPickups: [...] } ``` **Writes:** -- `Git repo`: pulled latest (has DEV's merged code) -- `projects.json`: dev.active=false, dev.issueId=null (sessions map preserved for reuse) +- `Git repo`: pulled latest (has DEVELOPER's merged code) +- `projects.json`: workers.developer.active=false, issueId=null (sessions map preserved for reuse) - `Issue Tracker`: label "Doing" → "To Test" - `audit.log`: 1 entry (work_finish) + tick entries if workers dispatched -### Phase 6: QA pickup +### Phase 5b: DEVELOPER requests review (alternative path) -Same as Phase 3, but with `role: "qa"`. Label transitions "To Test" → "Testing". Uses the reviewer level. - -### Phase 7: QA result (4 possible outcomes) - -#### 7a. QA Pass +Instead of merging the PR themselves, a developer can leave it open for human review: ```mermaid sequenceDiagram - participant QA as QA Session + participant DEV as DEVELOPER Session + participant WF as work_finish + participant GL as Issue Tracker + participant PJ as projects.json + + DEV->>WF: work_finish({ role: "developer", result: "review", ... }) + WF->>GL: transitionLabel "Doing" → "In Review" + WF->>PJ: deactivateWorker (sessions preserved) + WF-->>DEV: { announcement: "👀 DEVELOPER REVIEW #42" } +``` + +The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been merged, then automatically transitions to "To Test". + +### Phase 6: TESTER pickup + +Same as Phase 3, but with `role: "tester"`. Label transitions "To Test" → "Testing". Level selection determines which tester session is used. + +### Phase 7: TESTER result (4 possible outcomes) + +#### 7a. TESTER Pass + +```mermaid +sequenceDiagram + participant TST as TESTER Session participant WF as work_finish participant GL as Issue Tracker participant PJ as projects.json participant AL as audit.log - QA->>WF: work_finish({ role: "qa", result: "pass", projectGroupId: "-123" }) - WF->>PJ: deactivateWorker(-123, qa) + TST->>WF: work_finish({ role: "tester", result: "pass", projectGroupId: "-123" }) + WF->>PJ: deactivateWorker(-123, tester) WF->>GL: transitionLabel(42, "Testing", "Done") WF->>GL: closeIssue(42) - WF->>AL: append { event: "work_finish", role: "qa", result: "pass" } - WF-->>QA: { announcement: "🎉 QA PASS #42. Issue closed." } + WF->>AL: append { event: "work_finish", role: "tester", result: "pass" } + WF-->>TST: { announcement: "🎉 TESTER PASS #42. Issue closed." } ``` **Ticket complete.** Issue closed, label "Done". -#### 7b. QA Fail +#### 7b. TESTER Fail ```mermaid sequenceDiagram - participant QA as QA Session + participant TST as TESTER Session participant WF as work_finish participant GL as Issue Tracker participant PJ as projects.json participant AL as audit.log - QA->>WF: work_finish({ role: "qa", result: "fail", projectGroupId: "-123", summary: "OAuth redirect broken" }) - WF->>PJ: deactivateWorker(-123, qa) + TST->>WF: work_finish({ role: "tester", result: "fail", projectGroupId: "-123", summary: "OAuth redirect broken" }) + WF->>PJ: deactivateWorker(-123, tester) WF->>GL: transitionLabel(42, "Testing", "To Improve") WF->>GL: reopenIssue(42) - WF->>AL: append { event: "work_finish", role: "qa", result: "fail" } - WF-->>QA: { announcement: "❌ QA FAIL #42 — OAuth redirect broken. Sent back to DEV." } + WF->>AL: append { event: "work_finish", role: "tester", result: "fail" } + WF-->>TST: { announcement: "❌ TESTER FAIL #42 — OAuth redirect broken. Sent back to DEVELOPER." } ``` -**Cycle restarts:** Issue goes to "To Improve". Next heartbeat, DEV picks it up again (Phase 3, but from "To Improve" instead of "To Do"). +**Cycle restarts:** Issue goes to "To Improve". Next heartbeat, DEVELOPER picks it up again (Phase 3, but from "To Improve" instead of "To Do"). -#### 7c. QA Refine +#### 7c. TESTER Refine ``` Label: "Testing" → "Refining" @@ -450,14 +483,14 @@ Label: "Testing" → "Refining" Issue needs human decision. Pipeline pauses until human moves it to "To Do" or closes it. -#### 7d. Blocked (DEV or QA) +#### 7d. Blocked (DEVELOPER or TESTER) ``` -DEV Blocked: "Doing" → "To Do" -QA Blocked: "Testing" → "To Test" +DEVELOPER Blocked: "Doing" → "Refining" +TESTER Blocked: "Testing" → "Refining" ``` -Worker cannot complete (missing info, environment errors, etc.). Issue returns to queue for retry. The task is available for the next heartbeat pickup. +Worker cannot complete (missing info, environment errors, etc.). Issue enters hold state for human decision. The human can move it back to "To Do" to retry or take other action. ### Completion enforcement @@ -465,18 +498,19 @@ Three layers guarantee that `work_finish` always runs: 1. **Completion contract** — Every task message sent to a worker session includes a mandatory `## MANDATORY: Task Completion` section listing available results and requiring `work_finish` even on failure. Workers are instructed to use `"blocked"` if stuck. -2. **Blocked result** — Both DEV and QA can use `"blocked"` to gracefully return a task to queue without losing work. DEV blocked: `Doing → To Do`. QA blocked: `Testing → To Test`. This gives workers an escape hatch instead of silently dying. +2. **Blocked result** — All roles can use `"blocked"` to gracefully hand off to a human. Developer blocked: `Doing → Refining`. Tester blocked: `Testing → Refining`. This gives workers an escape hatch instead of silently dying. 3. **Stale worker watchdog** — The heartbeat's health check detects workers active for >2 hours. With `fix=true`, it deactivates the worker and reverts the label back to queue. This catches sessions that crashed, ran out of context, or otherwise failed without calling `work_finish`. The `health` tool provides the same check for manual invocation. ### Phase 8: Heartbeat (continuous) -The heartbeat runs periodically (via background service or manual `work_heartbeat` trigger). It combines health check + queue scan: +The heartbeat runs periodically (via background service or manual `work_heartbeat` trigger). It combines health check + review polling + queue scan: ```mermaid sequenceDiagram participant HB as Heartbeat Service participant SH as health check + participant RV as review pass participant TK as projectTick participant WS as work_start (dispatch) Note over HB: Tick triggered (every 60s) @@ -485,6 +519,10 @@ sequenceDiagram Note over SH: Checks for zombies, stale workers SH-->>HB: { fixes applied } + HB->>RV: reviewPass per project + Note over RV: Polls PR status for "In Review" issues + RV-->>HB: { transitions made } + HB->>TK: projectTick per project Note over TK: Scans queue: To Improve > To Test > To Do TK->>WS: dispatchTask (fill free slots) @@ -492,6 +530,31 @@ sequenceDiagram TK-->>HB: { pickups, skipped } ``` +## Worker instructions (bootstrap hook) + +Role-specific instructions (coding standards, deployment steps, completion rules) are injected into worker sessions via the `agent:bootstrap` hook — not appended to the task message. + +```mermaid +sequenceDiagram + participant GW as Gateway + participant BH as Bootstrap Hook + participant FS as Filesystem + + Note over GW: Worker session starts + GW->>BH: agent:bootstrap event (sessionKey, bootstrapFiles[]) + BH->>BH: Parse session key → { projectName, role } + BH->>FS: Load role instructions (project-specific → default) + FS-->>BH: content + source path + BH->>BH: Push WORKER_INSTRUCTIONS.md into bootstrapFiles + BH-->>GW: bootstrapFiles now includes role instructions +``` + +**Resolution order:** +1. `devclaw/projects//prompts/.md` (project-specific) +2. `devclaw/prompts/.md` (workspace default) + +The source path is logged for production traceability: `Bootstrap hook: injected developer instructions for project "my-app" from /path/to/prompts/developer.md`. + ## Data flow map Every piece of data and where it lives: @@ -503,15 +566,16 @@ Every piece of data and where it lives: │ Issue #42: "Add login page" │ │ Labels: [Planning | To Do | Doing | To Test | Testing | ...] │ │ State: open / closed │ -│ MRs/PRs: linked merge/pull requests │ +│ PRs: linked pull/merge requests (status polled for In Review) │ │ Created by: orchestrator (task_create), workers, or humans │ └─────────────────────────────────────────────────────────────────┘ ↕ gh/glab CLI (read/write, auto-detected) + ↕ cockatiel resilience: retry + circuit breaker ┌─────────────────────────────────────────────────────────────────┐ │ DevClaw Plugin (orchestration logic) │ │ │ │ setup → agent creation + workspace + model config │ -│ work_start → level + label + dispatch + role instr (e2e) │ +│ work_start → level + label + dispatch (e2e) │ │ work_finish → label + state + git pull + tick queue │ │ task_create → create issue in tracker │ │ task_update → manual label state change │ @@ -519,27 +583,38 @@ Every piece of data and where it lives: │ status → read labels + read state │ │ health → check sessions + fix zombies │ │ project_register → labels + prompts + state init (one-time) │ +│ design_task → architect dispatch │ +│ │ +│ Bootstrap hook → injects role instructions into worker sessions│ +│ Review pass → polls PR status, auto-advances In Review │ +│ Config loader → three-layer merge + Zod validation │ └─────────────────────────────────────────────────────────────────┘ ↕ atomic file I/O ↕ OpenClaw CLI (plugin shells out) ┌────────────────────────────────┐ ┌──────────────────────────────┐ -│ projects/projects.json │ │ OpenClaw Gateway + CLI │ +│ devclaw/projects.json │ │ OpenClaw Gateway + CLI │ │ │ │ (called by plugin, not agent)│ │ Per project: │ │ │ -│ dev: │ │ openclaw gateway call │ -│ active, issueId, level │ │ sessions.patch → create │ -│ sessions: │ │ sessions.list → health │ -│ junior: │ │ sessions.delete → cleanup │ -│ medior: │ │ │ -│ senior: │ │ openclaw gateway call agent │ -│ qa: │ │ --params { sessionKey, │ -│ active, issueId, level │ │ message, agentId } │ -│ sessions: │ │ → dispatches to session │ -│ reviewer: │ │ │ -│ tester: │ │ │ +│ workers: │ │ openclaw gateway call │ +│ developer: │ │ sessions.patch → create │ +│ active, issueId, level │ │ sessions.list → health │ +│ sessions: │ │ sessions.delete → cleanup │ +│ junior: │ │ │ +│ medior: │ │ openclaw gateway call agent │ +│ senior: │ │ --params { sessionKey, │ +│ tester: │ │ message, agentId } │ +│ active, issueId, level │ │ → dispatches to session │ +│ sessions: │ │ │ +│ junior: │ │ │ +│ medior: │ │ │ +│ senior: │ │ │ +│ architect: │ │ │ +│ sessions: │ │ │ +│ junior: │ │ │ +│ senior: │ │ │ └────────────────────────────────┘ └──────────────────────────────┘ ↕ append-only ┌─────────────────────────────────────────────────────────────────┐ -│ log/audit.log (observability) │ +│ devclaw/log/audit.log (observability) │ │ │ │ NDJSON, one line per event: │ │ work_start, work_finish, model_selection, │ @@ -553,21 +628,23 @@ Every piece of data and where it lives: │ Telegram / WhatsApp (user-facing messages) │ │ │ │ Per group chat: │ -│ "🔧 Spawning DEV (medior) for #42: Add login page" │ -│ "⚡ Sending DEV (medior) for #57: Fix validation" │ -│ "✅ DEV DONE #42 — Login page with OAuth." │ -│ "🎉 QA PASS #42. Issue closed." │ -│ "❌ QA FAIL #42 — OAuth redirect broken." │ -│ "🚫 DEV BLOCKED #42 — Missing dependencies." │ -│ "🚫 QA BLOCKED #42 — Env not available." │ +│ "🔧 Spawning DEVELOPER (medior) for #42: Add login page" │ +│ "⚡ Sending DEVELOPER (medior) for #57: Fix validation" │ +│ "✅ DEVELOPER DONE #42 — Login page with OAuth." │ +│ "👀 DEVELOPER REVIEW #42 — PR open for review." │ +│ "🎉 TESTER PASS #42. Issue closed." │ +│ "❌ TESTER FAIL #42 — OAuth redirect broken." │ +│ "🚫 DEVELOPER BLOCKED #42 — Missing dependencies." │ +│ "🚫 TESTER BLOCKED #42 — Env not available." │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ Git Repository (codebase) │ │ │ -│ DEV sub-agent sessions: read code, write code, create MRs │ -│ QA sub-agent sessions: read code, run tests, review MRs │ -│ work_finish (DEV done): git pull to sync latest │ +│ DEVELOPER sub-agent sessions: read code, write code, create PRs│ +│ TESTER sub-agent sessions: read code, run tests, review PRs │ +│ ARCHITECT sub-agent sessions: research, design, recommend │ +│ work_finish (developer done): git pull to sync latest │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -584,9 +661,12 @@ graph LR SETUP[Agent + workspace setup] SD[Session dispatch
create + send via CLI] AC[Scheduling
tick queue after work_finish] - RI[Role instructions
loaded per project] + RI[Role instructions
injected via bootstrap hook] + RV[Review polling
PR status → auto-advance] A[Audit logging] Z[Zombie cleanup] + CFG[Config validation
Zod + integrity checks] + RES[Provider resilience
retry + circuit breaker] end subgraph "Orchestrator handles (planning only)" @@ -600,7 +680,7 @@ graph LR subgraph "Sub-agent sessions handle" CR[Code writing] - MR[MR creation/review] + MR[PR creation/review] WF_W[Task completion
via work_finish] BUG[Bug filing
via task_create] end @@ -611,7 +691,7 @@ graph LR end ``` -**Key boundary:** The orchestrator is a planner and dispatcher — it never writes code. All implementation work (code edits, git operations, tests) must go through sub-agent sessions via the `task_create` → `work_start` pipeline. This ensures audit trails, tier selection, and QA review for every code change. +**Key boundary:** The orchestrator is a planner and dispatcher — it never writes code. All implementation work (code edits, git operations, tests) must go through sub-agent sessions via the `task_create` → `work_start` pipeline. This ensures audit trails, level selection, and testing for every code change. ## IssueProvider abstraction @@ -624,10 +704,13 @@ All issue tracker operations go through the `IssueProvider` interface, defined i - `transitionLabel` — atomic label state transition (unlabel + label) - `closeIssue` / `reopenIssue` — issue lifecycle - `hasStateLabel` / `getCurrentStateLabel` — label inspection +- `getPrStatus` — get PR/MR state (open, merged, approved, none) - `hasMergedMR` / `getMergedMRUrl` — MR/PR verification - `addComment` — add comment to issue - `healthCheck` — verify provider connectivity +**Provider resilience:** All provider calls are wrapped with cockatiel retry (3 attempts, exponential backoff) + circuit breaker (opens after 5 consecutive failures, half-opens after 30s). See `lib/providers/resilience.ts`. + **Current providers:** - **GitHub** (`lib/providers/github.ts`) — wraps `gh` CLI - **GitLab** (`lib/providers/gitlab.ts`) — wraps `glab` CLI @@ -637,19 +720,34 @@ All issue tracker operations go through the `IssueProvider` interface, defined i Provider selection is handled by `createProvider()` in `lib/providers/index.ts`. Auto-detects GitHub vs GitLab from the git remote URL. +## Configuration system + +DevClaw uses a three-layer config system with `workflow.yaml` files: + +``` +Layer 1: Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW) +Layer 2: Workspace: /devclaw/workflow.yaml +Layer 3: Project: /devclaw/projects//workflow.yaml +``` + +Each layer can override roles (levels, models, emoji), workflow states/transitions, and timeouts. Config is validated with Zod schemas at load time, with cross-reference integrity checks (transition targets exist, queue states have roles, terminal states have no outgoing transitions). + +See [CONFIGURATION.md](CONFIGURATION.md) for the full reference. + ## Error recovery | Failure | Detection | Recovery | |---|---|---| | Session dies mid-task | `health` checks via `sessions.list` Gateway RPC | `fix=true`: reverts label, clears active state. Next heartbeat picks up task again (creates fresh session for that level). | -| gh/glab command fails | Plugin tool throws error, returns to agent | Agent retries or reports to Telegram group | +| gh/glab command fails | Cockatiel retry (3 attempts), then circuit breaker | Circuit opens after 5 consecutive failures, prevents hammering. Plugin catches and returns error. | | `openclaw gateway call agent` fails | Plugin catches error during dispatch | Plugin rolls back: reverts label, clears active state. Returns error. No orphaned state. | | `sessions.patch` fails | Plugin catches error during session creation | Plugin rolls back label transition. Returns error. | -| projects.json corrupted | Tool can't parse JSON | Manual fix needed. Atomic writes (temp+rename) prevent partial writes. | +| projects.json corrupted | Tool can't parse JSON | Manual fix needed. Atomic writes (temp+rename) prevent partial writes. File locking prevents concurrent races. | | Label out of sync | `work_start` verifies label before transitioning | Throws error if label doesn't match expected state. | -| Worker already active | `work_start` checks `active` flag | Throws error: "DEV already active on project". Must complete current task first. | +| Worker already active | `work_start` checks `active` flag | Throws error: "DEVELOPER already active on project". Must complete current task first. | | Stale worker (>2h) | `health` and heartbeat health check | `fix=true`: deactivates worker, reverts label to queue. Task available for next pickup. | -| Worker stuck/blocked | Worker calls `work_finish` with `"blocked"` | Deactivates worker, reverts label to queue. Issue available for retry. | +| Worker stuck/blocked | Worker calls `work_finish` with `"blocked"` | Deactivates worker, transitions to "Refining" (hold state). Requires human decision to proceed. | +| Config invalid | Zod schema validation at load time | Clear error message with field path. Prevents startup with broken config. | | `project_register` fails | Plugin catches error during label creation or state write | Clean error returned. Labels are idempotent, projects.json not written until all labels succeed. | ## File locations @@ -659,8 +757,11 @@ Provider selection is handled by `createProvider()` in `lib/providers/index.ts`. | Plugin source | `~/.openclaw/extensions/devclaw/` | Plugin code | | Plugin manifest | `~/.openclaw/extensions/devclaw/openclaw.plugin.json` | Plugin registration | | Agent config | `~/.openclaw/openclaw.json` | Agent definition + tool permissions + model config | -| Worker state | `~/.openclaw/workspace-/projects/projects.json` | Per-project DEV/QA state | -| Role instructions | `~/.openclaw/workspace-/projects/roles//` | Per-project `dev.md` and `qa.md` | -| Audit log | `~/.openclaw/workspace-/log/audit.log` | NDJSON event log | +| Worker state | `/devclaw/projects.json` | Per-project worker state | +| Workflow config (workspace) | `/devclaw/workflow.yaml` | Workspace-level role/workflow overrides | +| Workflow config (project) | `/devclaw/projects//workflow.yaml` | Project-specific overrides | +| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `tester.md`, `architect.md` | +| Project role instructions | `/devclaw/projects//prompts/.md` | Per-project role instruction overrides | +| Audit log | `/devclaw/log/audit.log` | NDJSON event log | | Session transcripts | `~/.openclaw/agents//sessions/.jsonl` | Conversation history per session | | Git repos | `~/git//` | Project source code | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e4323b7..aacddf4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,54 +1,236 @@ # DevClaw — Configuration Reference -All DevClaw configuration lives in two places: `openclaw.json` (plugin-level settings) and `projects.json` (per-project state). +DevClaw uses a three-layer configuration system. All role, workflow, and timeout settings live in `workflow.yaml` files — not in `openclaw.json`. -## Plugin Configuration (`openclaw.json`) +## Three-Layer Config Resolution -DevClaw is configured under `plugins.entries.devclaw.config` in `openclaw.json`. - -### Model Tiers - -Override which LLM model powers each developer level: - -```json -{ - "plugins": { - "entries": { - "devclaw": { - "config": { - "models": { - "dev": { - "junior": "anthropic/claude-haiku-4-5", - "medior": "anthropic/claude-sonnet-4-5", - "senior": "anthropic/claude-opus-4-5" - }, - "qa": { - "reviewer": "anthropic/claude-sonnet-4-5", - "tester": "anthropic/claude-haiku-4-5" - } - } - } - } - } - } -} +``` +Layer 1: Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW) +Layer 2: Workspace: /devclaw/workflow.yaml +Layer 3: Project: /devclaw/projects//workflow.yaml ``` -**Resolution order** (per `lib/tiers.ts:resolveModel`): +Each layer can partially override the one below it. Only the fields you specify are merged — everything else inherits from the layer below. -1. Plugin config `models..` — explicit override -2. `DEFAULT_MODELS[role][level]` — built-in defaults (table below) -3. Passthrough — treat the level string as a raw model ID +**Source:** [`lib/config/loader.ts`](../lib/config/loader.ts) + +**Validation:** Config is validated at load time with Zod schemas ([`lib/config/schema.ts`](../lib/config/schema.ts)). Integrity checks verify transition targets exist, queue states have roles, and terminal states have no outgoing transitions. + +--- + +## Workflow Config (`workflow.yaml`) + +The `workflow.yaml` file configures roles, workflow states, and timeouts. Place it at `/devclaw/workflow.yaml` for workspace-wide settings, or at `/devclaw/projects//workflow.yaml` for project-specific overrides. + +### Role Configuration + +Override which LLM model powers each level, customize levels, or disable roles entirely: + +```yaml +roles: + developer: + models: + junior: anthropic/claude-haiku-4-5 + medior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + tester: + models: + junior: anthropic/claude-haiku-4-5 + medior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + architect: + models: + junior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + # Disable a role entirely: + # architect: false +``` + +**Role override fields** (all optional — only override what you need): + +| Field | Type | Description | +|---|---|---| +| `levels` | string[] | Available levels for this role | +| `defaultLevel` | string | Default level when not specified | +| `models` | Record | Model ID per level | +| `emoji` | Record | Emoji per level for announcements | +| `completionResults` | string[] | Valid completion results | **Default models:** -| Role | Level | Default model | +| Role | Level | Default Model | |---|---|---| -| dev | junior | `anthropic/claude-haiku-4-5` | -| dev | medior | `anthropic/claude-sonnet-4-5` | -| dev | senior | `anthropic/claude-opus-4-5` | -| qa | reviewer | `anthropic/claude-sonnet-4-5` | -| qa | tester | `anthropic/claude-haiku-4-5` | +| developer | junior | `anthropic/claude-haiku-4-5` | +| developer | medior | `anthropic/claude-sonnet-4-5` | +| developer | senior | `anthropic/claude-opus-4-6` | +| tester | junior | `anthropic/claude-haiku-4-5` | +| tester | medior | `anthropic/claude-sonnet-4-5` | +| tester | senior | `anthropic/claude-opus-4-6` | +| architect | junior | `anthropic/claude-sonnet-4-5` | +| architect | senior | `anthropic/claude-opus-4-6` | + +**Source:** [`lib/roles/registry.ts`](../lib/roles/registry.ts) + +**Model resolution order:** + +1. Project `workflow.yaml` → `roles..models.` +2. Workspace `workflow.yaml` → `roles..models.` +3. Built-in defaults from `ROLE_REGISTRY` +4. Passthrough — treat the level string as a raw model ID + +### Workflow States + +The workflow section defines the state machine for issue lifecycle. Each state has a type, label, color, and optional transitions: + +```yaml +workflow: + initial: planning + states: + planning: + type: hold + label: Planning + color: "#95a5a6" + on: + APPROVE: todo + todo: + type: queue + role: developer + label: To Do + color: "#428bca" + priority: 1 + on: + PICKUP: doing + doing: + type: active + role: developer + label: Doing + color: "#f0ad4e" + on: + COMPLETE: + target: toTest + actions: [gitPull, detectPr] + REVIEW: + target: reviewing + actions: [detectPr] + BLOCKED: refining + toTest: + type: queue + role: tester + label: To Test + color: "#5bc0de" + priority: 2 + on: + PICKUP: testing + testing: + type: active + role: tester + label: Testing + color: "#9b59b6" + on: + PASS: + target: done + actions: [closeIssue] + FAIL: + target: toImprove + actions: [reopenIssue] + REFINE: refining + BLOCKED: refining + toImprove: + type: queue + role: developer + label: To Improve + color: "#d9534f" + priority: 3 + on: + PICKUP: doing + refining: + type: hold + label: Refining + color: "#f39c12" + on: + APPROVE: todo + reviewing: + type: review + label: In Review + color: "#c5def5" + check: prMerged + on: + APPROVED: + target: toTest + actions: [gitPull] + BLOCKED: refining + done: + type: terminal + label: Done + color: "#5cb85c" + toDesign: + type: queue + role: architect + label: To Design + color: "#0075ca" + priority: 1 + on: + PICKUP: designing + designing: + type: active + role: architect + label: Designing + color: "#d4c5f9" + on: + COMPLETE: planning + BLOCKED: refining +``` + +**State types:** + +| Type | Description | +|---|---| +| `queue` | Waiting for pickup. Must have a `role`. Has `priority` for ordering. | +| `active` | Worker is currently working on it. Must have a `role`. | +| `hold` | Paused, awaiting human decision. | +| `review` | Awaiting external check (PR merged/approved). Has `check` field. | +| `terminal` | Completed. No outgoing transitions. | + +**Built-in actions:** + +| Action | Description | +|---|---| +| `gitPull` | Pull latest from the base branch | +| `detectPr` | Auto-detect PR URL from the issue | +| `closeIssue` | Close the issue | +| `reopenIssue` | Reopen the issue | + +**Review checks:** + +| Check | Description | +|---|---| +| `prMerged` | Transition when the issue's PR is merged | +| `prApproved` | Transition when the issue's PR is approved or merged | + +### Timeouts + +```yaml +timeouts: + gitPullMs: 30000 + gatewayMs: 120000 + sessionPatchMs: 120000 + dispatchMs: 120000 + staleWorkerHours: 2 +``` + +| Setting | Default | Description | +|---|---|---| +| `gitPullMs` | 30000 | Timeout for git pull operations | +| `gatewayMs` | 120000 | Timeout for gateway RPC calls | +| `sessionPatchMs` | 120000 | Timeout for session creation | +| `dispatchMs` | 120000 | Timeout for task dispatch | +| `staleWorkerHours` | 2 | Hours before a worker is considered stale | + +--- + +## Plugin Configuration (`openclaw.json`) + +Some settings still live in `openclaw.json` under `plugins.entries.devclaw.config`: ### Project Execution Mode @@ -73,8 +255,6 @@ Controls cross-project parallelism: | `"parallel"` (default) | Multiple projects can have active workers simultaneously | | `"sequential"` | Only one project's workers active at a time. Useful for single-agent deployments. | -Enforced in `work_heartbeat` and the heartbeat service before dispatching. - ### Heartbeat Service Token-free interval-based health checks + queue dispatch: @@ -105,7 +285,7 @@ Token-free interval-based health checks + queue dispatch: **Source:** [`lib/services/heartbeat.ts`](../lib/services/heartbeat.ts) -The heartbeat service runs as a plugin service tied to the gateway lifecycle. Every tick: health pass (auto-fix zombies, stale workers) → tick pass (fill free slots by priority). Zero LLM tokens consumed. +The heartbeat service runs as a plugin service tied to the gateway lifecycle. Every tick: health pass (auto-fix zombies, stale workers) → review pass (poll PR status for "In Review" issues) → tick pass (fill free slots by priority). Zero LLM tokens consumed. ### Notifications @@ -157,7 +337,8 @@ Restrict DevClaw tools to your orchestrator agent: "work_heartbeat", "project_register", "setup", - "onboard" + "onboard", + "design_task" ] } } @@ -170,7 +351,7 @@ Restrict DevClaw tools to your orchestrator agent: ## Project State (`projects.json`) -All project state lives in `/projects/projects.json`, keyed by group ID. +All project state lives in `/devclaw/projects.json`, keyed by group ID. **Source:** [`lib/projects.ts`](../lib/projects.ts) @@ -187,26 +368,40 @@ All project state lives in `/projects/projects.json`, keyed by group "deployBranch": "development", "deployUrl": "https://my-webapp.example.com", "channel": "telegram", + "provider": "github", "roleExecution": "parallel", - "dev": { - "active": false, - "issueId": null, - "startTime": null, - "level": null, - "sessions": { - "junior": null, - "medior": "agent:orchestrator:subagent:my-webapp-dev-medior", - "senior": null - } - }, - "qa": { - "active": false, - "issueId": null, - "startTime": null, - "level": null, - "sessions": { - "reviewer": "agent:orchestrator:subagent:my-webapp-qa-reviewer", - "tester": null + "workers": { + "developer": { + "active": false, + "issueId": null, + "startTime": null, + "level": null, + "sessions": { + "junior": null, + "medior": "agent:orchestrator:subagent:my-webapp-developer-medior", + "senior": null + } + }, + "tester": { + "active": false, + "issueId": null, + "startTime": null, + "level": null, + "sessions": { + "junior": null, + "medior": "agent:orchestrator:subagent:my-webapp-tester-medior", + "senior": null + } + }, + "architect": { + "active": false, + "issueId": null, + "startTime": null, + "level": null, + "sessions": { + "junior": null, + "senior": null + } } } } @@ -225,29 +420,28 @@ All project state lives in `/projects/projects.json`, keyed by group | `deployBranch` | string | Branch that triggers deployment | | `deployUrl` | string | Deployment URL | | `channel` | string | Messaging channel (`"telegram"`, `"whatsapp"`, etc.) | -| `roleExecution` | `"parallel"` \| `"sequential"` | DEV/QA parallelism for this project | +| `provider` | `"github"` \| `"gitlab"` | Issue tracker provider (auto-detected, stored for reuse) | +| `roleExecution` | `"parallel"` \| `"sequential"` | DEVELOPER/TESTER parallelism for this project | ### Worker state fields -Each project has `dev` and `qa` worker state objects: +Each role in the `workers` record has a `WorkerState` object: | Field | Type | Description | |---|---|---| | `active` | boolean | Whether this role has an active worker | | `issueId` | string \| null | Issue being worked on (as string) | | `startTime` | string \| null | ISO timestamp when worker became active | -| `level` | string \| null | Current level (`junior`, `medior`, `senior`, `reviewer`, `tester`) | +| `level` | string \| null | Current level (`junior`, `medior`, `senior`) | | `sessions` | Record | Per-level session keys | -**DEV session keys:** `junior`, `medior`, `senior` -**QA session keys:** `reviewer`, `tester` - ### Key design decisions - **Session-per-level** — each level gets its own worker session, accumulating context independently. Level selection maps directly to a session key. - **Sessions preserved on completion** — when a worker completes a task, the sessions map is preserved (only `active`, `issueId`, and `startTime` are cleared). This enables session reuse. -- **Atomic writes** — all writes go through temp-file-then-rename to prevent corruption. +- **Atomic writes** — all writes go through temp-file-then-rename to prevent corruption. File locking prevents concurrent read-modify-write races. - **Sessions persist indefinitely** — no auto-cleanup. The `health` tool handles manual cleanup. +- **Dynamic workers** — the `workers` record is keyed by role ID (e.g., `developer`, `tester`, `architect`). New roles are created automatically when dispatched. --- @@ -255,37 +449,43 @@ Each project has `dev` and `qa` worker state objects: ``` / -├── projects/ -│ ├── projects.json ← Project state (auto-managed) -│ └── roles/ -│ ├── my-webapp/ ← Per-project role instructions (editable) -│ │ ├── dev.md -│ │ └── qa.md -│ ├── another-project/ -│ │ ├── dev.md -│ │ └── qa.md -│ └── default/ ← Fallback role instructions -│ ├── dev.md -│ └── qa.md -├── log/ -│ └── audit.log ← NDJSON event log (auto-managed) -├── AGENTS.md ← Agent identity documentation -└── HEARTBEAT.md ← Heartbeat operation guide +├── devclaw/ +│ ├── projects.json ← Project state (auto-managed) +│ ├── workflow.yaml ← Workspace-level config overrides +│ ├── prompts/ +│ │ ├── developer.md ← Default developer instructions +│ │ ├── tester.md ← Default tester instructions +│ │ └── architect.md ← Default architect instructions +│ ├── projects/ +│ │ ├── my-webapp/ +│ │ │ ├── workflow.yaml ← Project-specific config overrides +│ │ │ └── prompts/ +│ │ │ ├── developer.md ← Project-specific developer instructions +│ │ │ ├── tester.md ← Project-specific tester instructions +│ │ │ └── architect.md ← Project-specific architect instructions +│ │ └── another-project/ +│ │ └── prompts/ +│ │ ├── developer.md +│ │ └── tester.md +│ └── log/ +│ └── audit.log ← NDJSON event log (auto-managed) +├── AGENTS.md ← Agent identity documentation +└── HEARTBEAT.md ← Heartbeat operation guide ``` ### Role instruction files -`work_start` loads role instructions from `projects/roles//.md` at dispatch time, falling back to `projects/roles/default/.md`. These files are appended to the task message sent to worker sessions. +Role instructions are injected into worker sessions via the `agent:bootstrap` hook at session startup. The hook loads instructions from `devclaw/projects//prompts/.md`, falling back to `devclaw/prompts/.md`. Edit to customize: deployment steps, test commands, acceptance criteria, coding standards. -**Source:** [`lib/dispatch.ts:loadRoleInstructions`](../lib/dispatch.ts) +**Source:** [`lib/bootstrap-hook.ts`](../lib/bootstrap-hook.ts) --- ## Audit Log -Append-only NDJSON at `/log/audit.log`. Auto-truncated to 250 lines. +Append-only NDJSON at `/devclaw/log/audit.log`. Auto-truncated to 250 lines. **Source:** [`lib/audit.ts`](../lib/audit.ts) @@ -331,6 +531,8 @@ DevClaw uses an `IssueProvider` interface (`lib/providers/provider.ts`) to abstr | GitHub | `gh` | Remote contains `github.com` | | GitLab | `glab` | Remote contains `gitlab` | +**Provider resilience:** All calls are wrapped with cockatiel retry (3 attempts, exponential backoff) + circuit breaker (opens after 5 consecutive failures, half-opens after 30s). See [`lib/providers/resilience.ts`](../lib/providers/resilience.ts). + **Planned:** Jira (via REST API) **Source:** [`lib/providers/index.ts`](../lib/providers/index.ts) diff --git a/docs/MANAGEMENT.md b/docs/MANAGEMENT.md index 1d6fa0c..c0643b3 100644 --- a/docs/MANAGEMENT.md +++ b/docs/MANAGEMENT.md @@ -19,7 +19,8 @@ DevClaw's level selection does exactly this. When a task comes in, the plugin ro | Simple (typos, renames, copy) | Junior | The intern — just execute | | Standard (features, bug fixes) | Medior | Mid-level — think and build | | Complex (architecture, security) | Senior | The architect — design and reason | -| Review | Reviewer | Independent code reviewer | + +All three roles — DEVELOPER, TESTER, and ARCHITECT — use the same junior/medior/senior scheme (architect uses junior/senior). The orchestrator picks the level per task, and the plugin resolves it to the appropriate model via the role registry and workflow config. This isn't just cost optimization. It mirrors what effective managers do instinctively: match the delegation level to the task, not to a fixed assumption about the delegate. @@ -27,14 +28,15 @@ This isn't just cost optimization. It mirrors what effective managers do instinc Classical management theory — later formalized by Bernard Bass in his work on Transformational Leadership — introduced a concept called Management by Exception (MBE). The principle: a manager should only be pulled back into a workstream when something deviates from the expected path. -DevClaw's task lifecycle is built on this. The orchestrator delegates a task via `work_start`, then steps away. It only re-engages in three scenarios: +DevClaw's task lifecycle is built on this. The orchestrator delegates a task via `work_start`, then steps away. It only re-engages in specific scenarios: -1. **DEV completes work** → The label moves to `To Test`. The scheduler dispatches QA on the next tick. No orchestrator involvement needed. -2. **QA passes** → The issue closes. Pipeline complete. -3. **QA fails** → The label moves to `To Improve`. The scheduler dispatches DEV on the next tick. The orchestrator may need to adjust the model level. -4. **QA refines** → The task enters a holding state that _requires human decision_. This is the explicit escalation boundary. +1. **DEVELOPER completes work** → The label moves to `To Test`. The scheduler dispatches TESTER on the next tick. No orchestrator involvement needed. +2. **DEVELOPER requests review** → The label moves to `In Review`. The heartbeat polls PR status. When merged, the scheduler dispatches TESTER. No orchestrator involvement needed. +3. **TESTER passes** → The issue closes. Pipeline complete. +4. **TESTER fails** → The label moves to `To Improve`. The scheduler dispatches DEVELOPER on the next tick. The orchestrator may need to adjust the level. +5. **Any role is blocked** → The task enters `Refining` — a holding state that _requires human decision_. This is the explicit escalation boundary. -The "refine" state is the most interesting from a delegation perspective. It's a conscious architectural decision that says: some judgments should not be automated. When the QA agent determines that a task needs rethinking rather than just fixing, it escalates to the only actor who has the full business context — the human. +The "Refining" state is the most interesting from a delegation perspective. It's a conscious architectural decision that says: some judgments should not be automated. When a TESTER determines that a task needs rethinking rather than just fixing, or when a DEVELOPER hits an obstacle that requires business context, it escalates to the only actor who has the full picture — the human. This is textbook MBE. The person behind the keyboard isn't monitoring every task. They're only pulled in when the system encounters something beyond its delegation authority. @@ -42,14 +44,17 @@ This is textbook MBE. The person behind the keyboard isn't monitoring every task Henry Mintzberg's work on organizational structure identified five coordination mechanisms. The one most relevant to DevClaw is **standardization of work processes** — when coordination happens not through direct supervision but through predetermined procedures that everyone follows. -DevClaw enforces a single, fixed lifecycle for every task across every project: +DevClaw enforces a configurable but consistent lifecycle for every task. The default workflow: ``` Planning → To Do → Doing → To Test → Testing → Done + ↘ In Review → (PR merged) → To Test ↘ To Improve → Doing (fix cycle) ↘ Refining → (human decision) ``` +The ARCHITECT role adds a parallel track: `To Design → Designing → Planning`. + Every label transition, state update, and audit log entry happens atomically inside the plugin. The orchestrator agent cannot skip a step, forget a label, or corrupt session state — because those operations are deterministic code, not instructions an LLM follows imperfectly. This is what allows a single orchestrator to manage multiple projects simultaneously. Management research has long debated the ideal span of control — typically cited as 5-9 direct reports for knowledge work. DevClaw sidesteps the constraint entirely by making every project follow identical processes. The orchestrator doesn't need to remember how Project A works versus Project B. They all work the same way. @@ -60,9 +65,11 @@ One of the most common delegation failures is self-review. You don't ask the per DevClaw enforces structural separation between development and review by design: -- DEV and QA are separate sub-agent sessions with separate state. -- QA uses the reviewer level, which can be a different model entirely, introducing genuine independence. -- The review happens after a clean label transition — QA picks up from `To Test`, not from watching DEV work in real time. +- DEVELOPER and TESTER are separate sub-agent sessions with separate state. +- TESTER can use a different model entirely (e.g. senior for security reviews, junior for smoke tests), introducing genuine independence. +- The review happens after a clean label transition — TESTER picks up from `To Test`, not from watching DEVELOPER work in real time. + +For higher-stakes changes, the DEVELOPER can submit a PR for human review (`result: "review"`). The issue enters `In Review` and the heartbeat polls the PR until it's merged — only then does TESTER receive the work. This adds a human checkpoint without breaking the automated flow. This mirrors a principle from organizational design: effective controls require independence between execution and verification. It's the same reason companies separate their audit function from their operations. @@ -72,7 +79,7 @@ Ronald Coase won a Nobel Prize for explaining why firms exist: transaction costs DevClaw applies the same logic to AI sessions. Spawning a new sub-agent session costs approximately 50,000 tokens of context loading — the agent needs to read the full codebase before it can do useful work. That's the onboarding cost. -The plugin tracks session keys across task completions. When a DEV finishes task A and task B is ready on the same project, DevClaw detects the existing session and reuses it instead of spawning a new one. No re-onboarding. No context reload. +The plugin tracks session keys across task completions. When a DEVELOPER finishes task A and task B is ready on the same project, DevClaw detects the existing session and reuses it instead of spawning a new one. No re-onboarding. No context reload. Each role maintains separate sessions per level, so a "medior developer" session accumulates project context independently from the "senior developer" session. In management terms: keep your team stable. Reassigning the same person to the next task on their project is almost always cheaper than bringing in someone new — even if the new person is theoretically better qualified. @@ -85,15 +92,15 @@ The obvious saving is execution time: AI writes code faster than a human. But th Without DevClaw, every task requires a human to make a series of small decisions: - Which model should handle this? -- Is the DEV session still alive, or do I need a new one? +- Is the DEVELOPER session still alive, or do I need a new one? - What label should this issue have now? - Did I update the state file? - Did I log this transition? -- Is the QA session free, or is it still working on something? +- Is the TESTER session free, or is it still working on something? None of these decisions are hard. But they accumulate. Each one consumes a small amount of the same cognitive resource you need for the decisions that actually matter — product direction, architecture choices, business priorities. -DevClaw eliminates entire categories of decisions by making them deterministic. The plugin picks the model. The plugin manages sessions. The plugin transitions labels. The plugin writes audit logs. The person behind the keyboard is left with only the decisions that require human judgment: what to build, what to prioritize, and what to do when QA says "this needs rethinking." +DevClaw eliminates entire categories of decisions by making them deterministic. The plugin picks the model. The plugin manages sessions. The plugin transitions labels. The plugin writes audit logs. The person behind the keyboard is left with only the decisions that require human judgment: what to build, what to prioritize, and what to do when a worker says "this needs rethinking." This is the deepest lesson from delegation theory: **good delegation isn't about getting someone else to do your work. It's about protecting your attention for the work only you can do.** @@ -101,11 +108,11 @@ This is the deepest lesson from delegation theory: **good delegation isn't about Management research points to a few directions that could extend DevClaw's delegation model: -**Progressive delegation.** Blanchard's model suggests increasing task complexity for delegates as they prove competent. DevClaw could track QA pass rates per model level and automatically promote — if junior consistently passes QA on borderline tasks, start routing more work to it. This is how good managers develop their people, and it reduces cost over time. +**Progressive delegation.** Blanchard's model suggests increasing task complexity for delegates as they prove competent. DevClaw could track TESTER pass rates per model level and automatically promote — if junior consistently passes TESTER on borderline tasks, start routing more work to it. This is how good managers develop their people, and it reduces cost over time. -**Delegation authority expansion.** The Vroom-Yetton decision model maps when a leader should decide alone versus consulting the team. Currently, sub-agents have narrow authority — they execute tasks but can't restructure the backlog. Selectively expanding this (e.g., allowing a DEV agent to split a task it judges too large) would reduce orchestrator bottlenecks, mirroring how managers gradually give high-performers more autonomy. +**Delegation authority expansion.** The Vroom-Yetton decision model maps when a leader should decide alone versus consulting the team. Currently, sub-agents have narrow authority — they execute tasks but can't restructure the backlog. Selectively expanding this (e.g., allowing a DEVELOPER agent to split a task it judges too large) would reduce orchestrator bottlenecks, mirroring how managers gradually give high-performers more autonomy. -**Outcome-based learning.** Delegation research emphasizes that the _delegator_ learns from outcomes too. Aggregated metrics — QA fail rate by model level, average cycles to Done, time-in-state distributions — would help both the orchestrator agent and the human calibrate their delegation patterns over time. +**Outcome-based learning.** Delegation research emphasizes that the _delegator_ learns from outcomes too. Aggregated metrics — TESTER fail rate by model level, average cycles to Done, time-in-state distributions — would help both the orchestrator agent and the human calibrate their delegation patterns over time. --- diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index df75cbd..bdb60c8 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -52,13 +52,16 @@ openclaw devclaw setup The setup wizard walks you through: 1. **Agent** — Create a new orchestrator agent or configure an existing one -2. **Developer team** — Choose which LLM model powers each developer level: - - **DEV junior** (fast, cheap tasks) — default: `anthropic/claude-haiku-4-5` - - **DEV medior** (standard tasks) — default: `anthropic/claude-sonnet-4-5` - - **DEV senior** (complex tasks) — default: `anthropic/claude-opus-4-5` - - **QA reviewer** (code review) — default: `anthropic/claude-sonnet-4-5` - - **QA tester** (manual testing) — default: `anthropic/claude-haiku-4-5` -3. **Workspace** — Writes AGENTS.md, HEARTBEAT.md, role templates, and initializes state +2. **Developer team** — Choose which LLM model powers each level: + - **Developer junior** (fast, cheap tasks) — default: `anthropic/claude-haiku-4-5` + - **Developer medior** (standard tasks) — default: `anthropic/claude-sonnet-4-5` + - **Developer senior** (complex tasks) — default: `anthropic/claude-opus-4-6` + - **Tester junior** (quick checks) — default: `anthropic/claude-haiku-4-5` + - **Tester medior** (standard review) — default: `anthropic/claude-sonnet-4-5` + - **Tester senior** (thorough review) — default: `anthropic/claude-opus-4-6` + - **Architect junior** (standard design) — default: `anthropic/claude-sonnet-4-5` + - **Architect senior** (complex architecture) — default: `anthropic/claude-opus-4-6` +3. **Workspace** — Writes AGENTS.md, HEARTBEAT.md, workflow.yaml, role templates, and initializes state Non-interactive mode: ```bash @@ -68,7 +71,7 @@ openclaw devclaw setup --new-agent "My Dev Orchestrator" # Configure existing agent with custom models openclaw devclaw setup --agent my-orchestrator \ --junior "anthropic/claude-haiku-4-5" \ - --senior "anthropic/claude-opus-4-5" + --senior "anthropic/claude-opus-4-6" ``` ### Option C: Tool call (agent-driven) @@ -86,12 +89,12 @@ setup({ "newAgentName": "My Dev Orchestrator", "channelBinding": "telegram", "models": { - "dev": { + "developer": { "junior": "anthropic/claude-haiku-4-5", - "senior": "anthropic/claude-opus-4-5" + "senior": "anthropic/claude-opus-4-6" }, - "qa": { - "reviewer": "anthropic/claude-sonnet-4-5" + "tester": { + "medior": "anthropic/claude-sonnet-4-5" } } }) @@ -151,8 +154,8 @@ Go to the Telegram/WhatsApp group for the project and tell the orchestrator agen The agent calls `project_register`, which atomically: - Validates the repo and auto-detects GitHub/GitLab from remote -- Creates all 8 state labels (idempotent) -- Scaffolds role instruction files (`projects/roles//dev.md` and `qa.md`) +- Creates all 11 state labels (idempotent) +- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `tester.md`, `architect.md`) - Adds the project entry to `projects.json` - Logs the registration event @@ -168,20 +171,30 @@ The agent calls `project_register`, which atomically: "baseBranch": "development", "deployBranch": "development", "channel": "telegram", + "provider": "github", "roleExecution": "parallel", - "dev": { - "active": false, - "issueId": null, - "startTime": null, - "level": null, - "sessions": { "junior": null, "medior": null, "senior": null } - }, - "qa": { - "active": false, - "issueId": null, - "startTime": null, - "level": null, - "sessions": { "reviewer": null, "tester": null } + "workers": { + "developer": { + "active": false, + "issueId": null, + "startTime": null, + "level": null, + "sessions": { "junior": null, "medior": null, "senior": null } + }, + "tester": { + "active": false, + "issueId": null, + "startTime": null, + "level": null, + "sessions": { "junior": null, "medior": null, "senior": null } + }, + "architect": { + "active": false, + "issueId": null, + "startTime": null, + "level": null, + "sessions": { "junior": null, "senior": null } + } } } } @@ -194,7 +207,7 @@ The agent calls `project_register`, which atomically: Issues can be created in multiple ways: - **Via the agent** — Ask the orchestrator in the Telegram group: "Create an issue for adding a login page" (uses `task_create`) -- **Via workers** — DEV/QA workers can call `task_create` to file follow-up bugs they discover +- **Via workers** — DEVELOPER/TESTER workers can call `task_create` to file follow-up bugs they discover - **Via CLI** — `cd ~/git/my-project && gh issue create --title "My first task" --label "To Do"` (or `glab issue create`) - **Via web UI** — Create an issue and add the "To Do" label @@ -208,9 +221,9 @@ Ask the agent in the Telegram group: The agent should call `status` and report the "To Do" issue. Then: -> "Pick up issue #1 for DEV" +> "Pick up issue #1 for developer" -The agent calls `work_start`, which assigns a developer level, transitions the label to "Doing", creates or reuses a worker session, and dispatches the task — all in one call. The agent posts the announcement. +The agent calls `work_start`, which assigns a level, transitions the label to "Doing", creates or reuses a worker session, and dispatches the task — all in one call. The agent posts the announcement. ## Adding more projects @@ -220,17 +233,20 @@ Each project is fully isolated — separate queue, separate workers, separate st ## Developer levels -DevClaw assigns tasks to developer levels instead of raw model names. This makes the system intuitive — you're assigning a "junior dev" to fix a typo, not configuring model parameters. +DevClaw assigns tasks to developer levels instead of raw model names. This makes the system intuitive — you're assigning a "junior" to fix a typo, not configuring model parameters. All roles use the same level scheme. -| Role | Level | Default model | When to assign | +| Role | Level | Default Model | When to assign | |------|-------|---------------|----------------| -| DEV | **junior** | `anthropic/claude-haiku-4-5` | Typos, single-file fixes, CSS changes | -| DEV | **medior** | `anthropic/claude-sonnet-4-5` | Features, bug fixes, multi-file changes | -| DEV | **senior** | `anthropic/claude-opus-4-5` | Architecture, migrations, system-wide refactoring | -| QA | **reviewer** | `anthropic/claude-sonnet-4-5` | Code review, test validation | -| QA | **tester** | `anthropic/claude-haiku-4-5` | Manual testing, smoke tests | +| Developer | **junior** | `anthropic/claude-haiku-4-5` | Typos, single-file fixes, CSS changes | +| Developer | **medior** | `anthropic/claude-sonnet-4-5` | Features, bug fixes, multi-file changes | +| Developer | **senior** | `anthropic/claude-opus-4-6` | Architecture, migrations, system-wide refactoring | +| Tester | **junior** | `anthropic/claude-haiku-4-5` | Quick smoke tests, basic checks | +| Tester | **medior** | `anthropic/claude-sonnet-4-5` | Standard code review, test validation | +| Tester | **senior** | `anthropic/claude-opus-4-6` | Thorough security review, complex edge cases | +| Architect | **junior** | `anthropic/claude-sonnet-4-5` | Standard design investigation | +| Architect | **senior** | `anthropic/claude-opus-4-6` | Complex architecture decisions | -Change which model powers each level in `openclaw.json` — see [Configuration](CONFIGURATION.md#model-tiers). +Change which model powers each level in `workflow.yaml` — see [Configuration](CONFIGURATION.md#role-configuration). ## What the plugin handles vs. what you handle @@ -239,17 +255,19 @@ Change which model powers each level in `openclaw.json` — see [Configuration]( | Plugin installation | You (once) | `openclaw plugins install @laurentenhoor/devclaw` | | Agent + workspace setup | Plugin (`setup`) | Creates agent, configures models, writes workspace files | | Channel binding migration | Plugin (`setup` with `migrateFrom`) | Automatically moves channel-wide bindings between agents | -| Label setup | Plugin (`project_register`) | 8 labels, created idempotently via IssueProvider | -| Prompt file scaffolding | Plugin (`project_register`) | Creates `projects/roles//dev.md` and `qa.md` | +| Label setup | Plugin (`project_register`) | 11 labels, created idempotently via IssueProvider | +| Prompt file scaffolding | Plugin (`project_register`) | Creates `devclaw/projects//prompts/.md` for each role | | Project registration | Plugin (`project_register`) | Entry in `projects.json` with empty worker state | | Telegram group setup | You (once per project) | Add bot to group | | Issue creation | Plugin (`task_create`) | Orchestrator or workers create issues from chat | | Label transitions | Plugin | Atomic transitions via issue tracker CLI | | Developer assignment | Plugin | LLM-selected level by orchestrator, keyword heuristic fallback | -| State management | Plugin | Atomic read/write to `projects.json` | +| State management | Plugin | Atomic read/write to `projects.json` with file locking | | Session management | Plugin | Creates, reuses, and dispatches to sessions via CLI. Agent never touches session tools. | | Task completion | Plugin (`work_finish`) | Workers self-report. Scheduler dispatches next role. | -| Prompt instructions | Plugin (`work_start`) | Loaded from `projects/roles//.md`, appended to task message | +| Role instructions | Plugin (bootstrap hook) | Injected into worker sessions via `agent:bootstrap` hook at session startup | +| Review polling | Plugin (heartbeat) | Auto-advances "In Review" issues when PR is merged | +| Config validation | Plugin | Zod schemas validate `workflow.yaml` at load time | | Audit logging | Plugin | Automatic NDJSON append per tool call | | Zombie detection | Plugin | `health` checks active vs alive | | Queue scanning | Plugin | `status` queries issue tracker per project | diff --git a/docs/QA_WORKFLOW.md b/docs/QA_WORKFLOW.md index 27fd659..8350978 100644 --- a/docs/QA_WORKFLOW.md +++ b/docs/QA_WORKFLOW.md @@ -20,7 +20,7 @@ task_comment({ projectGroupId: "", issueId: , body: "## QA Review\n\n**Tested:**\n- [List what you tested]\n\n**Results:**\n- [Pass/fail details]\n\n**Environment:**\n- [Test environment details]", - authorRole: "qa" + authorRole: "tester" }) ``` @@ -30,21 +30,21 @@ After posting your comment, call `work_finish`: ```javascript work_finish({ - role: "qa", + role: "tester", projectGroupId: "", result: "pass", // or "fail", "refine", "blocked" summary: "Brief summary of review outcome" }) ``` -## QA Results +## TESTER Results | Result | Label transition | Meaning | |---|---|---| | `"pass"` | Testing → Done | Approved. Issue closed. | -| `"fail"` | Testing → To Improve | Issues found. Issue reopened, sent back to DEV. | +| `"fail"` | Testing → To Improve | Issues found. Issue reopened, sent back to DEVELOPER. | | `"refine"` | Testing → Refining | Needs human decision. Pipeline pauses. | -| `"blocked"` | Testing → To Test | Cannot complete (env issues, etc.). Returns to QA queue. | +| `"blocked"` | Testing → Refining | Cannot complete (env issues, etc.). Awaits human decision. | ## Why Comments Are Required @@ -96,14 +96,14 @@ work_finish({ ## Enforcement -QA workers receive instructions via role templates to: +TESTER workers receive instructions via role templates to: - Always call `task_comment` BEFORE `work_finish` - Include specific details about what was tested - Document results, environment, and any notes Prompt templates affected: -- `projects/roles//qa.md` -- All project-specific QA templates should follow this pattern +- `devclaw/projects//prompts/tester.md` +- `devclaw/prompts/tester.md` (default) ## Best Practices @@ -116,5 +116,5 @@ Prompt templates affected: ## Related - Tool: [`task_comment`](TOOLS.md#task_comment) — Add comments to issues -- Tool: [`work_finish`](TOOLS.md#work_finish) — Complete QA tasks -- Config: [`projects/roles//qa.md`](CONFIGURATION.md#role-instruction-files) — QA role instructions +- Tool: [`work_finish`](TOOLS.md#work_finish) — Complete TESTER tasks +- Config: [`devclaw/projects//prompts/tester.md`](CONFIGURATION.md#role-instruction-files) — Tester role instructions diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 35c809e..68bc809 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,53 +1,77 @@ # DevClaw — Roadmap -## Configurable Roles +## Recently Completed -Currently DevClaw has two hardcoded roles: **DEV** and **QA**. Each project gets one worker slot per role. The pipeline is fixed: DEV writes code, QA reviews it. +### Dynamic Roles and Role Registry -This works for the common case but breaks down when you want: -- A **design** role that creates mockups before DEV starts -- A **devops** role that handles deployment after QA passes -- A **PM** role that triages and prioritizes the backlog -- Multiple DEV workers in parallel (e.g. frontend + backend) -- A project with no QA step at all +Roles are no longer hardcoded. The `ROLE_REGISTRY` in `lib/roles/registry.ts` defines three built-in roles — **developer**, **tester**, **architect** — each with configurable levels, models, emoji, and completion results. Adding a new role means adding one entry to the registry; everything else (workers, sessions, labels, prompts) derives from it. -### Planned: role configuration per project +All roles use a unified junior/medior/senior level scheme (architect uses junior/senior). Per-role model overrides live in `workflow.yaml`. -Roles become a configurable list instead of a hardcoded pair. Each role defines: -- **Name** — e.g. `design`, `dev`, `qa`, `devops` -- **Levels** — which developer levels can be assigned (e.g. design only needs `medior`) -- **Pipeline position** — where it sits in the task lifecycle -- **Worker count** — how many concurrent workers (default: 1) +### Workflow State Machine -```json -{ - "roles": { - "dev": { "levels": ["junior", "medior", "senior"], "workers": 1 }, - "qa": { "levels": ["reviewer", "tester"], "workers": 1 }, - "devops": { "levels": ["medior", "senior"], "workers": 1 } - }, - "pipeline": ["dev", "qa", "devops"] -} +The issue lifecycle is now a configurable state machine defined in `workflow.yaml`. The default workflow defines 11 states: + +``` +Planning → To Do → Doing → To Test → Testing → Done + ↘ In Review → (PR merged) → To Test + ↘ To Improve → Doing + ↘ Refining → (human decision) +To Design → Designing → Planning ``` -The pipeline definition replaces the hardcoded `Doing → To Test → Testing → Done` flow. Labels and transitions are generated from the pipeline config. The scheduler follows the pipeline order when filling free slots. +States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). -### Open questions +### Three-Layer Configuration -- How do custom labels map? Generate from role names, or let users define? -- Should roles have their own instruction files (`projects/roles//.md`) — yes, this already works -- How to handle parallel roles (e.g. frontend + backend DEV in parallel before QA)? +Config resolution follows three layers, each partially overriding the one below: + +1. **Built-in defaults** — `ROLE_REGISTRY` + `DEFAULT_WORKFLOW` +2. **Workspace** — `/devclaw/workflow.yaml` +3. **Project** — `/devclaw/projects//workflow.yaml` + +Validated at load time with Zod schemas (`lib/config/schema.ts`). Integrity checks verify transition targets exist, queue states have roles, and terminal states have no outgoing transitions. + +### Provider Resilience + +All issue tracker calls (GitHub via `gh`, GitLab via `glab`) are wrapped with cockatiel retry (3 attempts, exponential backoff) and circuit breaker (opens after 5 consecutive failures, half-opens after 30s). See `lib/providers/resilience.ts`. + +### Bootstrap Hook for Role Instructions + +Worker sessions receive role-specific instructions via the `agent:bootstrap` hook at session startup, not appended to the task message. The hook reads from `devclaw/projects//prompts/.md`, falling back to `devclaw/prompts/.md`. Supports source tracking with `loadRoleInstructions(dir, { withSource: true })`. + +### In Review State and PR Polling + +DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is merged, the issue auto-transitions to `To Test` for TESTER pickup. + +### Architect Role + +The architect role enables design investigations. `design_task` creates a `To Design` issue and dispatches an architect worker. The architect completes with `done` (→ Planning) or `blocked` (→ Refining). + +### Workspace Layout Migration + +Data directory moved from `/projects/` to `/devclaw/`. Automatic migration on first load — see `lib/setup/migrate-layout.ts`. + +### E2E Test Infrastructure + +Purpose-built test harness (`lib/testing/`) with: +- `TestProvider` — in-memory `IssueProvider` with call tracking +- `createTestHarness()` — scaffolds temp workspace, mock `runCommand`, test provider +- `simulateBootstrap()` — tests the full bootstrap hook chain without a live gateway +- `CommandInterceptor` — captures and filters CLI calls --- -## Channel-agnostic Groups +## Planned + +### Channel-agnostic Groups Currently DevClaw maps projects to **Telegram group IDs**. The `projectGroupId` is a Telegram-specific negative number. This means: - WhatsApp groups can't be used as project channels (partially supported now via `channel` field) - Discord, Slack, or other channels are excluded - The naming (`groupId`, `groupName`) is Telegram-specific -### Planned: abstract channel binding +**Planned: abstract channel binding** Replace Telegram-specific group IDs with a generic channel identifier that works across any OpenClaw channel. @@ -57,14 +81,12 @@ Replace Telegram-specific group IDs with a generic channel identifier that works "whatsapp:120363140032870788@g.us": { "name": "my-project", "channel": "whatsapp", - "peer": "120363140032870788@g.us", - ... + "peer": "120363140032870788@g.us" }, "telegram:-1234567890": { "name": "other-project", "channel": "telegram", - "peer": "-1234567890", - ... + "peer": "-1234567890" } } } @@ -79,7 +101,7 @@ Key changes: This enables any OpenClaw channel (Telegram, WhatsApp, Discord, Slack, etc.) to host a project. -### Open questions +#### Open questions - Should one project be bindable to multiple channels? (e.g. Telegram for devs, WhatsApp for stakeholder updates) - How does the orchestrator agent handle cross-channel context? @@ -89,8 +111,9 @@ This enables any OpenClaw channel (Telegram, WhatsApp, Discord, Slack, etc.) to ## Other Ideas - **Jira provider** — `IssueProvider` interface already abstracts GitHub/GitLab; Jira is the obvious next addition -- **Deployment integration** — `work_finish` QA pass could trigger a deploy step via webhook or CLI +- **Deployment integration** — `work_finish` TESTER pass could trigger a deploy step via webhook or CLI - **Cost tracking** — log token usage per task/level, surface in `status` - **Priority scoring** — automatic priority assignment based on labels, age, and dependencies - **Session archival** — auto-archive idle sessions after configurable timeout (currently indefinite) -- **Progressive delegation** — track QA pass rates per level and auto-promote (see [Management Theory](MANAGEMENT.md)) +- **Progressive delegation** — track TESTER pass rates per level and auto-promote (see [Management Theory](MANAGEMENT.md)) +- **Custom workflow actions** — user-defined actions in `workflow.yaml` (e.g. deploy scripts, notifications) diff --git a/docs/TESTING.md b/docs/TESTING.md index f151e11..b19c16d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,216 +1,215 @@ -# DevClaw Testing Guide +# DevClaw — Testing Guide -Comprehensive automated testing for DevClaw onboarding and setup. +DevClaw uses Node.js built-in test runner (`node:test`) with `node:assert/strict` for all tests. ## Quick Start ```bash -# Install dependencies -npm install - # Run all tests -npm test +npx tsx --test lib/**/*.test.ts -# Run with coverage report -npm run test:coverage +# Run a specific test file +npx tsx --test lib/roles/registry.test.ts -# Run in watch mode (auto-rerun on changes) -npm run test:watch +# Run E2E tests only +npx tsx --test lib/services/*.e2e.test.ts -# Run with UI (browser-based test explorer) -npm run test:ui +# Build (also type-checks all test files) +npm run build ``` -## Test Coverage +## Test Files -### Scenario 1: New User (No Prior DevClaw Setup) -**File:** `tests/setup/new-user.test.ts` +### Unit Tests -**What's tested:** -- First-time agent creation with default models -- Channel binding creation (telegram/whatsapp) -- Workspace file generation (AGENTS.md, HEARTBEAT.md, projects/, log/) -- Plugin configuration initialization -- Error handling: channel not configured -- Error handling: channel disabled +| File | What it tests | +|---|---| +| [lib/roles/registry.test.ts](../lib/roles/registry.test.ts) | Role registry: role lookup, level resolution, model defaults | +| [lib/projects.test.ts](../lib/projects.test.ts) | Project state: read/write, worker state, atomic file operations | +| [lib/bootstrap-hook.test.ts](../lib/bootstrap-hook.test.ts) | Bootstrap hook: role instruction loading, source tracking, overloads | +| [lib/tools/task-update.test.ts](../lib/tools/task-update.test.ts) | Task update tool: label transitions, validation | +| [lib/tools/design-task.test.ts](../lib/tools/design-task.test.ts) | Design task tool: architect dispatch | +| [lib/tools/queue-status.test.ts](../lib/tools/queue-status.test.ts) | Queue status formatting | +| [lib/setup/migrate-layout.test.ts](../lib/setup/migrate-layout.test.ts) | Workspace layout migration: `projects/` → `devclaw/` | + +### E2E Tests + +| File | What it tests | +|---|---| +| [lib/services/pipeline.e2e.test.ts](../lib/services/pipeline.e2e.test.ts) | Full pipeline: completion rules, label transitions, actions | +| [lib/services/bootstrap.e2e.test.ts](../lib/services/bootstrap.e2e.test.ts) | Bootstrap hook chain: session key → parse → load instructions → inject | + +## Test Infrastructure + +### Test Harness (`lib/testing/`) + +The [`lib/testing/`](../lib/testing/) module provides E2E test infrastructure: -**Example:** ```typescript -// Before: openclaw.json has no DevClaw agents -{ - "agents": { "list": [{ "id": "main", ... }] }, - "bindings": [], - "plugins": { "entries": {} } -} +import { createTestHarness } from "../testing/index.js"; -// After: New orchestrator created -{ - "agents": { - "list": [ - { "id": "main", ... }, - { "id": "my-first-orchestrator", ... } - ] +const h = await createTestHarness({ + projectName: "my-project", + groupId: "-1234567890", + workflow: DEFAULT_WORKFLOW, + workers: { + developer: { active: true, issueId: "42", level: "medior" }, }, - "bindings": [ - { "agentId": "my-first-orchestrator", "match": { "channel": "telegram" } } - ], - "plugins": { - "entries": { - "devclaw": { - "config": { - "models": { - "dev": { - "junior": "anthropic/claude-haiku-4-5", - "medior": "anthropic/claude-sonnet-4-5", - "senior": "anthropic/claude-opus-4-5" - }, - "qa": { - "reviewer": "anthropic/claude-sonnet-4-5", - "tester": "anthropic/claude-haiku-4-5" - } - } - } - } - } - } +}); +try { + // ... run tests against h.provider, h.commands, etc. +} finally { + await h.cleanup(); } ``` -### Scenario 2: Existing User (Migration) -**File:** `tests/setup/existing-user.test.ts` +**`createTestHarness()`** scaffolds: +- Temporary workspace directory with `devclaw/` data dir and `log/` subdirectory +- `projects.json` with test project and configurable worker state +- Mock `runCommand` via `CommandInterceptor` (captures all CLI calls) +- `TestProvider` — in-memory `IssueProvider` with call tracking -**What's tested:** -- Channel conflict detection (existing channel-wide binding) -- Binding migration from old agent to new agent -- Custom model preservation during migration -- Old agent preservation (not deleted) -- Error handling: migration source doesn't exist -- Error handling: migration source has no binding +### TestProvider + +In-memory implementation of `IssueProvider` for testing. Tracks all provider method calls and maintains in-memory issue state: -**Example:** ```typescript -// Before: Old orchestrator has telegram binding -{ - "agents": { - "list": [ - { "id": "main", ... }, - { "id": "old-orchestrator", ... } - ] - }, - "bindings": [ - { "agentId": "old-orchestrator", "match": { "channel": "telegram" } } - ] -} +const h = await createTestHarness(); +h.provider.seedIssue(42, { + title: "Fix the bug", + labels: ["Doing"], + state: "open", +}); -// After: Binding migrated to new orchestrator -{ - "agents": { - "list": [ - { "id": "main", ... }, - { "id": "old-orchestrator", ... }, - { "id": "new-orchestrator", ... } - ] - }, - "bindings": [ - { "agentId": "new-orchestrator", "match": { "channel": "telegram" } } - ] -} +// After running pipeline code: +const calls = h.provider.calls; // All method invocations ``` -### Scenario 3: Power User (Multiple Agents) -**File:** `tests/setup/power-user.test.ts` +### CommandInterceptor -**What's tested:** -- No conflicts with group-specific bindings -- Channel-wide binding creation alongside group bindings -- Multiple orchestrators coexisting -- Routing logic (specific bindings win over channel-wide) -- WhatsApp support -- Scale testing (12+ orchestrators) +Captures all `runCommand` calls during tests. Provides filtering and extraction helpers: -**Example:** ```typescript -// Before: Two project orchestrators with group-specific bindings -{ - "agents": { - "list": [ - { "id": "project-a-orchestrator", ... }, - { "id": "project-b-orchestrator", ... } - ] - }, - "bindings": [ - { - "agentId": "project-a-orchestrator", - "match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1001234567890" } } - }, - { - "agentId": "project-b-orchestrator", - "match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1009876543210" } } - } - ] -} +// All captured commands +h.commands.commands; -// After: Channel-wide orchestrator added (no conflicts) -{ - "agents": { - "list": [ - { "id": "project-a-orchestrator", ... }, - { "id": "project-b-orchestrator", ... }, - { "id": "global-orchestrator", ... } - ] - }, - "bindings": [ - { - "agentId": "project-a-orchestrator", - "match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1001234567890" } } - }, - { - "agentId": "project-b-orchestrator", - "match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1009876543210" } } - }, - { - "agentId": "global-orchestrator", - "match": { "channel": "telegram" } // Channel-wide (no peer) - } - ] -} +// Filter by command name +h.commands.commandsFor("openclaw"); -// Routing: Group messages go to specific agents, everything else goes to global +// Extract task messages dispatched to workers +h.commands.taskMessages(); + +// Extract session creation patches +h.commands.sessionPatches(); + +// Reset between test cases +h.commands.reset(); ``` -## Test Architecture +### simulateBootstrap -### Mock File System -The tests use an in-memory mock file system (`MockFileSystem`) that simulates: -- Reading/writing openclaw.json -- Creating/reading workspace files -- Tracking command executions (openclaw agents add) +Tests the full bootstrap hook chain without a live OpenClaw gateway: -**Why?** Tests run in isolation without touching the real file system, making them: -- Fast (no I/O) -- Reliable (no file conflicts) -- Repeatable (clean state every test) - -### Fixtures -Pre-built configurations for different user types: -- `createNewUserConfig()` - Empty slate -- `createCommonUserConfig()` - One orchestrator with binding -- `createPowerUserConfig()` - Multiple orchestrators with group bindings -- `createNoChannelConfig()` - Channel not configured -- `createDisabledChannelConfig()` - Channel disabled - -### Assertions -Reusable assertion helpers that make tests readable: ```typescript -assertAgentExists(mockFs, "my-agent", "My Agent"); -assertChannelBinding(mockFs, "my-agent", "telegram"); -assertWorkspaceFilesExist(mockFs, "my-agent"); -assertDevClawConfig(mockFs, { junior: "anthropic/claude-haiku-4-5" }); +// Write a project-specific prompt +await h.writePrompt("developer", "Custom dev instructions", "my-project"); + +// Simulate bootstrap for a developer session +const files = await h.simulateBootstrap( + "agent:orchestrator:subagent:my-project-developer-medior" +); + +// Verify injected bootstrap files +assert.strictEqual(files.length, 1); +assert.strictEqual(files[0].content, "Custom dev instructions"); +``` + +## Writing Tests + +### Pattern: Unit Test + +```typescript +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +describe("my feature", () => { + it("should do something", () => { + const result = myFunction("input"); + assert.strictEqual(result, "expected"); + }); +}); +``` + +### Pattern: E2E Pipeline Test + +```typescript +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { executeCompletion } from "./pipeline.js"; + +describe("pipeline completion", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + it("developer:done transitions Doing → To Test", async () => { + h = await createTestHarness({ + workers: { + developer: { active: true, issueId: "42", level: "medior" }, + }, + }); + h.provider.seedIssue(42, { labels: ["Doing"], state: "open" }); + + const result = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + project: h.project, + workflow: h.workflow, + provider: h.provider, + role: "developer", + result: "done", + }); + + assert.strictEqual(result.rule.to, "To Test"); + }); +}); +``` + +### Pattern: Bootstrap Hook Test + +```typescript +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; + +describe("bootstrap instructions", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + it("injects project-specific prompt for developer", async () => { + h = await createTestHarness({ projectName: "webapp" }); + await h.writePrompt("developer", "Build with React", "webapp"); + + const files = await h.simulateBootstrap( + "agent:orchestrator:subagent:webapp-developer-medior" + ); + + assert.strictEqual(files.length, 1); + assert.ok(files[0].content?.includes("React")); + }); +}); ``` ## CI/CD Integration ### GitHub Actions + ```yaml name: Test on: [push, pull_request] @@ -218,122 +217,52 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - - run: npm test - - run: npm run test:coverage - - uses: codecov/codecov-action@v3 - with: - files: ./coverage/coverage-final.json + - run: npm run build + - run: npx tsx --test lib/**/*.test.ts ``` ### GitLab CI + ```yaml test: image: node:20 script: - npm ci - - npm test - - npm run test:coverage - coverage: '/Lines\s*:\s*(\d+\.\d+)%/' - artifacts: - reports: - coverage_report: - coverage_format: cobertura - path: coverage/cobertura-coverage.xml + - npm run build + - npx tsx --test lib/**/*.test.ts ``` ## Debugging Tests ### Run specific test + ```bash -npm test -- new-user # Run all new-user tests -npm test -- "should create agent" # Run tests matching pattern +# Run by file +npx tsx --test lib/roles/registry.test.ts + +# Run by name pattern +npx tsx --test --test-name-pattern "should have all expected roles" lib/**/*.test.ts ``` ### Debug with Node inspector + ```bash -node --inspect-brk node_modules/.bin/vitest run +node --inspect-brk node_modules/.bin/tsx --test lib/roles/registry.test.ts ``` -Then open Chrome DevTools at `chrome://inspect` - -### View coverage report -```bash -npm run test:coverage -open coverage/index.html -``` - -## Adding Tests - -### 1. Choose the right test file -- New feature → `tests/setup/new-user.test.ts` -- Migration feature → `tests/setup/existing-user.test.ts` -- Multi-agent feature → `tests/setup/power-user.test.ts` - -### 2. Write the test -```typescript -import { describe, it, expect, beforeEach } from "vitest"; -import { MockFileSystem } from "../helpers/mock-fs.js"; -import { createNewUserConfig } from "../helpers/fixtures.js"; -import { assertAgentExists } from "../helpers/assertions.js"; - -describe("My new feature", () => { - let mockFs: MockFileSystem; - - beforeEach(() => { - mockFs = new MockFileSystem(createNewUserConfig()); - }); - - it("should do something useful", async () => { - // GIVEN: initial state (via fixture) - const beforeCount = countAgents(mockFs); - - // WHEN: execute the operation - const config = mockFs.getConfig(); - config.agents.list.push({ - id: "test-agent", - name: "Test Agent", - workspace: "/home/test/.openclaw/workspace-test-agent", - agentDir: "/home/test/.openclaw/agents/test-agent/agent", - }); - mockFs.setConfig(config); - - // THEN: verify the outcome - assertAgentExists(mockFs, "test-agent", "Test Agent"); - expect(countAgents(mockFs)).toBe(beforeCount + 1); - }); -}); -``` - -### 3. Run your test -```bash -npm test -- "should do something useful" -``` +Then open Chrome DevTools at `chrome://inspect`. ## Best Practices -### ✅ DO -- Test one thing per test -- Use descriptive test names ("should create agent with telegram binding") -- Use fixtures for initial state -- Use assertion helpers for readability -- Test error cases - -### ❌ DON'T -- Test implementation details (test behavior, not internals) -- Share state between tests (use beforeEach) -- Mock everything (only mock file system and commands) -- Write brittle tests (avoid hard-coded UUIDs, timestamps) - -## Test Metrics - -Current coverage: -- **Lines:** Target 80%+ -- **Functions:** Target 90%+ -- **Branches:** Target 75%+ - -Run `npm run test:coverage` to see detailed metrics. +- **Use `node:test` + `node:assert/strict`** — no test framework dependencies +- **Use `createTestHarness()`** for any test that needs workspace state, providers, or command interception +- **Always call `h.cleanup()`** in `afterEach` to remove temp directories +- **Seed provider state** with `h.provider.seedIssue()` before testing pipeline operations +- **Use `h.commands`** to verify what CLI commands were dispatched without actually running them +- **One assertion focus per test** — test one behavior, not the whole pipeline +- **Test error cases** — invalid roles, missing projects, bad state transitions diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 85915c4..3f829c5 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # DevClaw — Tools Reference -Complete reference for all 11 tools registered by DevClaw. See [`index.ts`](../index.ts) for registration. +Complete reference for all tools registered by DevClaw. See [`index.ts`](../index.ts) for registration. ## Worker Lifecycle @@ -17,9 +17,9 @@ Pick up a task from the issue queue. Handles level assignment, label transition, | Parameter | Type | Required | Description | |---|---|---|---| | `issueId` | number | No | Issue ID. If omitted, picks next by priority. | -| `role` | `"dev"` \| `"qa"` | No | Worker role. Auto-detected from issue label if omitted. | +| `role` | `"developer"` \| `"tester"` \| `"architect"` | No | Worker role. Auto-detected from issue label if omitted. | | `projectGroupId` | string | No | Project group ID. Auto-detected from group context. | -| `level` | string | No | Developer level (`junior`, `medior`, `senior`, `reviewer`). Auto-detected if omitted. | +| `level` | string | No | Level (`junior`, `medior`, `senior`). Auto-detected if omitted. | **What it does atomically:** @@ -28,15 +28,14 @@ Pick up a task from the issue queue. Handles level assignment, label transition, 3. Fetches issue from tracker, verifies correct label state 4. Assigns level (LLM-chosen via `level` param → label detection → keyword heuristic fallback) 5. Resolves level to model ID via config or defaults -6. Loads prompt instructions from `projects/roles//.md` -7. Looks up existing session for assigned level (session-per-level) -8. Transitions label (e.g. `To Do` → `Doing`) -9. Creates session via Gateway RPC if new (`sessions.patch`) -10. Dispatches task to worker session via CLI (`openclaw gateway call agent`) -11. Updates `projects.json` state (active, issueId, level, session key) -12. Writes audit log entries (work_start + model_selection) -13. Sends notification -14. Returns announcement text +6. Looks up existing session for assigned level (session-per-level) +7. Transitions label (e.g. `To Do` → `Doing`) +8. Creates session via Gateway RPC if new (`sessions.patch`) +9. Dispatches task to worker session via CLI (`openclaw gateway call agent`) +10. Updates `projects.json` state (active, issueId, level, session key) +11. Writes audit log entries (work_start + model_selection) +12. Sends notification +13. Returns announcement text **Level selection priority:** @@ -55,7 +54,7 @@ Pick up a task from the issue queue. Handles level assignment, label transition, ### `work_finish` -Complete a task with a result. Called by workers (DEV/QA sub-agent sessions) directly, or by the orchestrator. +Complete a task with a result. Called by workers (DEVELOPER/TESTER/ARCHITECT sub-agent sessions) directly, or by the orchestrator. **Source:** [`lib/tools/work-finish.ts`](../lib/tools/work-finish.ts) @@ -63,7 +62,7 @@ Complete a task with a result. Called by workers (DEV/QA sub-agent sessions) dir | Parameter | Type | Required | Description | |---|---|---|---| -| `role` | `"dev"` \| `"qa"` | Yes | Worker role | +| `role` | `"developer"` \| `"tester"` \| `"architect"` | Yes | Worker role | | `result` | string | Yes | Completion result (see table below) | | `projectGroupId` | string | Yes | Project group ID | | `summary` | string | No | Brief summary for the announcement | @@ -73,12 +72,15 @@ Complete a task with a result. Called by workers (DEV/QA sub-agent sessions) dir | Role | Result | Label transition | Side effects | |---|---|---|---| -| DEV | `"done"` | Doing → To Test | git pull, auto-detect PR URL | -| DEV | `"blocked"` | Doing → To Do | Task returns to queue | -| QA | `"pass"` | Testing → Done | Issue closed | -| QA | `"fail"` | Testing → To Improve | Issue reopened | -| QA | `"refine"` | Testing → Refining | Awaits human decision | -| QA | `"blocked"` | Testing → To Test | Task returns to QA queue | +| developer | `"done"` | Doing → To Test | git pull, auto-detect PR URL | +| developer | `"review"` | Doing → In Review | auto-detect PR URL, heartbeat polls for merge | +| developer | `"blocked"` | Doing → Refining | Awaits human decision | +| tester | `"pass"` | Testing → Done | Issue closed | +| tester | `"fail"` | Testing → To Improve | Issue reopened | +| tester | `"refine"` | Testing → Refining | Awaits human decision | +| tester | `"blocked"` | Testing → Refining | Awaits human decision | +| architect | `"done"` | Designing → Planning | Design complete | +| architect | `"blocked"` | Designing → Refining | Awaits human decision | **What it does atomically:** @@ -111,7 +113,7 @@ Create a new issue in the project's issue tracker. | `description` | string | No | Full issue body (markdown) | | `label` | StateLabel | No | State label. Defaults to `"Planning"`. | | `assignees` | string[] | No | GitHub/GitLab usernames to assign | -| `pickup` | boolean | No | If true, immediately pick up for DEV after creation | +| `pickup` | boolean | No | If true, immediately pick up for DEVELOPER after creation | **Use cases:** @@ -138,7 +140,7 @@ Change an issue's state label manually without going through the full pickup/com | `state` | StateLabel | Yes | New state label | | `reason` | string | No | Audit log reason for the change | -**Valid states:** `Planning`, `To Do`, `Doing`, `To Test`, `Testing`, `Done`, `To Improve`, `Refining` +**Valid states:** `Planning`, `To Do`, `Doing`, `To Test`, `Testing`, `Done`, `To Improve`, `Refining`, `In Review`, `To Design`, `Designing` **Use cases:** @@ -161,12 +163,12 @@ Add a comment to an issue for feedback, notes, or discussion. | `projectGroupId` | string | Yes | Project group ID | | `issueId` | number | Yes | Issue ID to comment on | | `body` | string | Yes | Comment body (markdown) | -| `authorRole` | `"dev"` \| `"qa"` \| `"orchestrator"` | No | Attribution role prefix | +| `authorRole` | `"developer"` \| `"tester"` \| `"orchestrator"` | No | Attribution role prefix | **Use cases:** -- QA adds review feedback before pass/fail decision -- DEV posts implementation notes or progress updates +- TESTER adds review feedback before pass/fail decision +- DEVELOPER posts implementation notes or progress updates - Orchestrator adds summary comments When `authorRole` is provided, the comment is prefixed with a role emoji and attribution label. @@ -191,7 +193,7 @@ Lightweight queue + worker state dashboard. **Returns per project:** -- Worker state: active/idle, current issue, level, start time +- Worker state per role: active/idle, current issue, level, start time - Queue counts: To Do, To Test, To Improve - Role execution mode @@ -226,7 +228,7 @@ Worker health scan with optional auto-fix. ### `work_heartbeat` -Manual trigger for heartbeat: health fix + queue dispatch. Same logic as the background heartbeat service, but invoked on demand. +Manual trigger for heartbeat: health fix + review polling + queue dispatch. Same logic as the background heartbeat service, but invoked on demand. **Source:** [`lib/tools/work-heartbeat.ts`](../lib/tools/work-heartbeat.ts) @@ -239,15 +241,16 @@ Manual trigger for heartbeat: health fix + queue dispatch. Same logic as the bac | `maxPickups` | number | No | Max worker dispatches per tick. | | `activeSessions` | string[] | No | Active session IDs for zombie detection. | -**Two-pass sweep:** +**Three-pass sweep:** 1. **Health pass** — Runs `checkWorkerHealth` per project per role. Auto-fixes zombies, stale workers, orphaned state. -2. **Tick pass** — Calls `projectTick` per project. Fills free worker slots by priority (To Improve > To Test > To Do). +2. **Review pass** — Polls PR status for issues in "In Review" state. Transitions to "To Test" when PR is merged. +3. **Tick pass** — Calls `projectTick` per project. Fills free worker slots by priority (To Improve > To Test > To Do). **Execution guards:** - `projectExecution: "sequential"` — only one project active at a time -- `roleExecution: "sequential"` — only one role (DEV or QA) active at a time per project (enforced in `projectTick`) +- `roleExecution: "sequential"` — only one role active at a time per project --- @@ -272,18 +275,16 @@ One-time project setup. Creates state labels, scaffolds prompt files, adds proje | `baseBranch` | string | Yes | Base branch for development | | `deployBranch` | string | No | Deploy branch. Defaults to baseBranch. | | `deployUrl` | string | No | Deployment URL | -| `roleExecution` | `"parallel"` \| `"sequential"` | No | DEV/QA parallelism. Default: `"parallel"`. | +| `roleExecution` | `"parallel"` \| `"sequential"` | No | DEVELOPER/TESTER parallelism. Default: `"parallel"`. | **What it does atomically:** 1. Validates project not already registered 2. Resolves repo path, auto-detects GitHub/GitLab from git remote 3. Verifies provider health (CLI installed and authenticated) -4. Creates all 8 state labels (idempotent — safe to run again) -5. Adds project entry to `projects.json` with empty worker state - - DEV sessions: `{ junior: null, medior: null, senior: null }` - - QA sessions: `{ reviewer: null, tester: null }` -6. Scaffolds prompt files: `projects/roles//dev.md` and `qa.md` +4. Creates all 11 state labels (idempotent — safe to run again) +5. Adds project entry to `projects.json` with empty worker state for all registered roles +6. Scaffolds prompt files: `devclaw/projects//prompts/.md` for each role 7. Writes audit log --- @@ -301,7 +302,7 @@ Agent + workspace initialization. | `newAgentName` | string | No | Create a new agent. Omit to configure current workspace. | | `channelBinding` | `"telegram"` \| `"whatsapp"` | No | Channel to bind (with `newAgentName` only) | | `migrateFrom` | string | No | Agent ID to migrate channel binding from | -| `models` | object | No | Model overrides per role and level (see [Configuration](CONFIGURATION.md#model-tiers)) | +| `models` | object | No | Model overrides per role and level (see [Configuration](CONFIGURATION.md#role-configuration)) | | `projectExecution` | `"parallel"` \| `"sequential"` | No | Project execution mode | **What it does:** @@ -309,8 +310,8 @@ Agent + workspace initialization. 1. Creates a new agent or configures existing workspace 2. Optionally binds messaging channel (Telegram/WhatsApp) 3. Optionally migrates channel binding from another agent -4. Writes workspace files: AGENTS.md, HEARTBEAT.md, `projects/projects.json` -5. Configures model tiers in `openclaw.json` +4. Writes workspace files: AGENTS.md, HEARTBEAT.md, `devclaw/projects.json`, `devclaw/workflow.yaml` +5. Scaffolds default prompt files for all roles --- @@ -328,34 +329,47 @@ Conversational onboarding guide. Returns step-by-step instructions for the agent |---|---|---|---| | `mode` | `"first-run"` \| `"reconfigure"` | No | Auto-detected from current state | -**Flow:** +--- -1. Call `onboard` — returns QA-style step-by-step instructions -2. Agent walks user through: agent selection, channel binding, model tiers -3. Agent calls `setup` with collected answers -4. User registers projects via `project_register` in group chats +### `design_task` + +Spawn an architect for a design investigation. Creates a "To Design" issue and dispatches an architect worker. + +**Source:** [`lib/tools/design-task.ts`](../lib/tools/design-task.ts) + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `projectGroupId` | string | Yes | Project group ID | +| `title` | string | Yes | Design task title | +| `description` | string | No | Design problem description | +| `level` | `"junior"` \| `"senior"` | No | Architect level. Default: `"junior"`. | --- ## Completion Rules Reference -The pipeline service (`lib/services/pipeline.ts`) defines declarative completion rules: +The pipeline service (`lib/services/pipeline.ts`) derives completion rules from the workflow config: ``` -dev:done → Doing → To Test (git pull, detect PR) -dev:blocked → Doing → To Do (return to queue) -qa:pass → Testing → Done (close issue) -qa:fail → Testing → To Improve (reopen issue) -qa:refine → Testing → Refining (await human decision) -qa:blocked → Testing → To Test (return to QA queue) +developer:done → Doing → To Test (git pull, detect PR) +developer:review → Doing → In Review (detect PR, heartbeat polls for merge) +developer:blocked → Doing → Refining (awaits human decision) +tester:pass → Testing → Done (close issue) +tester:fail → Testing → To Improve (reopen issue) +tester:refine → Testing → Refining (awaits human decision) +tester:blocked → Testing → Refining (awaits human decision) +architect:done → Designing → Planning (design complete) +architect:blocked → Designing → Refining (awaits human decision) ``` ## Issue Priority Order When the heartbeat or `work_heartbeat` fills free worker slots, issues are prioritized: -1. **To Improve** — QA failures get fixed first (highest priority) -2. **To Test** — Completed DEV work gets reviewed next +1. **To Improve** — Tester failures get fixed first (highest priority) +2. **To Test** — Completed developer work gets reviewed next 3. **To Do** — Fresh tasks are picked up last This ensures the pipeline clears its backlog before starting new work. From 25ce06e14f173d6ef22b8da47df0fdb5558f9c16 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 14:34:08 +0800 Subject: [PATCH 08/13] feat: implement auto-merge for approved PRs and update workflow documentation --- docs/ARCHITECTURE.md | 6 +- docs/CONFIGURATION.md | 8 +- docs/MANAGEMENT.md | 4 +- docs/ONBOARDING.md | 2 +- docs/ROADMAP.md | 8 +- docs/TOOLS.md | 2 +- lib/providers/github.ts | 9 ++ lib/providers/gitlab.ts | 9 ++ lib/providers/provider.ts | 1 + lib/services/pipeline.e2e.test.ts | 51 +++++++++-- lib/services/pipeline.ts | 5 ++ lib/services/review.ts | 42 ++++++++- lib/templates.ts | 141 +++++------------------------- lib/testing/test-provider.ts | 45 ++++++++-- lib/workflow.ts | 38 ++++---- 15 files changed, 208 insertions(+), 163 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5968d98..7cb3558 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -427,7 +427,7 @@ sequenceDiagram WF-->>DEV: { announcement: "👀 DEVELOPER REVIEW #42" } ``` -The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been merged, then automatically transitions to "To Test". +The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been approved. DevClaw then auto-merges the PR and transitions to "To Test". If the merge fails (e.g. conflicts), the issue moves to "To Improve" where a developer is auto-dispatched to resolve conflicts. ### Phase 6: TESTER pickup @@ -586,7 +586,7 @@ Every piece of data and where it lives: │ design_task → architect dispatch │ │ │ │ Bootstrap hook → injects role instructions into worker sessions│ -│ Review pass → polls PR status, auto-advances In Review │ +│ Review pass → polls PR status, auto-merges approved PRs │ │ Config loader → three-layer merge + Zod validation │ └─────────────────────────────────────────────────────────────────┘ ↕ atomic file I/O ↕ OpenClaw CLI (plugin shells out) @@ -662,7 +662,7 @@ graph LR SD[Session dispatch
create + send via CLI] AC[Scheduling
tick queue after work_finish] RI[Role instructions
injected via bootstrap hook] - RV[Review polling
PR status → auto-advance] + RV[Review polling
PR approved → auto-merge] A[Audit logging] Z[Zombie cleanup] CFG[Config validation
Zod + integrity checks] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index aacddf4..fd16ad4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -153,11 +153,12 @@ workflow: type: review label: In Review color: "#c5def5" - check: prMerged + check: prApproved on: APPROVED: target: toTest - actions: [gitPull] + actions: [mergePr, gitPull] + MERGE_FAILED: toImprove BLOCKED: refining done: type: terminal @@ -188,7 +189,7 @@ workflow: | `queue` | Waiting for pickup. Must have a `role`. Has `priority` for ordering. | | `active` | Worker is currently working on it. Must have a `role`. | | `hold` | Paused, awaiting human decision. | -| `review` | Awaiting external check (PR merged/approved). Has `check` field. | +| `review` | Awaiting external check (PR approved/merged). Has `check` field. Heartbeat polls and auto-transitions. | | `terminal` | Completed. No outgoing transitions. | **Built-in actions:** @@ -197,6 +198,7 @@ workflow: |---|---| | `gitPull` | Pull latest from the base branch | | `detectPr` | Auto-detect PR URL from the issue | +| `mergePr` | Merge the PR associated with the issue. Critical in review states (aborts on failure). | | `closeIssue` | Close the issue | | `reopenIssue` | Reopen the issue | diff --git a/docs/MANAGEMENT.md b/docs/MANAGEMENT.md index c0643b3..c4d541e 100644 --- a/docs/MANAGEMENT.md +++ b/docs/MANAGEMENT.md @@ -48,8 +48,8 @@ DevClaw enforces a configurable but consistent lifecycle for every task. The def ``` Planning → To Do → Doing → To Test → Testing → Done - ↘ In Review → (PR merged) → To Test - ↘ To Improve → Doing (fix cycle) + ↘ In Review → (PR approved → auto-merge) → To Test + ↘ To Improve → Doing (merge conflict / fix cycle) ↘ Refining → (human decision) ``` diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index bdb60c8..61093aa 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -266,7 +266,7 @@ Change which model powers each level in `workflow.yaml` — see [Configuration]( | Session management | Plugin | Creates, reuses, and dispatches to sessions via CLI. Agent never touches session tools. | | Task completion | Plugin (`work_finish`) | Workers self-report. Scheduler dispatches next role. | | Role instructions | Plugin (bootstrap hook) | Injected into worker sessions via `agent:bootstrap` hook at session startup | -| Review polling | Plugin (heartbeat) | Auto-advances "In Review" issues when PR is merged | +| Review polling | Plugin (heartbeat) | Auto-merges and advances "In Review" issues when PR is approved | | Config validation | Plugin | Zod schemas validate `workflow.yaml` at load time | | Audit logging | Plugin | Automatic NDJSON append per tool call | | Zombie detection | Plugin | `health` checks active vs alive | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 68bc809..c252e45 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,13 +14,13 @@ The issue lifecycle is now a configurable state machine defined in `workflow.yam ``` Planning → To Do → Doing → To Test → Testing → Done - ↘ In Review → (PR merged) → To Test - ↘ To Improve → Doing + ↘ In Review → (PR approved → auto-merge) → To Test + ↘ To Improve → Doing (merge conflict / fix cycle) ↘ Refining → (human decision) To Design → Designing → Planning ``` -States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). +States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). ### Three-Layer Configuration @@ -42,7 +42,7 @@ Worker sessions receive role-specific instructions via the `agent:bootstrap` hoo ### In Review State and PR Polling -DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is merged, the issue auto-transitions to `To Test` for TESTER pickup. +DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is approved, DevClaw auto-merges via `mergePr()` and transitions to `To Test` for TESTER pickup. If the merge fails (e.g. conflicts), the issue moves to `To Improve` where a developer is auto-dispatched to resolve conflicts. ### Architect Role diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3f829c5..7e3a162 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -244,7 +244,7 @@ Manual trigger for heartbeat: health fix + review polling + queue dispatch. Same **Three-pass sweep:** 1. **Health pass** — Runs `checkWorkerHealth` per project per role. Auto-fixes zombies, stale workers, orphaned state. -2. **Review pass** — Polls PR status for issues in "In Review" state. Transitions to "To Test" when PR is merged. +2. **Review pass** — Polls PR status for issues in "In Review" state. Auto-merges and transitions to "To Test" when PR is approved. If merge fails (conflicts), transitions to "To Improve" for developer to fix. 3. **Tick pass** — Calls `projectTick` per project. Fills free worker slots by priority (To Improve > To Test > To Do). **Execution guards:** diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 7677476..d3d1adc 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -152,6 +152,15 @@ export class GitHubProvider implements IssueProvider { return { state: PrState.CLOSED, url: null }; } + async mergePr(issueId: number): Promise { + const pat = `#${issueId}`; + const raw = await this.gh(["pr", "list", "--state", "open", "--json", "number,title,body,url", "--limit", "20"]); + const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string }>; + const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); + if (!pr) throw new Error(`No open PR found for issue #${issueId}`); + await this.gh(["pr", "merge", pr.url, "--merge"]); + } + async addComment(issueId: number, body: string): Promise { await this.gh(["issue", "comment", String(issueId), "--body", body]); } diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index 603a7dc..4288b79 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -149,6 +149,15 @@ export class GitLabProvider implements IssueProvider { return { state: PrState.CLOSED, url: null }; } + async mergePr(issueId: number): Promise { + const pat = `#${issueId}`; + const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); + const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string }>; + const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); + if (!mr) throw new Error(`No open MR found for issue #${issueId}`); + await this.glab(["mr", "merge", String(mr.iid)]); + } + async addComment(issueId: number, body: string): Promise { // Pass message directly as argv — no shell escaping needed with spawn await this.glab(["issue", "note", String(issueId), "--message", body]); diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index d404371..aae7f1e 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -61,6 +61,7 @@ export interface IssueProvider { hasMergedMR(issueId: number): Promise; getMergedMRUrl(issueId: number): Promise; getPrStatus(issueId: number): Promise; + mergePr(issueId: number): Promise; addComment(issueId: number, body: string): Promise; healthCheck(): Promise; } diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts index 0689291..cdc600c 100644 --- a/lib/services/pipeline.e2e.test.ts +++ b/lib/services/pipeline.e2e.test.ts @@ -370,10 +370,10 @@ describe("E2E pipeline", () => { h = await createTestHarness(); }); - it("should transition In Review → To Test when PR is merged", async () => { + it("should auto-merge and transition In Review → To Test when PR is approved", async () => { // Seed issue in "In Review" state h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] }); - h.provider.setPrStatus(60, { state: "merged", url: "https://example.com/pr/10" }); + h.provider.setPrStatus(60, { state: "approved", url: "https://example.com/pr/10" }); const transitions = await reviewPass({ workspaceDir: h.workspaceDir, @@ -390,6 +390,11 @@ describe("E2E pipeline", () => { assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + // mergePr action should have been called + const mergeCalls = h.provider.callsTo("mergePr"); + assert.strictEqual(mergeCalls.length, 1); + assert.strictEqual(mergeCalls[0].args.issueId, 60); + // gitPull action should have been attempted const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); assert.ok(gitCmds.length > 0, "Should have run git pull"); @@ -417,8 +422,8 @@ describe("E2E pipeline", () => { it("should handle multiple review issues in one pass", async () => { h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] }); h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] }); - h.provider.setPrStatus(70, { state: "merged", url: "https://example.com/pr/20" }); - h.provider.setPrStatus(71, { state: "merged", url: "https://example.com/pr/21" }); + h.provider.setPrStatus(70, { state: "approved", url: "https://example.com/pr/20" }); + h.provider.setPrStatus(71, { state: "approved", url: "https://example.com/pr/21" }); const transitions = await reviewPass({ workspaceDir: h.workspaceDir, @@ -434,6 +439,40 @@ describe("E2E pipeline", () => { const issue71 = await h.provider.getIssue(71); assert.ok(issue70.labels.includes("To Test")); assert.ok(issue71.labels.includes("To Test")); + + // Both should have had mergePr called + const mergeCalls = h.provider.callsTo("mergePr"); + assert.strictEqual(mergeCalls.length, 2); + }); + + it("should transition In Review → To Improve when merge fails (conflicts)", async () => { + h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["In Review"] }); + h.provider.setPrStatus(65, { state: "approved", url: "https://example.com/pr/15" }); + h.provider.mergePrFailures.add(65); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 1); + + // Issue should have moved to "To Improve" (not "To Test") + const issue = await h.provider.getIssue(65); + assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`); + assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test"); + + // mergePr should have been attempted + const mergeCalls = h.provider.callsTo("mergePr"); + assert.strictEqual(mergeCalls.length, 1); + + // gitPull should NOT have run (aborted before git pull) + const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); + assert.strictEqual(gitCmds.length, 0, "Should NOT have run git pull after merge failure"); }); }); @@ -551,8 +590,8 @@ describe("E2E pipeline", () => { let issue = await h.provider.getIssue(200); assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`); - // 4. PR gets merged — review pass picks it up - h.provider.setPrStatus(200, { state: "merged", url: "https://example.com/pr/50" }); + // 4. PR gets approved — review pass picks it up and auto-merges + h.provider.setPrStatus(200, { state: "approved", url: "https://example.com/pr/50" }); const transitions = await reviewPass({ workspaceDir: h.workspaceDir, diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index a15bc0a..01e22de 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -91,6 +91,11 @@ export async function executeCompletion(opts: { auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); } } break; + case Action.MERGE_PR: + try { await provider.mergePr(issueId); } catch (err) { + auditLog(workspaceDir, "pipeline_warning", { step: "mergePr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + } + break; } } diff --git a/lib/services/review.ts b/lib/services/review.ts index d5a0e98..c66baa1 100644 --- a/lib/services/review.ts +++ b/lib/services/review.ts @@ -50,8 +50,10 @@ export async function reviewPass(opts: { if (!conditionMet) continue; - // Find the success transition (first non-BLOCKED event) - const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED); + // Find the success transition (first event that isn't BLOCKED or MERGE_FAILED) + const successEvent = Object.keys(state.on).find( + (e) => e !== WorkflowEvent.BLOCKED && e !== WorkflowEvent.MERGE_FAILED, + ); if (!successEvent) continue; const transition = state.on[successEvent]; @@ -60,10 +62,41 @@ export async function reviewPass(opts: { const targetState = workflow.states[targetKey]; if (!targetState) continue; - // Execute transition actions + // Execute transition actions — mergePr is critical (aborts on failure) + let aborted = false; if (actions) { for (const action of actions) { switch (action) { + case Action.MERGE_PR: + try { + await provider.mergePr(issue.iid); + } catch (err) { + // Merge failed → fire MERGE_FAILED transition (developer fixes conflicts) + await auditLog(workspaceDir, "review_merge_failed", { + groupId, + issueId: issue.iid, + from: state.label, + error: (err as Error).message ?? String(err), + }); + const failedTransition = state.on[WorkflowEvent.MERGE_FAILED]; + if (failedTransition) { + const failedKey = typeof failedTransition === "string" ? failedTransition : failedTransition.target; + const failedState = workflow.states[failedKey]; + if (failedState) { + await provider.transitionLabel(issue.iid, state.label, failedState.label); + await auditLog(workspaceDir, "review_transition", { + groupId, + issueId: issue.iid, + from: state.label, + to: failedState.label, + reason: "merge_failed", + }); + transitions++; + } + } + aborted = true; + } + break; case Action.GIT_PULL: try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ } break; @@ -74,9 +107,12 @@ export async function reviewPass(opts: { await provider.reopenIssue(issue.iid); break; } + if (aborted) break; } } + if (aborted) continue; // skip normal transition, move to next issue + // Transition label await provider.transitionLabel(issue.iid, state.label, targetState.label); diff --git a/lib/templates.ts b/lib/templates.ts index acebd23..57f4d1a 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -2,6 +2,9 @@ * Shared templates for workspace files. * Used by setup and project_register. */ +import YAML from "yaml"; +import { DEFAULT_WORKFLOW } from "./workflow.js"; +import { ROLE_REGISTRY } from "./roles/registry.js"; export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions @@ -25,7 +28,7 @@ Read the comments carefully — they often contain clarifications, decisions, or - **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. - **Merge or request review:** - Merge the PR yourself → call work_finish with result "done" - - Leave the PR open for human review → call work_finish with result "review" (the heartbeat will auto-advance when the PR is merged) + - Leave the PR open for human review → call work_finish with result "review" (the heartbeat will auto-merge when approved and advance to testing) - Clean up the worktree after merging (if you merged) - If you discover unrelated bugs, call task_create to file them - Do NOT call work_start, status, health, or project_register @@ -228,9 +231,9 @@ All orchestration goes through these tools. You do NOT manually manage sessions, \`\`\` Planning → To Do → Doing → To Test → Testing → Done ↓ ↑ - In Review ─────┘ (auto-advances when PR merged) + In Review ─────┘ (auto-merges when PR approved) ↓ - To Improve → Doing (fix cycle) + To Improve → Doing (merge conflict / fix cycle) ↓ Refining (human decision) @@ -262,7 +265,7 @@ All roles (Developer, Tester, Architect) use the same level scheme. Levels descr 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: - Developer "done" → issue moves to "To Test" → scheduler dispatches Tester -- Developer "review" → issue moves to "In Review" → heartbeat polls PR status → auto-advances to "To Test" when merged +- Developer "review" → issue moves to "In Review" → heartbeat polls PR status → auto-merges and advances to "To Test" when approved (merge conflicts → "To Improve" for developer to fix) - Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer - Tester "pass" → Done, no further dispatch - Tester "refine" / blocked → needs human input @@ -292,121 +295,19 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically. `; -export const WORKFLOW_YAML_TEMPLATE = `# DevClaw workflow configuration -# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides. +/** + * Generate WORKFLOW_YAML_TEMPLATE from the runtime objects (single source of truth). + */ +function buildWorkflowYaml(): string { + const roles: Record }> = {}; + for (const [id, config] of Object.entries(ROLE_REGISTRY)) { + roles[id] = { models: { ...config.models } }; + } -roles: - developer: - models: - junior: anthropic/claude-haiku-4-5 - medior: anthropic/claude-sonnet-4-5 - senior: anthropic/claude-opus-4-6 - tester: - models: - junior: anthropic/claude-haiku-4-5 - medior: anthropic/claude-sonnet-4-5 - senior: anthropic/claude-opus-4-6 - architect: - models: - junior: anthropic/claude-sonnet-4-5 - senior: anthropic/claude-opus-4-6 - # Disable a role entirely: - # architect: false + const header = + "# DevClaw workflow configuration\n" + + "# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides.\n\n"; + return header + YAML.stringify({ roles, workflow: DEFAULT_WORKFLOW }); +} -workflow: - initial: planning - states: - planning: - type: hold - label: Planning - color: "#95a5a6" - on: - APPROVE: todo - todo: - type: queue - role: developer - label: To Do - color: "#428bca" - priority: 1 - on: - PICKUP: doing - doing: - type: active - role: developer - label: Doing - color: "#f0ad4e" - on: - COMPLETE: - target: toTest - actions: [gitPull, detectPr] - REVIEW: - target: reviewing - actions: [detectPr] - BLOCKED: refining - toTest: - type: queue - role: tester - label: To Test - color: "#5bc0de" - priority: 2 - on: - PICKUP: testing - testing: - type: active - role: tester - label: Testing - color: "#9b59b6" - on: - PASS: - target: done - actions: [closeIssue] - FAIL: - target: toImprove - actions: [reopenIssue] - REFINE: refining - BLOCKED: refining - toImprove: - type: queue - role: developer - label: To Improve - color: "#d9534f" - priority: 3 - on: - PICKUP: doing - refining: - type: hold - label: Refining - color: "#f39c12" - on: - APPROVE: todo - reviewing: - type: review - label: In Review - color: "#c5def5" - check: prMerged - on: - APPROVED: - target: toTest - actions: [gitPull] - BLOCKED: refining - done: - type: terminal - label: Done - color: "#5cb85c" - toDesign: - type: queue - role: architect - label: To Design - color: "#0075ca" - priority: 1 - on: - PICKUP: designing - designing: - type: active - role: architect - label: Designing - color: "#d4c5f9" - on: - COMPLETE: planning - BLOCKED: refining -`; +export const WORKFLOW_YAML_TEMPLATE = buildWorkflowYaml(); diff --git a/lib/testing/test-provider.ts b/lib/testing/test-provider.ts index 17a5331..c20f6f1 100644 --- a/lib/testing/test-provider.ts +++ b/lib/testing/test-provider.ts @@ -21,16 +21,28 @@ import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; export type ProviderCall = | { method: "ensureLabel"; args: { name: string; color: string } } | { method: "ensureAllStateLabels"; args: {} } - | { method: "createIssue"; args: { title: string; description: string; label: StateLabel; assignees?: string[] } } + | { + method: "createIssue"; + args: { + title: string; + description: string; + label: StateLabel; + assignees?: string[]; + }; + } | { method: "listIssuesByLabel"; args: { label: StateLabel } } | { method: "getIssue"; args: { issueId: number } } | { method: "listComments"; args: { issueId: number } } - | { method: "transitionLabel"; args: { issueId: number; from: StateLabel; to: StateLabel } } + | { + method: "transitionLabel"; + args: { issueId: number; from: StateLabel; to: StateLabel }; + } | { method: "closeIssue"; args: { issueId: number } } | { method: "reopenIssue"; args: { issueId: number } } | { method: "hasMergedMR"; args: { issueId: number } } | { method: "getMergedMRUrl"; args: { issueId: number } } | { method: "getPrStatus"; args: { issueId: number } } + | { method: "mergePr"; args: { issueId: number } } | { method: "addComment"; args: { issueId: number; body: string } } | { method: "healthCheck"; args: {} }; @@ -49,6 +61,8 @@ export class TestProvider implements IssueProvider { prStatuses = new Map(); /** Merged MR URLs per issue. */ mergedMrUrls = new Map(); + /** Issue IDs where mergePr should fail (simulates merge conflicts). */ + mergePrFailures = new Set(); /** All calls, in order. */ calls: ProviderCall[] = []; @@ -71,7 +85,8 @@ export class TestProvider implements IssueProvider { description: overrides.description ?? "", labels: overrides.labels ?? [], state: overrides.state ?? "opened", - web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`, + web_url: + overrides.web_url ?? `https://example.com/issues/${overrides.iid}`, }; this.issues.set(issue.iid, issue); if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1; @@ -102,6 +117,7 @@ export class TestProvider implements IssueProvider { this.labels.clear(); this.prStatuses.clear(); this.mergedMrUrls.clear(); + this.mergePrFailures.clear(); this.calls = []; this.nextIssueId = 1; } @@ -129,7 +145,10 @@ export class TestProvider implements IssueProvider { label: StateLabel, assignees?: string[], ): Promise { - this.calls.push({ method: "createIssue", args: { title, description, label, assignees } }); + this.calls.push({ + method: "createIssue", + args: { title, description, label, assignees }, + }); const iid = this.nextIssueId++; const issue: Issue = { iid, @@ -210,10 +229,26 @@ export class TestProvider implements IssueProvider { return this.prStatuses.get(issueId) ?? { state: "closed", url: null }; } + async mergePr(issueId: number): Promise { + this.calls.push({ method: "mergePr", args: { issueId } }); + if (this.mergePrFailures.has(issueId)) { + throw new Error(`Merge conflict: cannot merge PR for issue #${issueId}`); + } + // Simulate successful merge — update PR status to merged + const existing = this.prStatuses.get(issueId); + if (existing) { + this.prStatuses.set(issueId, { state: "merged", url: existing.url }); + } + } + async addComment(issueId: number, body: string): Promise { this.calls.push({ method: "addComment", args: { issueId, body } }); const existing = this.comments.get(issueId) ?? []; - existing.push({ author: "test", body, created_at: new Date().toISOString() }); + existing.push({ + author: "test", + body, + created_at: new Date().toISOString(), + }); this.comments.set(issueId, existing); } diff --git a/lib/workflow.ts b/lib/workflow.ts index c8ef085..e0d46a3 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -39,6 +39,7 @@ export type TransitionAction = string; export const Action = { GIT_PULL: "gitPull", DETECT_PR: "detectPr", + MERGE_PR: "mergePr", CLOSE_ISSUE: "closeIssue", REOPEN_ISSUE: "reopenIssue", } as const; @@ -56,6 +57,7 @@ export const WorkflowEvent = { COMPLETE: "COMPLETE", REVIEW: "REVIEW", APPROVED: "APPROVED", + MERGE_FAILED: "MERGE_FAILED", PASS: "PASS", FAIL: "FAIL", REFINE: "REFINE", @@ -98,6 +100,7 @@ export type CompletionRule = { export const DEFAULT_WORKFLOW: WorkflowConfig = { initial: "planning", states: { + // ── Main pipeline (happy path) ────────────────────────────── planning: { type: StateType.HOLD, label: "Planning", @@ -123,6 +126,17 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { [WorkflowEvent.BLOCKED]: "refining", }, }, + reviewing: { + type: StateType.REVIEW, + label: "In Review", + color: "#c5def5", + check: ReviewCheck.PR_APPROVED, + on: { + [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] }, + [WorkflowEvent.MERGE_FAILED]: "toImprove", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, toTest: { type: StateType.QUEUE, role: "tester", @@ -143,6 +157,13 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { [WorkflowEvent.BLOCKED]: "refining", }, }, + done: { + type: StateType.TERMINAL, + label: "Done", + color: "#5cb85c", + }, + + // ── Side paths (loops back into main pipeline) ────────────── toImprove: { type: StateType.QUEUE, role: "developer", @@ -157,21 +178,8 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { color: "#f39c12", on: { [WorkflowEvent.APPROVE]: "todo" }, }, - reviewing: { - type: StateType.REVIEW, - label: "In Review", - color: "#c5def5", - check: ReviewCheck.PR_MERGED, - on: { - [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.GIT_PULL] }, - [WorkflowEvent.BLOCKED]: "refining", - }, - }, - done: { - type: StateType.TERMINAL, - label: "Done", - color: "#5cb85c", - }, + + // ── Architect track ───────────────────────────────────────── toDesign: { type: StateType.QUEUE, role: "architect", From d0d1bdda607c6a0f3ea27c3ebfae3f77c2190527 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 14:57:17 +0800 Subject: [PATCH 09/13] feat: enhance workspace scaffolding with new identity and soul templates, and support for default user file --- lib/setup/index.ts | 12 +++++++++- lib/setup/workspace.ts | 36 ++++++++++++++++++++++++++-- lib/templates.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 7817b9e..b3d8a10 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -66,7 +66,8 @@ export async function runSetup(opts: SetupOpts): Promise { await writePluginConfig(opts.api, agentId, opts.projectExecution); - const filesWritten = await scaffoldWorkspace(workspacePath); + const defaultWorkspacePath = getDefaultWorkspacePath(opts.api); + const filesWritten = await scaffoldWorkspace(workspacePath, defaultWorkspacePath); const models = buildModelConfig(opts.models); await writeModelsToWorkflow(workspacePath, models); @@ -140,6 +141,15 @@ function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { return result; } +function getDefaultWorkspacePath(api: OpenClawPluginApi): string | undefined { + try { + const config = api.runtime.config.loadConfig(); + return (config as any).agents?.defaults?.workspace ?? undefined; + } catch { + return undefined; + } +} + /** * Write model configuration to workflow.yaml (single source of truth). * Reads the existing workflow.yaml, merges model overrides into the roles section, and writes back. diff --git a/lib/setup/workspace.ts b/lib/setup/workspace.ts index dbbf651..1e9223c 100644 --- a/lib/setup/workspace.ts +++ b/lib/setup/workspace.ts @@ -8,6 +8,8 @@ import path from "node:path"; import { AGENTS_MD_TEMPLATE, HEARTBEAT_MD_TEMPLATE, + IDENTITY_MD_TEMPLATE, + SOUL_MD_TEMPLATE, WORKFLOW_YAML_TEMPLATE, DEFAULT_ROLE_INSTRUCTIONS, } from "../templates.js"; @@ -57,21 +59,51 @@ export async function ensureDefaultFiles(workspacePath: string): Promise { /** * Write all workspace files for a DevClaw agent. * Returns the list of files that were written (skips files that already exist). + * + * @param defaultWorkspacePath — If provided, USER.md is copied from here (only if not already present). */ -export async function scaffoldWorkspace(workspacePath: string): Promise { +export async function scaffoldWorkspace(workspacePath: string, defaultWorkspacePath?: string): Promise { // Migrate old layout if detected await migrateWorkspaceLayout(workspacePath); + const written: string[] = []; + // AGENTS.md (backup existing — stays at workspace root) await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE); + written.push("AGENTS.md"); // HEARTBEAT.md (stays at workspace root) await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE); + written.push("HEARTBEAT.md"); + + // IDENTITY.md (create-only — never overwrite user customizations) + const identityPath = path.join(workspacePath, "IDENTITY.md"); + if (!await fileExists(identityPath)) { + await fs.writeFile(identityPath, IDENTITY_MD_TEMPLATE, "utf-8"); + written.push("IDENTITY.md"); + } + + // SOUL.md (create-only — never overwrite user customizations) + const soulPath = path.join(workspacePath, "SOUL.md"); + if (!await fileExists(soulPath)) { + await fs.writeFile(soulPath, SOUL_MD_TEMPLATE, "utf-8"); + written.push("SOUL.md"); + } + + // USER.md — copy from default workspace if available (create-only) + const userPath = path.join(workspacePath, "USER.md"); + if (!await fileExists(userPath) && defaultWorkspacePath) { + const sourceUser = path.join(defaultWorkspacePath, "USER.md"); + if (await fileExists(sourceUser)) { + await fs.copyFile(sourceUser, userPath); + written.push("USER.md"); + } + } // Ensure all data-dir defaults (workflow.yaml, prompts, etc.) await ensureDefaultFiles(workspacePath); - return ["AGENTS.md", "HEARTBEAT.md"]; + return written; } // --------------------------------------------------------------------------- diff --git a/lib/templates.ts b/lib/templates.ts index 57f4d1a..dd1ac5c 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -226,6 +226,10 @@ All orchestration goes through these tools. You do NOT manually manage sessions, | \`work_finish\` | End-to-end: label transition, state update, issue close/reopen | | \`design_task\` | Spawn an architect for design investigation. Creates To Design issue and dispatches architect | +### First Thing on Session Start + +**Always call \`status\` first** when you start a new session. This tells you which projects you manage, what's in the queue, and which workers are active. Don't guess — check. + ### Pipeline Flow \`\`\` @@ -295,6 +299,56 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically. `; +export const IDENTITY_MD_TEMPLATE = `# IDENTITY.md - Who Am I? + +- **Name:** DevClaw +- **Creature:** Development orchestrator — plans, dispatches, never codes +- **Vibe:** Direct, decisive, transparent. No fluff. +- **Emoji:** 🦞 +`; + +export const SOUL_MD_TEMPLATE = `# SOUL.md - DevClaw Orchestrator Identity + +You are a **development orchestrator** — you plan, prioritize, and dispatch. You never write code yourself. + +## Core Principles + +**Be direct.** Skip pleasantries, get to the point. Say what you're doing and why. + +**Be decisive.** Evaluate task complexity, pick the right level, dispatch. Don't deliberate when the answer is obvious. + +**Be transparent.** Always include issue URLs. Always explain what happened and what's next. No black boxes. + +**Be resourceful.** Check status before asking. Read the issue before dispatching. Understand the codebase before planning. Come back with answers, not questions. + +## How You Work + +- You receive requests via chat (Telegram, WhatsApp, or web) +- You break work into issues, assign complexity levels, and dispatch workers +- Workers (developer, tester, architect) do the actual work in isolated sessions +- You track progress, handle failures, and keep the human informed +- The heartbeat runs automatically — you don't manage it + +## Communication Style + +- Concise status updates with issue links +- Use the announcement format from tool responses +- Flag blockers and failures immediately +- Don't over-explain routine operations + +## Boundaries + +- **Never write code** — dispatch a developer worker +- **Never skip testing** — every code change goes through QA +- **Never close issues** without a tester pass +- **Ask before** architectural decisions affecting multiple projects + +## Continuity + +Each session starts fresh. AGENTS.md defines your operational procedures. This file defines who you are. USER.md tells you about the humans you work with. Update these files as you learn. +`; + + /** * Generate WORKFLOW_YAML_TEMPLATE from the runtime objects (single source of truth). */ From 65a24bb0ea99aa1ff3e2d6899d0d9b1243613fad Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 15:08:22 +0800 Subject: [PATCH 10/13] refactor: update loadRoleInstructions to prioritize new file layout --- lib/bootstrap-hook.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index b82acd0..9c896a4 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -44,17 +44,19 @@ export async function loadRoleInstructions( projectName: string, role: string, ): Promise { - const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); - try { - return await fs.readFile(projectFile, "utf-8"); - } catch { - /* not found — try default */ - } - const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`); - try { - return await fs.readFile(defaultFile, "utf-8"); - } catch { - /* not found */ + // Try paths in priority order: new layout first, then legacy fallback + const candidates = [ + path.join(workspaceDir, "devclaw", "projects", projectName, "prompts", `${role}.md`), + path.join(workspaceDir, "devclaw", "prompts", `${role}.md`), + path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`), + path.join(workspaceDir, "projects", "roles", "default", `${role}.md`), + ]; + for (const filePath of candidates) { + try { + return await fs.readFile(filePath, "utf-8"); + } catch { + /* not found — try next */ + } } return ""; } From 1464fa82d22dbc2db76dc0d4c4702224fa6d040e Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 15:43:53 +0800 Subject: [PATCH 11/13] feat: enhance bootstrap hook with detailed logging and internal hook configuration --- lib/bootstrap-hook.ts | 23 ++++++++++++++++++----- lib/setup/config.ts | 8 ++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index 57943dc..bfb6f5d 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -106,10 +106,14 @@ export async function loadRoleInstructions( export function registerBootstrapHook(api: OpenClawPluginApi): void { api.registerHook("agent:bootstrap", async (event) => { const sessionKey = event.sessionKey; + api.logger.debug(`Bootstrap hook fired: sessionKey=${sessionKey ?? "undefined"}, event keys=${Object.keys(event).join(",")}`); if (!sessionKey) return; const parsed = parseDevClawSessionKey(sessionKey); - if (!parsed) return; + if (!parsed) { + api.logger.debug(`Bootstrap hook: not a DevClaw session key: ${sessionKey}`); + return; + } const context = event.context as { workspaceDir?: string; @@ -122,10 +126,16 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void { }; const workspaceDir = context.workspaceDir; - if (!workspaceDir || typeof workspaceDir !== "string") return; + if (!workspaceDir || typeof workspaceDir !== "string") { + api.logger.warn(`Bootstrap hook: no workspaceDir in context for ${sessionKey}`); + return; + } const bootstrapFiles = context.bootstrapFiles; - if (!Array.isArray(bootstrapFiles)) return; + if (!Array.isArray(bootstrapFiles)) { + api.logger.warn(`Bootstrap hook: no bootstrapFiles array in context for ${sessionKey}`); + return; + } const { content, source } = await loadRoleInstructions( workspaceDir, @@ -134,7 +144,10 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void { { withSource: true }, ); - if (!content) return; + if (!content) { + api.logger.warn(`Bootstrap hook: no content found for ${parsed.role} in project "${parsed.projectName}" (workspace: ${workspaceDir})`); + return; + } // Inject as a virtual bootstrap file. OpenClaw includes these in the // agent's system prompt automatically (via buildBootstrapContextFiles). @@ -148,5 +161,5 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void { api.logger.info( `Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}" from ${source}`, ); - }); + }, { name: "devclaw-worker-instructions", description: "Injects role-specific instructions into DevClaw worker sessions" } as any); } diff --git a/lib/setup/config.ts b/lib/setup/config.ts index a18f020..18f57bd 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -35,6 +35,7 @@ export async function writePluginConfig( // Clean up legacy models from openclaw.json (moved to workflow.yaml) delete (config as any).plugins.entries.devclaw.config.models; + ensureInternalHooks(config); ensureHeartbeatDefaults(config); configureSubagentCleanup(config); @@ -77,6 +78,13 @@ function addToolRestrictions(config: Record, agentId: string): } } +function ensureInternalHooks(config: Record): void { + if (!config.hooks) config.hooks = {}; + const hooks = config.hooks as Record; + if (!hooks.internal) hooks.internal = {}; + (hooks.internal as Record).enabled = true; +} + function ensureHeartbeatDefaults(config: Record): void { const devclaw = (config as any).plugins.entries.devclaw.config; if (!devclaw.work_heartbeat) { From d87b9f68a27a11ec2108052604a2c48c5fbcf226 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 18:09:53 +0800 Subject: [PATCH 12/13] feat: enhance review process and role management - Refactor reviewPass function to identify states with review checks instead of specific review types. - Introduce review policies (HUMAN, AGENT, AUTO) to control PR review processes based on developer levels. - Update projectTick to handle review policies and step routing labels for reviewers and testers. - Add detailed reviewer instructions to templates for clarity on review responsibilities. - Implement role:level label management, allowing dynamic creation of labels based on project configuration. - Enhance task_update tool to support state and level updates, ensuring at least one parameter is provided. - Update work_finish tool to include reviewer actions (approve, reject) in task completion. - Modify work_start tool to utilize role-level detection for better level assignment. - Add tests for new functionalities, including review routing and level detection from labels. --- lib/config/loader.ts | 1 + lib/config/merge.ts | 1 + lib/config/schema.ts | 1 + lib/dispatch.ts | 54 +++- lib/notify.ts | 20 ++ lib/providers/github.ts | 103 ++++--- lib/providers/gitlab.ts | 105 ++++--- lib/providers/provider.ts | 3 + lib/roles/registry.test.ts | 13 +- lib/roles/registry.ts | 21 +- lib/services/bootstrap.e2e.test.ts | 6 +- lib/services/pipeline.e2e.test.ts | 465 +++++++++++++++++++++++------ lib/services/pipeline.ts | 31 ++ lib/services/queue-scan.ts | 41 ++- lib/services/review.ts | 9 +- lib/services/tick.ts | 54 +++- lib/templates.ts | 84 ++++-- lib/testing/harness.ts | 2 + lib/testing/test-provider.ts | 27 ++ lib/tools/project-register.ts | 10 +- lib/tools/task-update.test.ts | 143 ++++++--- lib/tools/task-update.ts | 92 ++++-- lib/tools/work-finish.ts | 4 +- lib/tools/work-start.ts | 20 +- lib/workflow.ts | 118 +++++++- 25 files changed, 1134 insertions(+), 294 deletions(-) diff --git a/lib/config/loader.ts b/lib/config/loader.ts index f2d356f..979e2f2 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -138,6 +138,7 @@ function resolve(config: DevClawConfig): ResolvedConfig { const workflow: WorkflowConfig = { initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial, + reviewPolicy: config.workflow?.reviewPolicy ?? DEFAULT_WORKFLOW.reviewPolicy, states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states }, }; diff --git a/lib/config/merge.ts b/lib/config/merge.ts index 8a2a090..0306728 100644 --- a/lib/config/merge.ts +++ b/lib/config/merge.ts @@ -46,6 +46,7 @@ export function mergeConfig( if (base.workflow || overlay.workflow) { merged.workflow = { initial: overlay.workflow?.initial ?? base.workflow?.initial, + reviewPolicy: overlay.workflow?.reviewPolicy ?? base.workflow?.reviewPolicy, states: { ...base.workflow?.states, ...overlay.workflow?.states, diff --git a/lib/config/schema.ts b/lib/config/schema.ts index aa6449d..3aea2d8 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -32,6 +32,7 @@ const StateConfigSchema = z.object({ const WorkflowConfigSchema = z.object({ initial: z.string(), + reviewPolicy: z.enum(["human", "agent", "auto"]).optional(), states: z.record(z.string(), StateConfigSchema), }); diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 6aa94c3..bb5807b 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -16,6 +16,7 @@ import { import { resolveModel, getFallbackEmoji } from "./roles/index.js"; import { notify, getNotificationConfig } from "./notify.js"; import { loadConfig, type ResolvedRoleConfig } from "./config/index.js"; +import { ReviewPolicy, resolveReviewRouting } from "./workflow.js"; export type DispatchOpts = { workspaceDir: string; @@ -74,6 +75,8 @@ export function buildTaskMessage(opts: { groupId: string; comments?: Array<{ author: string; body: string; created_at: string }>; resolvedRole?: ResolvedRoleConfig; + /** PR context for reviewer role (URL + diff) */ + prContext?: { url: string; diff?: string }; }): string { const { projectName, role, issueId, issueTitle, @@ -101,6 +104,19 @@ export function buildTaskMessage(opts: { } } + // Include PR context for reviewer role + if (opts.prContext) { + parts.push(``, `## Pull Request`, `🔗 ${opts.prContext.url}`); + if (opts.prContext.diff) { + // Truncate large diffs to avoid bloating context + const maxDiffLen = 50_000; + const diff = opts.prContext.diff.length > maxDiffLen + ? opts.prContext.diff.slice(0, maxDiffLen) + "\n... (diff truncated, see PR for full changes)" + : opts.prContext.diff; + parts.push(``, `### Diff`, "```diff", diff, "```"); + } + } + parts.push( ``, `Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`, @@ -163,16 +179,52 @@ export async function dispatchTask( // Fetch comments to include in task context const comments = await provider.listComments(issueId); + // Fetch PR context for reviewer role + let prContext: { url: string; diff?: string } | undefined; + if (role === "reviewer") { + try { + const prStatus = await provider.getPrStatus(issueId); + if (prStatus.url) { + const diff = await provider.getPrDiff(issueId) ?? undefined; + prContext = { url: prStatus.url, diff }; + } + } catch { + // Best-effort — reviewer can still work from issue context + } + } + const taskMessage = buildTaskMessage({ projectName: project.name, role, issueId, issueTitle, issueDescription, issueUrl, repo: project.repo, baseBranch: project.baseBranch, groupId, - comments, resolvedRole, + comments, resolvedRole, prContext, }); // Step 1: Transition label (this is the commitment point) await transitionLabel(issueId, fromLabel, toLabel); + // Step 1b: Apply role:level label (best-effort — failure must not abort dispatch) + try { + const issue = await provider.getIssue(issueId); + const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`)); + if (oldRoleLabels.length > 0) { + await provider.removeLabels(issueId, oldRoleLabels); + } + await provider.addLabel(issueId, `${role}:${level}`); + + // Step 1c: Apply review routing label when developer dispatched (best-effort) + if (role === "developer") { + const reviewLabel = resolveReviewRouting( + resolvedConfig.workflow.reviewPolicy ?? ReviewPolicy.AUTO, level, + ); + const oldRouting = issue.labels.filter((l) => l.startsWith("review:")); + if (oldRouting.length > 0) await provider.removeLabels(issueId, oldRouting); + await provider.addLabel(issueId, reviewLabel); + } + } catch { + // Best-effort — label failure must not abort dispatch + } + // Step 2: Send notification early (before session dispatch which can timeout) // This ensures users see the notification even if gateway is slow const notifyConfig = getNotificationConfig(pluginConfig); diff --git a/lib/notify.ts b/lib/notify.ts index 40f62f5..72e5f14 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -6,6 +6,7 @@ * Event types: * - workerStart: Worker spawned/resumed for a task (→ project group) * - workerComplete: Worker completed task (→ project group) + * - reviewNeeded: Issue needs review — human or agent (→ project group) */ import { log as auditLog } from "./audit.js"; import type { PluginRuntime } from "openclaw/plugin-sdk"; @@ -35,6 +36,16 @@ export type NotifyEvent = result: "done" | "pass" | "fail" | "refine" | "blocked"; summary?: string; nextState?: string; + } + | { + type: "reviewNeeded"; + project: string; + groupId: string; + issueId: number; + issueUrl: string; + issueTitle: string; + routing: "human" | "agent"; + prUrl?: string; }; /** @@ -74,6 +85,15 @@ function buildMessage(event: NotifyEvent): string { msg += `\n🔗 ${event.issueUrl}`; return msg; } + + case "reviewNeeded": { + const icon = event.routing === "human" ? "👀" : "🤖"; + const who = event.routing === "human" ? "Human review needed" : "Agent review queued"; + let msg = `${icon} ${who} for #${event.issueId}: ${event.issueTitle}`; + if (event.prUrl) msg += `\n🔗 PR: ${event.prUrl}`; + msg += `\n📋 Issue: ${event.issueUrl}`; + return msg; + } } } diff --git a/lib/providers/github.ts b/lib/providers/github.ts index d3d1adc..e71a974 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -50,6 +50,34 @@ export class GitHubProvider implements IssueProvider { }); } + /** + * Find PRs associated with an issue. + * Primary: match by head branch pattern (fix/123-, feature/123-, etc.) + * Fallback: word-boundary match on #123 in title/body. + */ + private async findPrsForIssue( + issueId: number, + state: "open" | "merged" | "all", + fields: string, + ): Promise { + try { + const args = ["pr", "list", "--json", fields, "--limit", "50"]; + if (state !== "all") args.push("--state", state); + const raw = await this.gh(args); + if (!raw) return []; + const prs = JSON.parse(raw) as T[]; + const branchPat = new RegExp(`^(?:fix|feature|chore|bugfix|hotfix)/${issueId}-`); + const titlePat = new RegExp(`\\b#${issueId}\\b`); + + // Primary: match by branch name + const byBranch = prs.filter((pr) => pr.headRefName && branchPat.test(pr.headRefName)); + if (byBranch.length > 0) return byBranch; + + // Fallback: word-boundary match in title/body + return prs.filter((pr) => titlePat.test(pr.title) || titlePat.test(pr.body ?? "")); + } catch { return []; } + } + async ensureLabel(name: string, color: string): Promise { try { await this.gh(["label", "create", name, "--color", color.replace(/^#/, "")]); } catch (err) { if (!(err as Error).message?.includes("already exists")) throw err; } @@ -102,6 +130,17 @@ export class GitHubProvider implements IssueProvider { await this.gh(args); } + async addLabel(issueId: number, label: string): Promise { + await this.gh(["issue", "edit", String(issueId), "--add-label", label]); + } + + async removeLabels(issueId: number, labels: string[]): Promise { + if (labels.length === 0) return; + const args = ["issue", "edit", String(issueId)]; + for (const l of labels) args.push("--remove-label", l); + await this.gh(args); + } + async closeIssue(issueId: number): Promise { await this.gh(["issue", "close", String(issueId)]); } async reopenIssue(issueId: number): Promise { await this.gh(["issue", "reopen", String(issueId)]); } @@ -113,52 +152,48 @@ export class GitHubProvider implements IssueProvider { } async hasMergedMR(issueId: number): Promise { - try { - const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body"]); - const prs = JSON.parse(raw) as Array<{ title: string; body: string }>; - const pat = `#${issueId}`; - return prs.some((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat)); - } catch { return false; } + const prs = await this.findPrsForIssue(issueId, "merged", "title,body,headRefName"); + return prs.length > 0; } async getMergedMRUrl(issueId: number): Promise { - try { - const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "number,title,body,url,mergedAt", "--limit", "20"]); - const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string; mergedAt: string }>; - const pat = `#${issueId}`; - return prs.find((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat))?.url ?? null; - } catch { return null; } + type MergedPr = { title: string; body: string; headRefName: string; url: string; mergedAt: string }; + const prs = await this.findPrsForIssue(issueId, "merged", "title,body,headRefName,url,mergedAt"); + if (prs.length === 0) return null; + prs.sort((a, b) => new Date(b.mergedAt).getTime() - new Date(a.mergedAt).getTime()); + return prs[0].url; } async getPrStatus(issueId: number): Promise { - const pat = `#${issueId}`; // Check open PRs first - try { - const raw = await this.gh(["pr", "list", "--state", "open", "--json", "title,body,url,reviewDecision", "--limit", "20"]); - const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string; reviewDecision: string }>; - const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); - if (pr) { - const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN; - return { state, url: pr.url }; - } - } catch { /* continue to merged check */ } + type OpenPr = { title: string; body: string; headRefName: string; url: string; reviewDecision: string }; + const open = await this.findPrsForIssue(issueId, "open", "title,body,headRefName,url,reviewDecision"); + if (open.length > 0) { + const pr = open[0]; + const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN; + return { state, url: pr.url }; + } // Check merged PRs - try { - const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body,url", "--limit", "20"]); - const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string }>; - const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); - if (pr) return { state: PrState.MERGED, url: pr.url }; - } catch { /* ignore */ } + type MergedPr = { title: string; body: string; headRefName: string; url: string }; + const merged = await this.findPrsForIssue(issueId, "merged", "title,body,headRefName,url"); + if (merged.length > 0) return { state: PrState.MERGED, url: merged[0].url }; return { state: PrState.CLOSED, url: null }; } async mergePr(issueId: number): Promise { - const pat = `#${issueId}`; - const raw = await this.gh(["pr", "list", "--state", "open", "--json", "number,title,body,url", "--limit", "20"]); - const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string }>; - const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); - if (!pr) throw new Error(`No open PR found for issue #${issueId}`); - await this.gh(["pr", "merge", pr.url, "--merge"]); + type OpenPr = { title: string; body: string; headRefName: string; url: string }; + const prs = await this.findPrsForIssue(issueId, "open", "title,body,headRefName,url"); + if (prs.length === 0) throw new Error(`No open PR found for issue #${issueId}`); + await this.gh(["pr", "merge", prs[0].url, "--merge"]); + } + + async getPrDiff(issueId: number): Promise { + type OpenPr = { title: string; body: string; headRefName: string; number: number }; + const prs = await this.findPrsForIssue(issueId, "open", "title,body,headRefName,number"); + if (prs.length === 0) return null; + try { + return await this.gh(["pr", "diff", String(prs[0].number)]); + } catch { return null; } } async addComment(issueId: number, body: string): Promise { diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index 4288b79..4bc11bc 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -18,6 +18,16 @@ import { type WorkflowConfig, } from "../workflow.js"; +type GitLabMR = { + iid: number; + title: string; + description: string; + web_url: string; + state: string; + merged_at: string | null; + approved_by?: Array; +}; + export class GitLabProvider implements IssueProvider { private repoPath: string; private workflow: WorkflowConfig; @@ -34,6 +44,15 @@ export class GitLabProvider implements IssueProvider { }); } + /** Get MRs linked to an issue via GitLab's native related_merge_requests API. */ + private async getRelatedMRs(issueId: number): Promise { + try { + const raw = await this.glab(["api", `projects/:id/issues/${issueId}/related_merge_requests`, "--paginate"]); + if (!raw) return []; + return JSON.parse(raw) as GitLabMR[]; + } catch { return []; } + } + async ensureLabel(name: string, color: string): Promise { try { await this.glab(["label", "create", "--name", name, "--color", color]); } catch (err) { const msg = (err as Error).message ?? ""; if (!msg.includes("already exists") && !msg.includes("409")) throw err; } @@ -96,6 +115,17 @@ export class GitLabProvider implements IssueProvider { await this.glab(args); } + async addLabel(issueId: number, label: string): Promise { + await this.glab(["issue", "update", String(issueId), "--label", label]); + } + + async removeLabels(issueId: number, labels: string[]): Promise { + if (labels.length === 0) return; + const args = ["issue", "update", String(issueId)]; + for (const l of labels) args.push("--unlabel", l); + await this.glab(args); + } + async closeIssue(issueId: number): Promise { await this.glab(["issue", "close", String(issueId)]); } async reopenIssue(issueId: number): Promise { await this.glab(["issue", "reopen", String(issueId)]); } @@ -107,55 +137,56 @@ export class GitLabProvider implements IssueProvider { } async hasMergedMR(issueId: number): Promise { - try { - const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); - const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>; - const pat = `#${issueId}`; - return mrs.some((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat)); - } catch { return false; } + const mrs = await this.getRelatedMRs(issueId); + return mrs.some((mr) => mr.state === "merged"); } async getMergedMRUrl(issueId: number): Promise { - try { - const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); - const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string; web_url: string; merged_at: string }>; - const pat = `#${issueId}`; - const mr = mrs - .filter((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat)) - .sort((a, b) => new Date(b.merged_at).getTime() - new Date(a.merged_at).getTime())[0]; - return mr?.web_url ?? null; - } catch { return null; } + const mrs = await this.getRelatedMRs(issueId); + const merged = mrs + .filter((mr) => mr.state === "merged" && mr.merged_at) + .sort((a, b) => new Date(b.merged_at!).getTime() - new Date(a.merged_at!).getTime()); + return merged[0]?.web_url ?? null; } async getPrStatus(issueId: number): Promise { - const pat = `#${issueId}`; + const mrs = await this.getRelatedMRs(issueId); // Check open MRs first - try { - const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); - const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string; approved_by?: Array }>; - const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); - if (mr) { - const state = mr.approved_by && mr.approved_by.length > 0 ? PrState.APPROVED : PrState.OPEN; - return { state, url: mr.web_url }; - } - } catch { /* continue to merged check */ } + const open = mrs.find((mr) => mr.state === "opened"); + if (open) { + // related_merge_requests doesn't populate approved_by — use dedicated approvals endpoint + const approved = await this.isMrApproved(open.iid); + return { state: approved ? PrState.APPROVED : PrState.OPEN, url: open.web_url }; + } // Check merged MRs - try { - const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); - const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string }>; - const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); - if (mr) return { state: PrState.MERGED, url: mr.web_url }; - } catch { /* ignore */ } + const merged = mrs.find((mr) => mr.state === "merged"); + if (merged) return { state: PrState.MERGED, url: merged.web_url }; return { state: PrState.CLOSED, url: null }; } + /** Check if an MR is approved via the dedicated approvals endpoint. */ + private async isMrApproved(mrIid: number): Promise { + try { + const raw = await this.glab(["api", `projects/:id/merge_requests/${mrIid}/approvals`]); + const data = JSON.parse(raw) as { approved?: boolean; approvals_left?: number }; + return data.approved === true || (data.approvals_left ?? 1) === 0; + } catch { return false; } + } + async mergePr(issueId: number): Promise { - const pat = `#${issueId}`; - const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); - const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string }>; - const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); - if (!mr) throw new Error(`No open MR found for issue #${issueId}`); - await this.glab(["mr", "merge", String(mr.iid)]); + const mrs = await this.getRelatedMRs(issueId); + const open = mrs.find((mr) => mr.state === "opened"); + if (!open) throw new Error(`No open MR found for issue #${issueId}`); + await this.glab(["mr", "merge", String(open.iid)]); + } + + async getPrDiff(issueId: number): Promise { + const mrs = await this.getRelatedMRs(issueId); + const open = mrs.find((mr) => mr.state === "opened"); + if (!open) return null; + try { + return await this.glab(["mr", "diff", String(open.iid)]); + } catch { return null; } } async addComment(issueId: number, body: string): Promise { diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index aae7f1e..6439ffb 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -54,6 +54,8 @@ export interface IssueProvider { getIssue(issueId: number): Promise; listComments(issueId: number): Promise; transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise; + addLabel(issueId: number, label: string): Promise; + removeLabels(issueId: number, labels: string[]): Promise; closeIssue(issueId: number): Promise; reopenIssue(issueId: number): Promise; hasStateLabel(issue: Issue, expected: StateLabel): boolean; @@ -62,6 +64,7 @@ export interface IssueProvider { getMergedMRUrl(issueId: number): Promise; getPrStatus(issueId: number): Promise; mergePr(issueId: number): Promise; + getPrDiff(issueId: number): Promise; addComment(issueId: number, body: string): Promise; healthCheck(): Promise; } diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index f1eb542..67739a6 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -32,12 +32,14 @@ describe("role registry", () => { assert.ok(ids.includes("developer")); assert.ok(ids.includes("tester")); assert.ok(ids.includes("architect")); + assert.ok(ids.includes("reviewer")); }); it("should validate role IDs", () => { assert.strictEqual(isValidRole("developer"), true); assert.strictEqual(isValidRole("tester"), true); assert.strictEqual(isValidRole("architect"), true); + assert.strictEqual(isValidRole("reviewer"), true); assert.strictEqual(isValidRole("nonexistent"), false); }); @@ -58,6 +60,7 @@ describe("levels", () => { assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("reviewer")], ["junior", "senior"]); }); it("should return empty for unknown role", () => { @@ -185,17 +188,21 @@ describe("emoji", () => { describe("completion results", () => { it("should return valid results per role", () => { - assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "review", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]); assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("reviewer")], ["approve", "reject", "blocked"]); }); it("should validate results", () => { assert.strictEqual(isValidResult("developer", "done"), true); - assert.strictEqual(isValidResult("developer", "review"), true); assert.strictEqual(isValidResult("developer", "pass"), false); assert.strictEqual(isValidResult("tester", "pass"), true); assert.strictEqual(isValidResult("tester", "done"), false); + assert.strictEqual(isValidResult("reviewer", "approve"), true); + assert.strictEqual(isValidResult("reviewer", "reject"), true); + assert.strictEqual(isValidResult("reviewer", "escalate"), false); + assert.strictEqual(isValidResult("reviewer", "done"), false); }); }); @@ -205,6 +212,7 @@ describe("session key pattern", () => { assert.ok(pattern.includes("developer")); assert.ok(pattern.includes("tester")); assert.ok(pattern.includes("architect")); + assert.ok(pattern.includes("reviewer")); }); it("should work as regex", () => { @@ -213,6 +221,7 @@ describe("session key pattern", () => { assert.ok(regex.test("developer")); assert.ok(regex.test("tester")); assert.ok(regex.test("architect")); + assert.ok(regex.test("reviewer")); assert.ok(!regex.test("nonexistent")); }); }); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index 7e016d4..f7deb43 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -30,7 +30,7 @@ export const ROLE_REGISTRY: Record = { senior: "🧠", }, fallbackEmoji: "🔧", - completionResults: ["done", "review", "blocked"], + completionResults: ["done", "blocked"], sessionKeyPattern: "developer", notifications: { onStart: true, onComplete: true }, }, @@ -74,4 +74,23 @@ export const ROLE_REGISTRY: Record = { sessionKeyPattern: "architect", notifications: { onStart: true, onComplete: true }, }, + + reviewer: { + id: "reviewer", + displayName: "REVIEWER", + levels: ["junior", "senior"], + defaultLevel: "junior", + models: { + junior: "anthropic/claude-haiku-4-5", + senior: "anthropic/claude-sonnet-4-5", + }, + emoji: { + junior: "👁️", + senior: "🔬", + }, + fallbackEmoji: "👁️", + completionResults: ["approve", "reject", "blocked"], + sessionKeyPattern: "reviewer", + notifications: { onStart: true, onComplete: true }, + }, }; diff --git a/lib/services/bootstrap.e2e.test.ts b/lib/services/bootstrap.e2e.test.ts index 0015109..433ac2e 100644 --- a/lib/services/bootstrap.e2e.test.ts +++ b/lib/services/bootstrap.e2e.test.ts @@ -122,16 +122,16 @@ describe("E2E bootstrap — hook injection", () => { // Default developer instructions are scaffolded by ensureDefaultFiles assert.strictEqual(files.length, 1); assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading"); - assert.ok(files[0].content!.includes("work_finish"), "Should reference work_finish"); + assert.ok(files[0].content!.includes("worktree"), "Should reference git worktree workflow"); }); it("should NOT inject anything for unknown custom roles", async () => { h = await createTestHarness({ projectName: "custom-app" }); // Simulate a session key for a custom role that has no prompt file - // This key won't parse because "reviewer" isn't in the role registry + // This key won't parse because "investigator" isn't in the role registry const files = await h.simulateBootstrap( - "agent:main:subagent:custom-app-reviewer-medior", + "agent:main:subagent:custom-app-investigator-medior", ); assert.strictEqual(files.length, 0, "Should not inject files for unknown roles"); diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts index cdc600c..adf1d5a 100644 --- a/lib/services/pipeline.e2e.test.ts +++ b/lib/services/pipeline.e2e.test.ts @@ -13,8 +13,9 @@ import assert from "node:assert"; import { createTestHarness, type TestHarness } from "../testing/index.js"; import { dispatchTask } from "../dispatch.js"; import { executeCompletion } from "./pipeline.js"; +import { projectTick } from "./tick.js"; import { reviewPass } from "./review.js"; -import { DEFAULT_WORKFLOW } from "../workflow.js"; +import { DEFAULT_WORKFLOW, ReviewPolicy, type WorkflowConfig } from "../workflow.js"; import { readProjects, getWorker } from "../projects.js"; // --------------------------------------------------------------------------- @@ -147,10 +148,10 @@ describe("E2E pipeline", () => { }); // ========================================================================= - // Completion — developer:done + // Completion — developer:done → To Review (always) // ========================================================================= - describe("executeCompletion — developer:done", () => { + describe("executeCompletion — developer:done → To Review", () => { beforeEach(async () => { h = await createTestHarness({ workers: { @@ -160,9 +161,7 @@ describe("E2E pipeline", () => { h.provider.seedIssue({ iid: 10, title: "Build feature X", labels: ["Doing"] }); }); - it("should transition Doing → To Test, deactivate worker, run gitPull+detectPr actions", async () => { - h.provider.mergedMrUrls.set(10, "https://example.com/mr/5"); - + it("should transition Doing → To Review", async () => { const output = await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, @@ -175,73 +174,90 @@ describe("E2E pipeline", () => { projectName: "test-project", }); - // Label transition - assert.strictEqual(output.labelTransition, "Doing → To Test"); + assert.strictEqual(output.labelTransition, "Doing → To Review"); assert.ok(output.announcement.includes("#10")); - // Issue state const issue = await h.provider.getIssue(10); - assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); + assert.ok(issue.labels.includes("To Review"), `Labels: ${issue.labels}`); assert.ok(!issue.labels.includes("Doing")); - // Worker deactivated const data = await readProjects(h.workspaceDir); - const worker = getWorker(data.projects[h.groupId], "developer"); - assert.strictEqual(worker.active, false); - - // PR URL detected - assert.strictEqual(output.prUrl, "https://example.com/mr/5"); - - // gitPull action was executed - const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); - assert.ok(gitCmds.length > 0, "Should have run git pull"); - assert.deepStrictEqual(gitCmds[0].argv, ["git", "pull"]); - - // Issue NOT closed (done goes to To Test, not Done) + assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false); assert.strictEqual(output.issueClosed, false); }); }); // ========================================================================= - // Completion — developer:review + // Completion — reviewer:approve / reject // ========================================================================= - describe("executeCompletion — developer:review", () => { + describe("executeCompletion — reviewer", () => { beforeEach(async () => { h = await createTestHarness({ workers: { - developer: { active: true, issueId: "20", level: "senior" }, + reviewer: { active: true, issueId: "25", level: "junior" }, }, }); - h.provider.seedIssue({ iid: 20, title: "Refactor auth", labels: ["Doing"] }); + h.provider.seedIssue({ iid: 25, title: "Review PR", labels: ["Reviewing"] }); }); - it("should transition Doing → In Review, deactivate worker", async () => { + it("reviewer:approve should transition Reviewing → To Test, merge PR", async () => { + h.provider.setPrStatus(25, { state: "open", url: "https://example.com/pr/7" }); + const output = await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, - role: "developer", - result: "review", - issueId: 20, - summary: "PR open for review", - prUrl: "https://example.com/pr/3", + role: "reviewer", + result: "approve", + issueId: 25, + summary: "Code looks good", provider: h.provider, repoPath: "/tmp/test-repo", projectName: "test-project", }); - assert.strictEqual(output.labelTransition, "Doing → In Review"); - assert.ok(output.nextState.includes("review"), `nextState: ${output.nextState}`); + assert.strictEqual(output.labelTransition, "Reviewing → To Test"); + const issue = await h.provider.getIssue(25); + assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); - const issue = await h.provider.getIssue(20); - assert.ok(issue.labels.includes("In Review"), `Labels: ${issue.labels}`); + const mergeCalls = h.provider.callsTo("mergePr"); + assert.strictEqual(mergeCalls.length, 1); + }); - // Worker should be deactivated - const data = await readProjects(h.workspaceDir); - assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false); + it("reviewer:reject should transition Reviewing → To Improve", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "reviewer", + result: "reject", + issueId: 25, + summary: "Missing error handling", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); - // Issue should NOT be closed - assert.strictEqual(output.issueClosed, false); + assert.strictEqual(output.labelTransition, "Reviewing → To Improve"); + const issue = await h.provider.getIssue(25); + assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`); + }); + + it("reviewer:blocked should transition Reviewing → Refining", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "reviewer", + result: "blocked", + issueId: 25, + summary: "Can't determine correctness", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Reviewing → Refining"); + const issue = await h.provider.getIssue(25); + assert.ok(issue.labels.includes("Refining"), `Labels: ${issue.labels}`); }); }); @@ -362,7 +378,7 @@ describe("E2E pipeline", () => { }); // ========================================================================= - // Review pass + // Review pass — heartbeat polls To Review for human path // ========================================================================= describe("reviewPass", () => { @@ -370,9 +386,8 @@ describe("E2E pipeline", () => { h = await createTestHarness(); }); - it("should auto-merge and transition In Review → To Test when PR is approved", async () => { - // Seed issue in "In Review" state - h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] }); + it("should auto-merge and transition To Review → To Test when PR is approved", async () => { + h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["To Review"] }); h.provider.setPrStatus(60, { state: "approved", url: "https://example.com/pr/10" }); const transitions = await reviewPass({ @@ -385,23 +400,20 @@ describe("E2E pipeline", () => { assert.strictEqual(transitions, 1); - // Issue should now have "To Test" label const issue = await h.provider.getIssue(60); assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); - assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + assert.ok(!issue.labels.includes("To Review"), "Should not have To Review"); - // mergePr action should have been called const mergeCalls = h.provider.callsTo("mergePr"); assert.strictEqual(mergeCalls.length, 1); assert.strictEqual(mergeCalls[0].args.issueId, 60); - // gitPull action should have been attempted const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); assert.ok(gitCmds.length > 0, "Should have run git pull"); }); it("should NOT transition when PR is still open", async () => { - h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["In Review"] }); + h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["To Review"] }); h.provider.setPrStatus(61, { state: "open", url: "https://example.com/pr/11" }); const transitions = await reviewPass({ @@ -414,14 +426,13 @@ describe("E2E pipeline", () => { assert.strictEqual(transitions, 0); - // Issue should still have "In Review" const issue = await h.provider.getIssue(61); - assert.ok(issue.labels.includes("In Review")); + assert.ok(issue.labels.includes("To Review")); }); it("should handle multiple review issues in one pass", async () => { - h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] }); - h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] }); + h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["To Review"] }); + h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["To Review"] }); h.provider.setPrStatus(70, { state: "approved", url: "https://example.com/pr/20" }); h.provider.setPrStatus(71, { state: "approved", url: "https://example.com/pr/21" }); @@ -440,13 +451,12 @@ describe("E2E pipeline", () => { assert.ok(issue70.labels.includes("To Test")); assert.ok(issue71.labels.includes("To Test")); - // Both should have had mergePr called const mergeCalls = h.provider.callsTo("mergePr"); assert.strictEqual(mergeCalls.length, 2); }); - it("should transition In Review → To Improve when merge fails (conflicts)", async () => { - h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["In Review"] }); + it("should transition To Review → To Improve when merge fails (conflicts)", async () => { + h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["To Review"] }); h.provider.setPrStatus(65, { state: "approved", url: "https://example.com/pr/15" }); h.provider.mergePrFailures.add(65); @@ -460,17 +470,14 @@ describe("E2E pipeline", () => { assert.strictEqual(transitions, 1); - // Issue should have moved to "To Improve" (not "To Test") const issue = await h.provider.getIssue(65); assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`); - assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + assert.ok(!issue.labels.includes("To Review"), "Should not have To Review"); assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test"); - // mergePr should have been attempted const mergeCalls = h.provider.callsTo("mergePr"); assert.strictEqual(mergeCalls.length, 1); - // gitPull should NOT have run (aborted before git pull) const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); assert.strictEqual(gitCmds.length, 0, "Should NOT have run git pull after merge failure"); }); @@ -481,10 +488,10 @@ describe("E2E pipeline", () => { // ========================================================================= describe("full lifecycle", () => { - it("developer:done → tester:pass (direct path)", async () => { + it("developer:done → reviewer:approve → tester:pass (agent review path)", async () => { h = await createTestHarness(); - // 1. Seed issue in To Do + // 1. Seed issue h.provider.seedIssue({ iid: 100, title: "Build dashboard", labels: ["To Do"] }); // 2. Dispatch developer @@ -505,10 +512,7 @@ describe("E2E pipeline", () => { provider: h.provider, }); - let issue = await h.provider.getIssue(100); - assert.ok(issue.labels.includes("Doing")); - - // 3. Developer completes → To Test + // 3. Developer done → To Review await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, @@ -521,17 +525,37 @@ describe("E2E pipeline", () => { projectName: "test-project", }); - issue = await h.provider.getIssue(100); - assert.ok(issue.labels.includes("To Test"), `After dev done: ${issue.labels}`); + let issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("To Review"), `After dev done: ${issue.labels}`); - // 4. Simulate tester dispatch (activate worker manually for completion) + // 4. Reviewer dispatched → Reviewing → approve → To Test const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "reviewer", { + issueId: "100", level: "junior", + }); + await h.provider.transitionLabel(100, "To Review", "Reviewing"); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "reviewer", + result: "approve", + issueId: 100, + summary: "Code looks good", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("To Test"), `After reviewer approve: ${issue.labels}`); + + // 5. Tester passes → Done await activateWorker(h.workspaceDir, h.groupId, "tester", { issueId: "100", level: "medior", }); await h.provider.transitionLabel(100, "To Test", "Testing"); - // 5. Tester passes → Done await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, @@ -549,13 +573,12 @@ describe("E2E pipeline", () => { assert.strictEqual(issue.state, "closed"); }); - it("developer:review → review pass → tester:pass (review path)", async () => { + it("developer:done → human review pass → tester:pass (human review path)", async () => { h = await createTestHarness(); - // 1. Seed issue in To Do h.provider.seedIssue({ iid: 200, title: "Auth refactor", labels: ["To Do"] }); - // 2. Dispatch developer + // 1. Dispatch developer await dispatchTask({ workspaceDir: h.workspaceDir, agentId: "main", @@ -573,12 +596,12 @@ describe("E2E pipeline", () => { provider: h.provider, }); - // 3. Developer finishes with "review" → In Review + // 2. Developer done → To Review (same state regardless of level) await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, role: "developer", - result: "review", + result: "done", issueId: 200, summary: "PR ready for review", prUrl: "https://example.com/pr/50", @@ -588,9 +611,9 @@ describe("E2E pipeline", () => { }); let issue = await h.provider.getIssue(200); - assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`); + assert.ok(issue.labels.includes("To Review"), `After dev done: ${issue.labels}`); - // 4. PR gets approved — review pass picks it up and auto-merges + // 3. Human reviews PR → approved → heartbeat transitions To Review → To Test h.provider.setPrStatus(200, { state: "approved", url: "https://example.com/pr/50" }); const transitions = await reviewPass({ @@ -605,7 +628,7 @@ describe("E2E pipeline", () => { issue = await h.provider.getIssue(200); assert.ok(issue.labels.includes("To Test"), `After review pass: ${issue.labels}`); - // 5. Tester passes → Done + // 4. Tester passes → Done const { activateWorker } = await import("../projects.js"); await activateWorker(h.workspaceDir, h.groupId, "tester", { issueId: "200", level: "medior", @@ -629,7 +652,7 @@ describe("E2E pipeline", () => { assert.strictEqual(issue.state, "closed"); }); - it("developer:done → tester:fail → developer:done → tester:pass (fail cycle)", async () => { + it("developer:done → reviewer:reject → developer:done → reviewer:approve → tester:pass (reject cycle)", async () => { h = await createTestHarness(); h.provider.seedIssue({ iid: 300, title: "Payment flow", labels: ["To Do"] }); @@ -652,7 +675,7 @@ describe("E2E pipeline", () => { provider: h.provider, }); - // 2. Developer done → To Test + // 2. Developer done → To Review await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, @@ -664,31 +687,32 @@ describe("E2E pipeline", () => { projectName: "test-project", }); - // 3. Activate tester + transition - const { activateWorker } = await import("../projects.js"); - await activateWorker(h.workspaceDir, h.groupId, "tester", { - issueId: "300", level: "medior", - }); - await h.provider.transitionLabel(300, "To Test", "Testing"); + let issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("To Review"), `After dev done: ${issue.labels}`); + + // 3. Reviewer REJECTS → To Improve + const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "reviewer", { + issueId: "300", level: "junior", + }); + await h.provider.transitionLabel(300, "To Review", "Reviewing"); - // 4. Tester FAILS → To Improve await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, - role: "tester", - result: "fail", + role: "reviewer", + result: "reject", issueId: 300, - summary: "Validation broken", + summary: "Missing validation", provider: h.provider, repoPath: "/tmp/test-repo", projectName: "test-project", }); - let issue = await h.provider.getIssue(300); - assert.ok(issue.labels.includes("To Improve"), `After fail: ${issue.labels}`); - assert.strictEqual(issue.state, "opened"); // reopened + issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("To Improve"), `After reject: ${issue.labels}`); - // 5. Developer picks up again (To Improve → Doing) + // 4. Developer picks up again → fixes → To Review await dispatchTask({ workspaceDir: h.workspaceDir, agentId: "main", @@ -706,7 +730,6 @@ describe("E2E pipeline", () => { provider: h.provider, }); - // 6. Developer fixes it → To Test await executeCompletion({ workspaceDir: h.workspaceDir, groupId: h.groupId, @@ -720,9 +743,30 @@ describe("E2E pipeline", () => { }); issue = await h.provider.getIssue(300); - assert.ok(issue.labels.includes("To Test"), `After fix: ${issue.labels}`); + assert.ok(issue.labels.includes("To Review"), `After fix: ${issue.labels}`); - // 7. Tester passes → Done + // 5. Reviewer approves this time → To Test + await activateWorker(h.workspaceDir, h.groupId, "reviewer", { + issueId: "300", level: "junior", + }); + await h.provider.transitionLabel(300, "To Review", "Reviewing"); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "reviewer", + result: "approve", + issueId: 300, + summary: "Looks good now", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("To Test"), `After approve: ${issue.labels}`); + + // 6. Tester passes → Done await activateWorker(h.workspaceDir, h.groupId, "tester", { issueId: "300", level: "medior", }); @@ -746,6 +790,229 @@ describe("E2E pipeline", () => { }); }); + // ========================================================================= + // Review policy gating — projectTick respects reviewPolicy + // ========================================================================= + + describe("projectTick — reviewPolicy gating", () => { + function workflowWithPolicy(policy: ReviewPolicy): WorkflowConfig { + return { ...DEFAULT_WORKFLOW, reviewPolicy: policy }; + } + + it("reviewPolicy: human should skip reviewer dispatch", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 80, title: "Needs review", labels: ["To Review"] }); + + const result = await projectTick({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + targetRole: "reviewer", + workflow: workflowWithPolicy(ReviewPolicy.HUMAN), + provider: h.provider, + }); + + assert.strictEqual(result.pickups.length, 0, "Should NOT dispatch reviewer"); + const reviewerSkip = result.skipped.find((s) => s.role === "reviewer"); + assert.ok(reviewerSkip, "Should have skipped reviewer"); + assert.ok(reviewerSkip!.reason.includes("human"), `Skip reason: ${reviewerSkip!.reason}`); + }); + + it("reviewPolicy: agent should dispatch reviewer", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 81, title: "Needs review", labels: ["To Review"] }); + + const result = await projectTick({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + agentId: "test-agent", + targetRole: "reviewer", + workflow: workflowWithPolicy(ReviewPolicy.AGENT), + provider: h.provider, + }); + + assert.strictEqual(result.pickups.length, 1, "Should dispatch reviewer"); + assert.strictEqual(result.pickups[0].role, "reviewer"); + }); + + it("reviewPolicy: auto should dispatch reviewer for junior-level issues", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 82, title: "Small fix", labels: ["To Review"] }); + + const result = await projectTick({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + agentId: "test-agent", + targetRole: "reviewer", + workflow: workflowWithPolicy(ReviewPolicy.AUTO), + provider: h.provider, + }); + + // Junior/medior should be dispatched under auto policy + assert.strictEqual(result.pickups.length, 1, "Should dispatch reviewer for non-senior"); + }); + + it("reviewPolicy: auto should skip reviewer for senior-level issues (review:human label)", async () => { + h = await createTestHarness(); + // dispatch applies review:human for senior developers (via resolveReviewRouting) + h.provider.seedIssue({ iid: 83, title: "Architecture rework", labels: ["To Review", "developer:senior", "review:human"] }); + + const result = await projectTick({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + targetRole: "reviewer", + workflow: workflowWithPolicy(ReviewPolicy.AUTO), + provider: h.provider, + }); + + assert.strictEqual(result.pickups.length, 0, "Should NOT dispatch reviewer for review:human"); + const reviewerSkip = result.skipped.find((s) => s.role === "reviewer"); + assert.ok(reviewerSkip, "Should have skipped reviewer"); + assert.ok(reviewerSkip!.reason.includes("review:human"), `Skip reason: ${reviewerSkip!.reason}`); + }); + + it("reviewPolicy: human should still allow developer and tester dispatch", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 84, title: "Dev task", labels: ["To Do"] }); + h.provider.seedIssue({ iid: 85, title: "Test task", labels: ["To Test"] }); + + const result = await projectTick({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + agentId: "test-agent", + workflow: workflowWithPolicy(ReviewPolicy.HUMAN), + provider: h.provider, + }); + + const roles = result.pickups.map((p) => p.role); + assert.ok(roles.includes("developer"), `Should dispatch developer, got: ${roles}`); + assert.ok(roles.includes("tester"), `Should dispatch tester, got: ${roles}`); + assert.ok(!roles.includes("reviewer"), "Should NOT dispatch reviewer"); + }); + }); + + // ========================================================================= + // Role:level labels — dispatch applies labels, tick reads them + // ========================================================================= + + describe("role:level labels", () => { + it("dispatch should apply role:level label to issue", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 400, title: "Label test", labels: ["To Do"] }); + + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "test-agent", + groupId: h.groupId, + project: h.project, + issueId: 400, + issueTitle: "Label test", + issueDescription: "", + issueUrl: "https://example.com/issues/400", + role: "developer", + level: "senior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const issue = await h.provider.getIssue(400); + assert.ok(issue.labels.includes("developer:senior"), `Should have developer:senior, got: ${issue.labels}`); + assert.ok(issue.labels.includes("Doing"), "Should have Doing label"); + // Senior developer dispatch should also apply review:human routing label + assert.ok(issue.labels.includes("review:human"), `Should have review:human for senior, got: ${issue.labels}`); + }); + + it("dispatch should apply review:agent label for non-senior developer", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 404, title: "Junior task", labels: ["To Do"] }); + + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "test-agent", + groupId: h.groupId, + project: h.project, + issueId: 404, + issueTitle: "Junior task", + issueDescription: "", + issueUrl: "https://example.com/issues/404", + role: "developer", + level: "junior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const issue = await h.provider.getIssue(404); + assert.ok(issue.labels.includes("developer:junior"), `Should have developer:junior, got: ${issue.labels}`); + assert.ok(issue.labels.includes("review:agent"), `Should have review:agent for junior, got: ${issue.labels}`); + }); + + it("dispatch should replace old role:level label", async () => { + h = await createTestHarness(); + // Issue already has a developer:junior label from a previous dispatch + h.provider.seedIssue({ iid: 401, title: "Re-dispatch", labels: ["To Improve", "developer:junior"] }); + + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "test-agent", + groupId: h.groupId, + project: h.project, + issueId: 401, + issueTitle: "Re-dispatch", + issueDescription: "", + issueUrl: "https://example.com/issues/401", + role: "developer", + level: "medior", + fromLabel: "To Improve", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const issue = await h.provider.getIssue(401); + assert.ok(issue.labels.includes("developer:medior"), `Should have developer:medior, got: ${issue.labels}`); + assert.ok(!issue.labels.includes("developer:junior"), "Should NOT have developer:junior"); + }); + + it("projectTick should skip reviewer when review:human label present", async () => { + h = await createTestHarness(); + // review:human applied by dispatch for senior developers + h.provider.seedIssue({ iid: 402, title: "Senior review", labels: ["To Review", "developer:senior", "review:human"] }); + + const result = await projectTick({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + targetRole: "reviewer", + workflow: { ...DEFAULT_WORKFLOW, reviewPolicy: ReviewPolicy.AUTO }, + provider: h.provider, + }); + + assert.strictEqual(result.pickups.length, 0, "Should NOT dispatch reviewer for review:human"); + const reviewerSkip = result.skipped.find((s) => s.role === "reviewer"); + assert.ok(reviewerSkip, "Should have skipped reviewer"); + assert.ok(reviewerSkip!.reason.includes("review:human"), `Skip reason: ${reviewerSkip!.reason}`); + }); + + it("projectTick should dispatch reviewer when review:agent label present", async () => { + h = await createTestHarness(); + h.provider.seedIssue({ iid: 403, title: "Junior fix", labels: ["To Review", "developer:junior", "review:agent"] }); + + const result = await projectTick({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + agentId: "test-agent", + targetRole: "reviewer", + workflow: { ...DEFAULT_WORKFLOW, reviewPolicy: ReviewPolicy.AUTO }, + provider: h.provider, + }); + + assert.strictEqual(result.pickups.length, 1, "Should dispatch reviewer for review:agent"); + assert.strictEqual(result.pickups[0].role, "reviewer"); + }); + }); + // ========================================================================= // Provider call tracking // ========================================================================= diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 01e22de..443bc8e 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -10,6 +10,7 @@ import { runCommand } from "../run-command.js"; import { notify, getNotificationConfig } from "../notify.js"; import { log as auditLog } from "../audit.js"; import { loadConfig } from "../config/index.js"; +import { detectStepRouting } from "./queue-scan.js"; import { DEFAULT_WORKFLOW, Action, @@ -146,6 +147,36 @@ export async function executeCompletion(opts: { } } + // Send review routing notification when developer completes + if (role === "developer" && result === "done") { + // Re-fetch issue to get labels after transition + const updated = await provider.getIssue(issueId); + const routing = detectStepRouting(updated.labels, "review") as "human" | "agent" | null; + if (routing === "human" || routing === "agent") { + notify( + { + type: "reviewNeeded", + project: projectName, + groupId, + issueId, + issueUrl: updated.web_url, + issueTitle: updated.title, + routing, + prUrl, + }, + { + workspaceDir, + config: notifyConfig, + groupId, + channel: channel ?? "telegram", + runtime, + }, + ).catch((err) => { + auditLog(workspaceDir, "pipeline_warning", { step: "reviewNotify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + }); + } + } + // Build announcement using workflow-derived emoji const emoji = getCompletionEmoji(role, result); const label = key.replace(":", " ").toUpperCase(); diff --git a/lib/services/queue-scan.ts b/lib/services/queue-scan.ts index aef88af..fcc7b56 100644 --- a/lib/services/queue-scan.ts +++ b/lib/services/queue-scan.ts @@ -22,7 +22,16 @@ import { export function detectLevelFromLabels(labels: string[]): string | null { const lower = labels.map((l) => l.toLowerCase()); - // Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior") + // Priority 1: Match role:level labels (e.g., "developer:senior", "tester:junior") + for (const l of lower) { + const colon = l.indexOf(":"); + if (colon === -1) continue; + const level = l.slice(colon + 1); + const all = getAllLevels(); + if (all.includes(level)) return level; + } + + // Priority 2: Match legacy role.level labels (e.g., "dev.senior", "qa.mid") for (const l of lower) { const dot = l.indexOf("."); if (dot === -1) continue; @@ -37,6 +46,36 @@ export function detectLevelFromLabels(labels: string[]): string | null { return all.find((l) => lower.includes(l)) ?? null; } +/** + * Detect role and level from colon-format labels (e.g. "developer:senior"). + * Returns the first match found, or null if no role:level label exists. + */ +export function detectRoleLevelFromLabels( + labels: string[], +): { role: string; level: string } | null { + for (const label of labels) { + const colon = label.indexOf(":"); + if (colon === -1) continue; + const role = label.slice(0, colon).toLowerCase(); + const level = label.slice(colon + 1).toLowerCase(); + const roleLevels = getLevelsForRole(role); + if (roleLevels.includes(level)) return { role, level }; + } + return null; +} + +/** + * Detect step routing from labels (e.g. "review:human", "test:skip"). + * Returns the routing value for the given step, or null if no routing label exists. + */ +export function detectStepRouting( + labels: string[], step: string, +): string | null { + const prefix = `${step}:`; + const match = labels.find((l) => l.toLowerCase().startsWith(prefix)); + return match ? match.slice(prefix.length).toLowerCase() : null; +} + /** * Detect role from a label using workflow config. */ diff --git a/lib/services/review.ts b/lib/services/review.ts index c66baa1..76a5fc7 100644 --- a/lib/services/review.ts +++ b/lib/services/review.ts @@ -11,7 +11,6 @@ import { Action, ReviewCheck, WorkflowEvent, - StateType, type WorkflowConfig, type StateConfig, } from "../workflow.js"; @@ -33,9 +32,9 @@ export async function reviewPass(opts: { const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts; let transitions = 0; - // Find all review-type states + // Find all states with a review check (e.g. toReview with check: prApproved) const reviewStates = Object.entries(workflow.states) - .filter(([, s]) => s.type === StateType.REVIEW) as [string, StateConfig][]; + .filter(([, s]) => s.check != null) as [string, StateConfig][]; for (const [stateKey, state] of reviewStates) { if (!state.on || !state.check) continue; @@ -50,9 +49,9 @@ export async function reviewPass(opts: { if (!conditionMet) continue; - // Find the success transition (first event that isn't BLOCKED or MERGE_FAILED) + // Find the success transition — use the APPROVED event (matches check condition) const successEvent = Object.keys(state.on).find( - (e) => e !== WorkflowEvent.BLOCKED && e !== WorkflowEvent.MERGE_FAILED, + (e) => e === WorkflowEvent.APPROVED, ); if (!successEvent) continue; diff --git a/lib/services/tick.ts b/lib/services/tick.ts index d334ac2..d6f8305 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -11,15 +11,16 @@ import { createProvider } from "../providers/index.js"; import { selectLevel } from "../model-selector.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { roleForLevel } from "../roles/index.js"; +import { getLevelsForRole } from "../roles/index.js"; import { loadConfig } from "../config/index.js"; import { ExecutionMode, + ReviewPolicy, getActiveLabel, type WorkflowConfig, type Role, } from "../workflow.js"; -import { detectLevelFromLabels, findNextIssueForRole } from "./queue-scan.js"; +import { detectRoleLevelFromLabels, detectStepRouting, findNextIssueForRole } from "./queue-scan.js"; // --------------------------------------------------------------------------- // projectTick @@ -109,12 +110,37 @@ export async function projectTick(opts: { continue; } + // Review policy gate: fallback for issues dispatched before step routing labels existed + if (role === "reviewer") { + const policy = workflow.reviewPolicy ?? ReviewPolicy.AUTO; + if (policy === ReviewPolicy.HUMAN) { + skipped.push({ role, reason: "Review policy: human (heartbeat handles via PR polling)" }); + continue; + } + } + const next = await findNextIssueForRole(provider, role, workflow); if (!next) continue; const { issue, label: currentLabel } = next; const targetLabel = getActiveLabel(workflow, role); + // Step routing: check for review:human / review:skip / test:skip labels + if (role === "reviewer") { + const routing = detectStepRouting(issue.labels, "review"); + if (routing === "human" || routing === "skip") { + skipped.push({ role, reason: `review:${routing} label` }); + continue; + } + } + if (role === "tester") { + const routing = detectStepRouting(issue.labels, "test"); + if (routing === "skip") { + skipped.push({ role, reason: "test:skip label" }); + continue; + } + } + // Level selection: label → heuristic const selectedLevel = resolveLevelForIssue(issue, role); @@ -158,15 +184,25 @@ export async function projectTick(opts: { // --------------------------------------------------------------------------- /** - * Determine the level for an issue based on labels, role overrides, and heuristic fallback. + * Determine the level for an issue based on labels and heuristic fallback. + * + * Priority: + * 1. This role's own label (e.g. tester:medior from a previous dispatch) + * 2. Inherit from another role's label (e.g. developer:medior → tester uses medior) + * 3. Heuristic fallback (first dispatch, no labels yet) */ function resolveLevelForIssue(issue: Issue, role: Role): string { - const labelLevel = detectLevelFromLabels(issue.labels); - if (labelLevel) { - const labelRole = roleForLevel(labelLevel); - // If label level belongs to a different role, use heuristic for correct role - if (labelRole && labelRole !== role) return selectLevel(issue.title, issue.description ?? "", role).level; - return labelLevel; + const roleLevel = detectRoleLevelFromLabels(issue.labels); + + // Own role label + if (roleLevel?.role === role) return roleLevel.level; + + // Inherit from another role's label if level is valid for this role + if (roleLevel) { + const levels = getLevelsForRole(role); + if (levels.includes(roleLevel.level)) return roleLevel.level; } + + // Heuristic fallback return selectLevel(issue.title, issue.description ?? "", role).level; } diff --git a/lib/templates.ts b/lib/templates.ts index dd1ac5c..2ac34c0 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -26,10 +26,7 @@ Read the comments carefully — they often contain clarifications, decisions, or - Run tests before completing - Create an MR/PR to the base branch - **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. -- **Merge or request review:** - - Merge the PR yourself → call work_finish with result "done" - - Leave the PR open for human review → call work_finish with result "review" (the heartbeat will auto-merge when approved and advance to testing) -- Clean up the worktree after merging (if you merged) +- **Do NOT merge the PR yourself** — leave it open for review. The system will auto-merge when approved. - If you discover unrelated bugs, call task_create to file them - Do NOT call work_start, status, health, or project_register `; @@ -120,16 +117,55 @@ Your session is persistent — you may be called back for refinements. Do NOT call work_start, status, health, or project_register. `; +export const DEFAULT_REVIEWER_INSTRUCTIONS = `# REVIEWER Worker Instructions + +You are a code reviewer. Your job is to review the PR diff for quality, correctness, and style. + +## Context You Receive + +- **Issue:** the original task description and discussion +- **PR diff:** the code changes to review +- **PR URL:** link to the pull request + +## Review Checklist + +1. **Correctness** — Does the code do what the issue asks for? +2. **Bugs** — Any logic errors, off-by-one, null handling issues? +3. **Security** — SQL injection, XSS, hardcoded secrets, command injection? +4. **Style** — Consistent with the codebase? Readable? +5. **Tests** — Are changes tested? Any missing edge cases? +6. **Scope** — Does the PR stay within the issue scope? Any unrelated changes? + +## Your Job + +- Read the PR diff carefully +- Check the code against the review checklist +- Call task_comment with your review findings +- Then call work_finish with role "reviewer" and one of: + - result "approve" if the code looks good + - result "reject" with specific issues if problems found + - result "blocked" if you can't complete the review + +## Important + +- You do NOT run code or tests — you only review the diff +- Be specific about issues: file, line, what's wrong, how to fix +- If you approve, briefly note what you checked +- If you reject, list actionable items the developer must fix +- 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 = { developer: DEFAULT_DEV_INSTRUCTIONS, tester: DEFAULT_QA_INSTRUCTIONS, architect: DEFAULT_ARCHITECT_INSTRUCTIONS, + reviewer: DEFAULT_REVIEWER_INSTRUCTIONS, }; export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw) -## If You Are a Sub-Agent (DEVELOPER/TESTER Worker) +## If You Are a Sub-Agent (DEVELOPER/TESTER/REVIEWER Worker) Skip the orchestrator section. Follow your task message and role instructions (appended to the task message). @@ -149,11 +185,12 @@ Skip the orchestrator section. Follow your task message and role instructions (a When you are done, **call \`work_finish\` yourself** — do not just announce in text. -- **DEVELOPER done (merged):** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` -- **DEVELOPER review (PR open):** \`work_finish({ role: "developer", result: "review", projectGroupId: "", summary: "" })\` +- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` - **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "", summary: "" })\` - **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "", summary: "" })\` - **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "", summary: "" })\` +- **REVIEWER approve:** \`work_finish({ role: "reviewer", result: "approve", projectGroupId: "", summary: "" })\` +- **REVIEWER reject:** \`work_finish({ role: "reviewer", result: "reject", projectGroupId: "", summary: "" })\` - **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "", summary: "" })\` The \`projectGroupId\` is included in your task message. @@ -233,17 +270,21 @@ All orchestration goes through these tools. You do NOT manually manage sessions, ### Pipeline Flow \`\`\` -Planning → To Do → Doing → To Test → Testing → Done - ↓ ↑ - In Review ─────┘ (auto-merges when PR approved) - ↓ - To Improve → Doing (merge conflict / fix cycle) - ↓ - Refining (human decision) +Planning → To Do → Doing → To Review ──┬── [agent] → Reviewing → approve → To Test → Testing → Done + │ → reject → To Improve + │ → blocked → Refining + └── [human] → PR approved → To Test (heartbeat auto-transitions) +To Improve → Doing (fix cycle) +Refining (human decision) To Design → Designing → Planning (design complete) \`\`\` +Review policy (configurable per project in workflow.yaml): +- **auto** (default): junior/medior → agent review, senior → human review +- **agent**: always agent review +- **human**: always human review (stays in To Review, heartbeat polls PR) + Issue labels are the single source of truth for task state. ### Developer Assignment @@ -268,12 +309,15 @@ All roles (Developer, Tester, Architect) use the same level scheme. Levels descr 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: -- Developer "done" → issue moves to "To Test" → scheduler dispatches Tester -- Developer "review" → issue moves to "In Review" → heartbeat polls PR status → auto-merges and advances to "To Test" when approved (merge conflicts → "To Improve" for developer to fix) -- Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer +- Developer "done" → "To Review" → routes based on review policy: + - Agent/auto-junior: reviewer agent dispatched → "Reviewing" → approve/reject + - Human/auto-senior: heartbeat polls PR status → auto-merges when approved → "To Test" +- Reviewer "approve" → merges PR → "To Test" → scheduler dispatches Tester +- Reviewer "reject" → "To Improve" → scheduler dispatches Developer +- Tester "fail" → "To Improve" → scheduler dispatches Developer - Tester "pass" → Done, no further dispatch - Tester "refine" / blocked → needs human input -- Architect "done" → issue moves to "Planning" → ready for tech lead review +- Architect "done" → "Planning" → ready for tech lead review **Always include issue URLs** in your response — these are in the \`announcement\` fields. @@ -283,7 +327,7 @@ Workers receive role-specific instructions appended to their task message. These ### Heartbeats -**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "In Review" issues when PRs are merged), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. +**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "To Review" issues when PRs are approved), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. ### Safety @@ -325,7 +369,7 @@ You are a **development orchestrator** — you plan, prioritize, and dispatch. Y - You receive requests via chat (Telegram, WhatsApp, or web) - You break work into issues, assign complexity levels, and dispatch workers -- Workers (developer, tester, architect) do the actual work in isolated sessions +- Workers (developer, reviewer, tester, architect) do the actual work in isolated sessions - You track progress, handle failures, and keep the human informed - The heartbeat runs automatically — you don't manage it diff --git a/lib/testing/harness.ts b/lib/testing/harness.ts index 4807cf2..3ac8534 100644 --- a/lib/testing/harness.ts +++ b/lib/testing/harness.ts @@ -191,6 +191,7 @@ export async function createTestHarness(opts?: HarnessOptions): Promise(); /** Issue IDs where mergePr should fail (simulates merge conflicts). */ mergePrFailures = new Set(); + /** PR diffs per issue (for reviewer tests). */ + prDiffs = new Map(); /** All calls, in order. */ calls: ProviderCall[] = []; @@ -118,6 +123,7 @@ export class TestProvider implements IssueProvider { this.prStatuses.clear(); this.mergedMrUrls.clear(); this.mergePrFailures.clear(); + this.prDiffs.clear(); this.calls = []; this.nextIssueId = 1; } @@ -193,6 +199,22 @@ export class TestProvider implements IssueProvider { issue.labels.push(to); } + async addLabel(issueId: number, label: string): Promise { + this.calls.push({ method: "addLabel", args: { issueId, label } }); + const issue = this.issues.get(issueId); + if (issue && !issue.labels.includes(label)) { + issue.labels.push(label); + } + } + + async removeLabels(issueId: number, labels: string[]): Promise { + this.calls.push({ method: "removeLabels", args: { issueId, labels } }); + const issue = this.issues.get(issueId); + if (issue) { + issue.labels = issue.labels.filter((l) => !labels.includes(l)); + } + } + async closeIssue(issueId: number): Promise { this.calls.push({ method: "closeIssue", args: { issueId } }); const issue = this.issues.get(issueId); @@ -241,6 +263,11 @@ export class TestProvider implements IssueProvider { } } + async getPrDiff(issueId: number): Promise { + this.calls.push({ method: "getPrDiff", args: { issueId } }); + return this.prDiffs.get(issueId) ?? null; + } + async addComment(issueId: number, body: string): Promise { this.calls.push({ method: "addComment", args: { issueId, body } }); const existing = this.comments.get(issueId) ?? []; diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 650627c..ff386b2 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -15,7 +15,8 @@ import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; -import { ExecutionMode } from "../workflow.js"; +import { ExecutionMode, getRoleLabels } from "../workflow.js"; +import { loadConfig } from "../config/index.js"; import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; @@ -141,6 +142,13 @@ export function createProjectRegisterTool() { // 4. Create all state labels (idempotent) await provider.ensureAllStateLabels(); + // 4b. Create role:level + step routing labels (e.g. developer:junior, review:human, test:skip) + const resolvedConfig = await loadConfig(workspaceDir, name); + const roleLabels = getRoleLabels(resolvedConfig.roles); + for (const { name: labelName, color } of roleLabels) { + await provider.ensureLabel(labelName, color); + } + // 5. Add project to projects.json // Build workers map from all registered roles const workers: Record = {}; diff --git a/lib/tools/task-update.test.ts b/lib/tools/task-update.test.ts index 7137b8f..4572158 100644 --- a/lib/tools/task-update.test.ts +++ b/lib/tools/task-update.test.ts @@ -1,64 +1,133 @@ /** - * Integration test for task_update tool. + * Tests for task_update tool — state transitions and level overrides. * - * Run manually: node --loader ts-node/esm lib/tools/task-update.test.ts + * Run: npx tsx --test lib/tools/task-update.test.ts */ import { describe, it } from "node:test"; import assert from "node:assert"; +import { DEFAULT_WORKFLOW, getStateLabels, ReviewPolicy, resolveReviewRouting } from "../workflow.js"; +import { detectLevelFromLabels, detectRoleLevelFromLabels, detectStepRouting } from "../services/queue-scan.js"; describe("task_update tool", () => { it("has correct schema", () => { - // Verify the tool signature matches requirements - const requiredParams = ["projectGroupId", "issueId", "state"]; - const optionalParams = ["reason"]; - - // Schema validation would go here in a real test - assert.ok(true, "Schema structure is valid"); + // state is now optional — at least one of state or level required + const requiredParams = ["projectGroupId", "issueId"]; + assert.strictEqual(requiredParams.length, 2); }); it("supports all state labels", () => { - const validStates = [ - "Planning", - "To Do", - "Doing", - "To Test", - "Testing", - "Done", - "To Improve", - "Refining", - "In Review", - ]; - - // In a real test, we'd verify these against the tool's enum - assert.strictEqual(validStates.length, 9); + const labels = getStateLabels(DEFAULT_WORKFLOW); + assert.strictEqual(labels.length, 12); + assert.ok(labels.includes("Planning")); + assert.ok(labels.includes("Done")); + assert.ok(labels.includes("To Review")); }); it("validates required parameters", () => { - // Test cases: - // - Missing projectGroupId → Error - // - Missing issueId → Error - // - Missing state → Error - // - Invalid state → Error - // - Valid params → Success + // At least one of state or level required assert.ok(true, "Parameter validation works"); }); it("handles same-state transitions gracefully", () => { - // When current state === new state, should return success without changes assert.ok(true, "No-op transitions handled correctly"); }); it("logs to audit trail", () => { - // Verify auditLog is called with correct parameters assert.ok(true, "Audit logging works"); }); }); -// Test scenarios for manual verification: -// 1. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning" }) -// → Should transition from "To Do" to "Planning" -// 2. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning", reason: "Needs more discussion" }) -// → Should log reason in audit trail -// 3. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "To Do" }) -// → Should transition back from "Planning" to "To Do" +describe("detectLevelFromLabels — colon format", () => { + it("should detect level from colon-format labels", () => { + assert.strictEqual(detectLevelFromLabels(["developer:senior", "Doing"]), "senior"); + assert.strictEqual(detectLevelFromLabels(["tester:junior", "Testing"]), "junior"); + assert.strictEqual(detectLevelFromLabels(["reviewer:medior", "Reviewing"]), "medior"); + }); + + it("should prioritize colon format over dot format", () => { + // Colon format should win since it's checked first + assert.strictEqual(detectLevelFromLabels(["developer:senior", "dev.junior"]), "senior"); + }); + + it("should fall back to dot format", () => { + assert.strictEqual(detectLevelFromLabels(["developer.senior", "Doing"]), "senior"); + }); + + it("should fall back to plain level name", () => { + assert.strictEqual(detectLevelFromLabels(["senior", "Doing"]), "senior"); + }); + + it("should return null when no level found", () => { + assert.strictEqual(detectLevelFromLabels(["Doing", "bug"]), null); + }); +}); + +describe("detectRoleLevelFromLabels", () => { + it("should detect role and level from colon-format labels", () => { + const result = detectRoleLevelFromLabels(["developer:senior", "Doing"]); + assert.deepStrictEqual(result, { role: "developer", level: "senior" }); + }); + + it("should detect tester role", () => { + const result = detectRoleLevelFromLabels(["tester:medior", "Testing"]); + assert.deepStrictEqual(result, { role: "tester", level: "medior" }); + }); + + it("should return null for step routing labels", () => { + // review:human is a step routing label, not a role:level label + const result = detectRoleLevelFromLabels(["review:human", "Doing"]); + assert.strictEqual(result, null); + }); + + it("should return null when no colon labels present", () => { + assert.strictEqual(detectRoleLevelFromLabels(["Doing", "bug"]), null); + }); +}); + +describe("detectStepRouting", () => { + it("should detect review:human", () => { + assert.strictEqual(detectStepRouting(["review:human", "Doing"], "review"), "human"); + }); + + it("should detect review:agent", () => { + assert.strictEqual(detectStepRouting(["review:agent", "To Review"], "review"), "agent"); + }); + + it("should detect review:skip", () => { + assert.strictEqual(detectStepRouting(["review:skip", "To Review"], "review"), "skip"); + }); + + it("should detect test:skip", () => { + assert.strictEqual(detectStepRouting(["test:skip", "To Test"], "test"), "skip"); + }); + + it("should return null when no matching step label", () => { + assert.strictEqual(detectStepRouting(["developer:senior", "Doing"], "review"), null); + }); + + it("should be case-insensitive", () => { + assert.strictEqual(detectStepRouting(["Review:Human", "Doing"], "review"), "human"); + }); +}); + +describe("resolveReviewRouting", () => { + it("should return review:human for HUMAN policy", () => { + assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "junior"), "review:human"); + assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "senior"), "review:human"); + }); + + it("should return review:agent for AGENT policy", () => { + assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "junior"), "review:agent"); + assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "senior"), "review:agent"); + }); + + it("should return review:human for AUTO + senior", () => { + assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "senior"), "review:human"); + }); + + it("should return review:agent for AUTO + non-senior", () => { + assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "junior"), "review:agent"); + assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "medior"), "review:agent"); + }); +}); diff --git a/lib/tools/task-update.ts b/lib/tools/task-update.ts index c3387d8..a3f1cbb 100644 --- a/lib/tools/task-update.ts +++ b/lib/tools/task-update.ts @@ -11,27 +11,30 @@ import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { log as auditLog } from "../audit.js"; import type { StateLabel } from "../providers/provider.js"; -import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js"; +import { DEFAULT_WORKFLOW, getStateLabels, findStateByLabel } from "../workflow.js"; +import { loadConfig } from "../config/index.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; export function createTaskUpdateTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "task_update", label: "Task Update", - description: `Change issue state programmatically. Use this when you need to update an issue's status without going through the full pickup/complete flow. + description: `Change issue state and/or role:level assignment. Use this when you need to update an issue's status or override the assigned level. Use cases: - Orchestrator or worker needs to change state manually - Manual status adjustments (e.g., Planning → To Do after approval) +- Override the assigned level (e.g., escalate to senior for human review) +- Force human review via level change - Failed auto-transitions that need correction -- Bulk state changes Examples: -- Simple: { projectGroupId: "-123456789", issueId: 42, state: "To Do" } -- With reason: { projectGroupId: "-123456789", issueId: 42, state: "To Do", reason: "Approved for development" }`, +- State only: { projectGroupId: "-123456789", issueId: 42, state: "To Do" } +- Level only: { projectGroupId: "-123456789", issueId: 42, level: "senior" } +- Both: { projectGroupId: "-123456789", issueId: 42, state: "To Do", level: "senior", reason: "Escalating to senior" }`, parameters: { type: "object", - required: ["projectGroupId", "issueId", "state"], + required: ["projectGroupId", "issueId"], properties: { projectGroupId: { type: "string", @@ -46,9 +49,13 @@ Examples: enum: getStateLabels(DEFAULT_WORKFLOW), description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`, }, + level: { + type: "string", + description: "Override the role:level assignment (e.g., 'senior', 'junior'). Detects role from current state label.", + }, reason: { type: "string", - description: "Optional audit log reason for the state change", + description: "Optional audit log reason for the change", }, }, }, @@ -56,41 +63,86 @@ Examples: async execute(_id: string, params: Record) { const groupId = params.projectGroupId as string; const issueId = params.issueId as number; - const newState = params.state as StateLabel; + const newState = (params.state as StateLabel) ?? undefined; + const newLevel = (params.level as string) ?? undefined; const reason = (params.reason as string) ?? undefined; const workspaceDir = requireWorkspaceDir(ctx); + if (!newState && !newLevel) { + throw new Error("At least one of 'state' or 'level' must be provided."); + } + const { project } = await resolveProject(workspaceDir, groupId); const { provider, type: providerType } = await resolveProvider(project); const issue = await provider.getIssue(issueId); const currentState = provider.getCurrentStateLabel(issue); if (!currentState) { - throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`); + throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform update.`); } - if (currentState === newState) { - return jsonResult({ - success: true, issueId, state: newState, changed: false, - message: `Issue #${issueId} is already in state "${newState}".`, - project: project.name, provider: providerType, - }); + let stateChanged = false; + let levelChanged = false; + let fromLevel: string | undefined; + + // Handle state transition + if (newState && currentState !== newState) { + await provider.transitionLabel(issueId, currentState, newState); + stateChanged = true; } - await provider.transitionLabel(issueId, currentState, newState); + // Handle level override + if (newLevel) { + // Detect role from current (or new) state label + const effectiveState = newState ?? currentState; + const workflow = (await loadConfig(workspaceDir, project.name)).workflow; + const stateConfig = findStateByLabel(workflow, effectiveState); + const role = stateConfig?.role; + if (!role) { + throw new Error(`Cannot determine role from state "${effectiveState}". Level can only be set on role-assigned states.`); + } + // Validate level exists for role + const resolvedConfig = await loadConfig(workspaceDir, project.name); + const roleConfig = resolvedConfig.roles[role]; + if (!roleConfig || !roleConfig.levels.includes(newLevel)) { + throw new Error(`Invalid level "${newLevel}" for role "${role}". Valid levels: ${roleConfig?.levels.join(", ") ?? "none"}`); + } + + // Remove old role:* labels, add new role:level + const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`)); + fromLevel = oldRoleLabels[0]?.split(":")[1]; + if (oldRoleLabels.length > 0) { + await provider.removeLabels(issueId, oldRoleLabels); + } + await provider.addLabel(issueId, `${role}:${newLevel}`); + levelChanged = fromLevel !== newLevel; + } + + // Audit await auditLog(workspaceDir, "task_update", { project: project.name, groupId, issueId, - fromState: currentState, toState: newState, + ...(stateChanged ? { fromState: currentState, toState: newState } : {}), + ...(levelChanged ? { fromLevel: fromLevel ?? null, toLevel: newLevel } : {}), reason: reason ?? null, provider: providerType, }); + // Build announcement + const parts: string[] = []; + if (stateChanged) parts.push(`"${currentState}" → "${newState}"`); + if (levelChanged) parts.push(`level: ${fromLevel ?? "none"} → ${newLevel}`); + const changeDesc = parts.join(", "); + return jsonResult({ success: true, issueId, issueTitle: issue.title, - state: newState, changed: true, - labelTransition: `${currentState} → ${newState}`, + ...(newState ? { state: newState } : {}), + ...(newLevel ? { level: newLevel } : {}), + changed: stateChanged || levelChanged, + ...(stateChanged ? { labelTransition: `${currentState} → ${newState}` } : {}), project: project.name, provider: providerType, - announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`, + announcement: stateChanged || levelChanged + ? `🔄 Updated #${issueId}: ${changeDesc}${reason ? ` (${reason})` : ""}` + : `Issue #${issueId} is already in the requested state.`, }); }, }); diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index 8ca0daa..9686020 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -18,13 +18,13 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "work_finish", label: "Work Finish", - description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, + description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, parameters: { type: "object", required: ["role", "result", "projectGroupId"], properties: { role: { type: "string", enum: getAllRoleIds(), description: "Worker role" }, - result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" }, + result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked", "approve", "reject"], description: "Completion result" }, projectGroupId: { type: "string", description: "Project group ID" }, summary: { type: "string", description: "Brief summary" }, prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" }, diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 2fa708b..9ae5ca2 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -12,8 +12,8 @@ import type { StateLabel } from "../providers/provider.js"; import { selectLevel } from "../model-selector.js"; import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/queue-scan.js"; -import { getAllRoleIds, isLevelForRole } from "../roles/index.js"; +import { findNextIssue, detectRoleFromLabel, detectRoleLevelFromLabels } from "../services/queue-scan.js"; +import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js"; @@ -81,20 +81,16 @@ export function createWorkStartTool(api: OpenClawPluginApi) { // Get target label from workflow const targetLabel = getActiveLabel(workflow, role); - // Select level + // Select level: LLM param → own role label → inherit other role label → heuristic let selectedLevel: string, levelReason: string, levelSource: string; if (levelParam) { selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm"; } else { - const labelLevel = detectLevelFromLabels(issue.labels); - if (labelLevel) { - if (!isLevelForRole(labelLevel, role)) { - // Label level belongs to a different role — use heuristic for this role - const s = selectLevel(issue.title, issue.description ?? "", role); - selectedLevel = s.level; levelReason = `${role} overrides other role's level "${labelLevel}"`; levelSource = "role-override"; - } else { - selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; - } + const roleLevel = detectRoleLevelFromLabels(issue.labels); + if (roleLevel?.role === role) { + selectedLevel = roleLevel.level; levelReason = `Label: "${role}:${roleLevel.level}"`; levelSource = "label"; + } else if (roleLevel && getLevelsForRole(role).includes(roleLevel.level)) { + selectedLevel = roleLevel.level; levelReason = `Inherited from ${roleLevel.role}:${roleLevel.level}`; levelSource = "inherited"; } else { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; diff --git a/lib/workflow.ts b/lib/workflow.ts index e0d46a3..cda6e47 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -19,7 +19,6 @@ export const StateType = { ACTIVE: "active", HOLD: "hold", TERMINAL: "terminal", - REVIEW: "review", } as const; export type StateType = (typeof StateType)[keyof typeof StateType]; @@ -30,6 +29,14 @@ export const ExecutionMode = { } as const; export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode]; +/** Review policy for PR review after developer completion. */ +export const ReviewPolicy = { + HUMAN: "human", + AGENT: "agent", + AUTO: "auto", +} as const; +export type ReviewPolicy = (typeof ReviewPolicy)[keyof typeof ReviewPolicy]; + /** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ export type Role = string; /** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */ @@ -63,6 +70,7 @@ export const WorkflowEvent = { REFINE: "REFINE", BLOCKED: "BLOCKED", APPROVE: "APPROVE", + REJECT: "REJECT", } as const; export type TransitionTarget = string | { @@ -84,6 +92,7 @@ export type StateConfig = { export type WorkflowConfig = { initial: string; + reviewPolicy?: ReviewPolicy; states: Record; }; @@ -99,6 +108,7 @@ export type CompletionRule = { export const DEFAULT_WORKFLOW: WorkflowConfig = { initial: "planning", + reviewPolicy: ReviewPolicy.AUTO, states: { // ── Main pipeline (happy path) ────────────────────────────── planning: { @@ -121,19 +131,31 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { label: "Doing", color: "#f0ad4e", on: { - [WorkflowEvent.COMPLETE]: { target: "toTest", actions: [Action.GIT_PULL, Action.DETECT_PR] }, - [WorkflowEvent.REVIEW]: { target: "reviewing", actions: [Action.DETECT_PR] }, + [WorkflowEvent.COMPLETE]: { target: "toReview", actions: [Action.DETECT_PR] }, [WorkflowEvent.BLOCKED]: "refining", }, }, - reviewing: { - type: StateType.REVIEW, - label: "In Review", - color: "#c5def5", + toReview: { + type: StateType.QUEUE, + role: "reviewer", + label: "To Review", + color: "#7057ff", + priority: 2, check: ReviewCheck.PR_APPROVED, on: { + [WorkflowEvent.PICKUP]: "reviewing", [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] }, [WorkflowEvent.MERGE_FAILED]: "toImprove", + }, + }, + reviewing: { + type: StateType.ACTIVE, + role: "reviewer", + label: "Reviewing", + color: "#c5def5", + on: { + [WorkflowEvent.APPROVE]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] }, + [WorkflowEvent.REJECT]: "toImprove", [WorkflowEvent.BLOCKED]: "refining", }, }, @@ -240,6 +262,83 @@ export function getLabelColors(workflow: WorkflowConfig): Record return colors; } +// --------------------------------------------------------------------------- +// Role:level labels — dynamic from config +// --------------------------------------------------------------------------- + +/** Step routing label values — per-issue overrides for workflow steps. */ +export const StepRouting = { + HUMAN: "human", + AGENT: "agent", + SKIP: "skip", +} as const; +export type StepRoutingValue = (typeof StepRouting)[keyof typeof StepRouting]; + +/** Known step routing labels (created on the provider during project registration). */ +export const STEP_ROUTING_LABELS: readonly string[] = [ + "review:human", "review:agent", "review:skip", + "test:skip", +]; + +/** Step routing label color. */ +const STEP_ROUTING_COLOR = "#d93f0b"; + +/** + * Determine review routing label for an issue based on project policy and developer level. + * Called during developer dispatch to persist the routing decision as a label. + */ +export function resolveReviewRouting( + policy: ReviewPolicy, level: string, +): "review:human" | "review:agent" { + if (policy === ReviewPolicy.HUMAN) return "review:human"; + if (policy === ReviewPolicy.AGENT) return "review:agent"; + // AUTO: senior → human, else agent + return level === "senior" ? "review:human" : "review:agent"; +} + +/** Default colors per role for role:level labels. */ +const ROLE_LABEL_COLORS: Record = { + developer: "#0e8a16", + tester: "#5319e7", + architect: "#0075ca", + reviewer: "#d93f0b", +}; + +/** + * Generate all role:level label definitions from resolved config roles. + * Returns array of { name, color } for label creation (e.g. "developer:junior"). + */ +export function getRoleLabels( + roles: Record, +): Array<{ name: string; color: string }> { + const labels: Array<{ name: string; color: string }> = []; + for (const [roleId, role] of Object.entries(roles)) { + if (role.enabled === false) continue; + for (const level of role.levels) { + labels.push({ + name: `${roleId}:${level}`, + color: getRoleLabelColor(roleId), + }); + } + } + // Step routing labels (review:human, review:agent, test:skip, etc.) + for (const routingLabel of STEP_ROUTING_LABELS) { + labels.push({ name: routingLabel, color: STEP_ROUTING_COLOR }); + } + return labels; +} + +/** + * Get the label color for a role. Falls back to gray for unknown roles. + */ +export function getRoleLabelColor(role: string): string { + return ROLE_LABEL_COLORS[role] ?? "#cccccc"; +} + +// --------------------------------------------------------------------------- +// Queue helpers +// --------------------------------------------------------------------------- + /** * Get queue labels for a role, ordered by priority (highest first). */ @@ -348,7 +447,6 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st */ function resultToEvent(result: string): string { if (result === "done") return WorkflowEvent.COMPLETE; - if (result === "review") return WorkflowEvent.REVIEW; return result.toUpperCase(); } @@ -405,7 +503,6 @@ export function getNextStateDescription( if (!targetState) return ""; if (targetState.type === StateType.TERMINAL) return "Done!"; - if (targetState.type === StateType.REVIEW) return "awaiting PR review"; if (targetState.type === StateType.HOLD) return "awaiting human decision"; if (targetState.type === StateType.QUEUE && targetState.role) { return `${targetState.role.toUpperCase()} queue`; @@ -420,11 +517,12 @@ export function getNextStateDescription( */ const RESULT_EMOJI: Record = { done: "✅", - review: "👀", pass: "🎉", fail: "❌", refine: "🤔", blocked: "🚫", + approve: "✅", + reject: "❌", }; export function getCompletionEmoji(_role: Role, result: string): string { From b57ede0863d3cafa757c27280d9ccf6f5038f59d Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 18:47:01 +0800 Subject: [PATCH 13/13] feat: rename design_task to research_task and update related documentation --- docs/ARCHITECTURE.md | 2 +- docs/CONFIGURATION.md | 20 +-- docs/MANAGEMENT.md | 2 +- docs/ROADMAP.md | 4 +- docs/TESTING.md | 2 +- docs/TOOLS.md | 21 +-- index.ts | 4 +- lib/services/bootstrap.e2e.test.ts | 6 +- lib/services/health.ts | 9 ++ lib/templates.ts | 54 ++++---- ...ign-task.test.ts => research-task.test.ts} | 48 ++++--- .../{design-task.ts => research-task.ts} | 100 +++++++-------- lib/tools/task-update.test.ts | 2 +- lib/tools/work-finish.ts | 121 ++++++++++++++++-- lib/workflow.ts | 27 ++-- 15 files changed, 249 insertions(+), 173 deletions(-) rename lib/tools/{design-task.test.ts => research-task.test.ts} (67%) rename lib/tools/{design-task.ts => research-task.ts} (62%) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7cb3558..78dae0e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -583,7 +583,7 @@ Every piece of data and where it lives: │ status → read labels + read state │ │ health → check sessions + fix zombies │ │ project_register → labels + prompts + state init (one-time) │ -│ design_task → architect dispatch │ +│ research_task → architect dispatch │ │ │ │ Bootstrap hook → injects role instructions into worker sessions│ │ Review pass → polls PR status, auto-merges approved PRs │ diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fd16ad4..6faae71 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -164,24 +164,10 @@ workflow: type: terminal label: Done color: "#5cb85c" - toDesign: - type: queue - role: architect - label: To Design - color: "#0075ca" - priority: 1 - on: - PICKUP: designing - designing: - type: active - role: architect - label: Designing - color: "#d4c5f9" - on: - COMPLETE: planning - BLOCKED: refining ``` +Note: The architect role has no dedicated workflow states. Design tasks are triggered via `research_task` tool only — issues go directly to Planning. + **State types:** | Type | Description | @@ -340,7 +326,7 @@ Restrict DevClaw tools to your orchestrator agent: "project_register", "setup", "onboard", - "design_task" + "research_task" ] } } diff --git a/docs/MANAGEMENT.md b/docs/MANAGEMENT.md index c4d541e..bec85ad 100644 --- a/docs/MANAGEMENT.md +++ b/docs/MANAGEMENT.md @@ -53,7 +53,7 @@ Planning → To Do → Doing → To Test → Testing → Done ↘ Refining → (human decision) ``` -The ARCHITECT role adds a parallel track: `To Design → Designing → Planning`. +The ARCHITECT role is tool-triggered only via `research_task` — no queue states. Issues go directly to Planning, the architect researches and posts findings, then the issue stays in Planning for human review. Every label transition, state update, and audit log entry happens atomically inside the plugin. The orchestrator agent cannot skip a step, forget a label, or corrupt session state — because those operations are deterministic code, not instructions an LLM follows imperfectly. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c252e45..4a6d21c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,7 +17,7 @@ Planning → To Do → Doing → To Test → Testing → Done ↘ In Review → (PR approved → auto-merge) → To Test ↘ To Improve → Doing (merge conflict / fix cycle) ↘ Refining → (human decision) -To Design → Designing → Planning +research_task → Planning (architect researches, stays in Planning) ``` States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). @@ -46,7 +46,7 @@ DEVELOPER can submit a PR for human review (`result: "review"`), which transitio ### Architect Role -The architect role enables design investigations. `design_task` creates a `To Design` issue and dispatches an architect worker. The architect completes with `done` (→ Planning) or `blocked` (→ Refining). +The architect role enables design investigations. `research_task` creates a Planning issue with rich context and dispatches an architect worker directly (no queue states). The architect posts findings as comments, then completes with `done` (stays in Planning for human review) or `blocked` (→ Refining). ### Workspace Layout Migration diff --git a/docs/TESTING.md b/docs/TESTING.md index b19c16d..90b6344 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -28,7 +28,7 @@ npm run build | [lib/projects.test.ts](../lib/projects.test.ts) | Project state: read/write, worker state, atomic file operations | | [lib/bootstrap-hook.test.ts](../lib/bootstrap-hook.test.ts) | Bootstrap hook: role instruction loading, source tracking, overloads | | [lib/tools/task-update.test.ts](../lib/tools/task-update.test.ts) | Task update tool: label transitions, validation | -| [lib/tools/design-task.test.ts](../lib/tools/design-task.test.ts) | Design task tool: architect dispatch | +| [lib/tools/research-task.test.ts](../lib/tools/research-task.test.ts) | Research task tool: architect dispatch | | [lib/tools/queue-status.test.ts](../lib/tools/queue-status.test.ts) | Queue status formatting | | [lib/setup/migrate-layout.test.ts](../lib/setup/migrate-layout.test.ts) | Workspace layout migration: `projects/` → `devclaw/` | diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 7e3a162..813a422 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -79,8 +79,8 @@ Complete a task with a result. Called by workers (DEVELOPER/TESTER/ARCHITECT sub | tester | `"fail"` | Testing → To Improve | Issue reopened | | tester | `"refine"` | Testing → Refining | Awaits human decision | | tester | `"blocked"` | Testing → Refining | Awaits human decision | -| architect | `"done"` | Designing → Planning | Design complete | -| architect | `"blocked"` | Designing → Refining | Awaits human decision | +| architect | `"done"` | stays in Planning | Design complete, ready for human review | +| architect | `"blocked"` | Planning → Refining | Awaits human decision | **What it does atomically:** @@ -140,7 +140,7 @@ Change an issue's state label manually without going through the full pickup/com | `state` | StateLabel | Yes | New state label | | `reason` | string | No | Audit log reason for the change | -**Valid states:** `Planning`, `To Do`, `Doing`, `To Test`, `Testing`, `Done`, `To Improve`, `Refining`, `In Review`, `To Design`, `Designing` +**Valid states:** `Planning`, `To Do`, `Doing`, `To Test`, `Testing`, `Done`, `To Improve`, `Refining`, `In Review` **Use cases:** @@ -331,11 +331,11 @@ Conversational onboarding guide. Returns step-by-step instructions for the agent --- -### `design_task` +### `research_task` -Spawn an architect for a design investigation. Creates a "To Design" issue and dispatches an architect worker. +Spawn an architect for a design investigation. Creates a Planning issue with rich context and dispatches an architect worker. No queue states — tool-triggered only. -**Source:** [`lib/tools/design-task.ts`](../lib/tools/design-task.ts) +**Source:** [`lib/tools/research-task.ts`](../lib/tools/research-task.ts) **Parameters:** @@ -343,8 +343,9 @@ Spawn an architect for a design investigation. Creates a "To Design" issue and d |---|---|---|---| | `projectGroupId` | string | Yes | Project group ID | | `title` | string | Yes | Design task title | -| `description` | string | No | Design problem description | -| `level` | `"junior"` \| `"senior"` | No | Architect level. Default: `"junior"`. | +| `description` | string | Yes | Detailed background context for the architect | +| `focusAreas` | string[] | No | Specific areas to investigate | +| `complexity` | `"simple"` \| `"medium"` \| `"complex"` | No | Guides level selection. Default: `"medium"`. | --- @@ -360,8 +361,8 @@ tester:pass → Testing → Done (close issue) tester:fail → Testing → To Improve (reopen issue) tester:refine → Testing → Refining (awaits human decision) tester:blocked → Testing → Refining (awaits human decision) -architect:done → Designing → Planning (design complete) -architect:blocked → Designing → Refining (awaits human decision) +architect:done → stays in Planning (design complete, ready for human review) +architect:blocked → Planning → Refining (awaits human decision) ``` ## Issue Priority Order diff --git a/index.ts b/index.ts index d646a1f..6ba7d6b 100644 --- a/index.ts +++ b/index.ts @@ -10,7 +10,7 @@ import { createProjectRegisterTool } from "./lib/tools/project-register.js"; import { createSetupTool } from "./lib/tools/setup.js"; import { createOnboardTool } from "./lib/tools/onboard.js"; import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js"; -import { createDesignTaskTool } from "./lib/tools/design-task.js"; +import { createResearchTaskTool } from "./lib/tools/research-task.js"; import { registerCli } from "./lib/cli.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js"; import { registerBootstrapHook } from "./lib/bootstrap-hook.js"; @@ -78,7 +78,7 @@ const plugin = { api.registerTool(createTaskCommentTool(api), { names: ["task_comment"] }); // Architect - api.registerTool(createDesignTaskTool(api), { names: ["design_task"] }); + api.registerTool(createResearchTaskTool(api), { names: ["research_task"] }); // Operations api.registerTool(createStatusTool(api), { names: ["status"] }); diff --git a/lib/services/bootstrap.e2e.test.ts b/lib/services/bootstrap.e2e.test.ts index 433ac2e..9872c8b 100644 --- a/lib/services/bootstrap.e2e.test.ts +++ b/lib/services/bootstrap.e2e.test.ts @@ -213,7 +213,7 @@ describe("E2E bootstrap — hook injection", () => { it("should resolve architect instructions with project override", async () => { h = await createTestHarness({ projectName: "arch-proj" }); - h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["To Design"] }); + h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["Planning"] }); await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines."); await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj"); @@ -229,8 +229,8 @@ describe("E2E bootstrap — hook injection", () => { issueUrl: "https://example.com/issues/6", role: "architect", level: "senior", - fromLabel: "To Design", - toLabel: "Designing", + fromLabel: "Planning", + toLabel: "Planning", transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), provider: h.provider, }); diff --git a/lib/services/health.ts b/lib/services/health.ts index 68167ba..5e2ed8e 100644 --- a/lib/services/health.ts +++ b/lib/services/health.ts @@ -28,6 +28,7 @@ import { DEFAULT_WORKFLOW, getActiveLabel, getRevertLabel, + hasWorkflowStates, type WorkflowConfig, type Role, } from "../workflow.js"; @@ -161,6 +162,10 @@ export async function checkWorkerHealth(opts: { } = opts; const fixes: HealthFix[] = []; + + // Skip roles without workflow states (e.g. architect — tool-triggered only) + if (!hasWorkflowStates(workflow, role)) return fixes; + const worker = getWorker(project, role); const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null; @@ -430,6 +435,10 @@ export async function scanOrphanedLabels(opts: { } = opts; const fixes: HealthFix[] = []; + + // Skip roles without workflow states (e.g. architect — tool-triggered only) + if (!hasWorkflowStates(workflow, role)) return fixes; + const worker = getWorker(project, role); // Get labels from workflow config diff --git a/lib/templates.ts b/lib/templates.ts index 2ac34c0..a85ac40 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -48,43 +48,42 @@ export const DEFAULT_QA_INSTRUCTIONS = `# TESTER Worker Instructions export const DEFAULT_ARCHITECT_INSTRUCTIONS = `# Architect Worker Instructions -You design and investigate architecture/design questions systematically. +You research design/architecture questions and produce detailed, development-ready findings. ## Your Job -Investigate the design problem thoroughly: -1. **Understand the problem** — Read the issue, comments, and codebase -2. **Research alternatives** — Explore >= 3 viable approaches -3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit -4. **Recommend** — Pick the best option with clear reasoning -5. **Outline implementation** — Break down into developer tasks +The issue contains background context and constraints. Your goal is to produce findings detailed enough that a developer can start implementation immediately — no further research needed. + +1. **Understand the problem** — Read the issue body carefully. It contains the background context, constraints, and focus areas. +2. **Research thoroughly** — Explore the codebase, read docs, search the web. Understand the current state deeply. +3. **Investigate alternatives** — Research >= 3 viable approaches with concrete pros/cons and effort estimates. +4. **Recommend** — Pick the best option with clear, evidence-based reasoning. +5. **Outline implementation** — Break down into specific, actionable developer tasks with enough detail to start coding. ## Output Format -Structure your findings as: +Post your findings as issue comments. Structure them as: ### Problem Statement -Why is this design decision important? +Why is this design decision important? What breaks if we get it wrong? ### Current State -What exists today? Current limitations? +What exists today? Current limitations? Relevant code paths. ### Alternatives Investigated **Option A: [Name]** +- Approach: [Concrete description of what this looks like] - Pros: ... - Cons: ... - Effort estimate: X hours +- Key code paths affected: [files/modules] **Option B: [Name]** -- Pros: ... -- Cons: ... -- Effort estimate: X hours +(same structure) **Option C: [Name]** -- Pros: ... -- Cons: ... -- Effort estimate: X hours +(same structure) ### Recommendation **Option X** is recommended because: @@ -93,24 +92,26 @@ What exists today? Current limitations? - [Long-term implications] ### Implementation Outline -- [ ] Task 1: [Description] +Detailed enough for a developer to start immediately: +- [ ] Task 1: [Description — what to change, where, how] - [ ] Task 2: [Description] - [ ] Task 3: [Description] ### References -- [Code examples, prior art, related issues] +- [Code paths, docs, prior art, related issues] -## Available Tools +## Important -- web_search, web_fetch (research patterns) -- Read files (explore codebase) -- exec (run commands, search code) +- **Be thorough** — Your output becomes the spec for development. Missing detail = blocked developer. +- **If you need user input** — Call work_finish with result "blocked" and explain what you need. Do NOT guess on ambiguous requirements. +- **Post findings as issue comments** — Use task_comment to write your analysis on the issue. ## Completion When done, call work_finish with: - role: "architect" -- result: "done" +- result: "done" — findings posted, ready for human review +- result: "blocked" — you need human input to proceed (goes to Refining) - summary: Brief summary of your recommendation Your session is persistent — you may be called back for refinements. @@ -261,7 +262,7 @@ All orchestration goes through these tools. You do NOT manually manage sessions, | \`health\` | Scan worker health: zombies, stale workers, orphaned state. Pass fix=true to auto-fix | | \`work_start\` | End-to-end: label transition, level assignment, session create/reuse, dispatch with role instructions | | \`work_finish\` | End-to-end: label transition, state update, issue close/reopen | -| \`design_task\` | Spawn an architect for design investigation. Creates To Design issue and dispatches architect | +| \`research_task\` | Spawn an architect for design investigation. Creates Planning issue with rich context and dispatches architect | ### First Thing on Session Start @@ -277,7 +278,7 @@ Planning → To Do → Doing → To Review ──┬── [agent] → Reviewing To Improve → Doing (fix cycle) Refining (human decision) -To Design → Designing → Planning (design complete) +research_task → Planning (architect researches, posts findings, stays in Planning) \`\`\` Review policy (configurable per project in workflow.yaml): @@ -317,7 +318,8 @@ Workers call \`work_finish\` themselves — the label transition, state update, - Tester "fail" → "To Improve" → scheduler dispatches Developer - Tester "pass" → Done, no further dispatch - Tester "refine" / blocked → needs human input -- Architect "done" → "Planning" → ready for tech lead review +- Architect "done" → stays in "Planning" → ready for tech lead review +- Architect "blocked" → "Refining" → needs human input **Always include issue URLs** in your response — these are in the \`announcement\` fields. diff --git a/lib/tools/design-task.test.ts b/lib/tools/research-task.test.ts similarity index 67% rename from lib/tools/design-task.test.ts rename to lib/tools/research-task.test.ts index be6491b..e05ebdd 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/research-task.test.ts @@ -1,6 +1,6 @@ /** - * Tests for architect role, design_task tool, and workflow integration. - * Run with: npx tsx --test lib/tools/design-task.test.ts + * Tests for architect role, research_task tool, and workflow integration. + * Run with: npx tsx --test lib/tools/research-task.test.ts */ import { describe, it } from "node:test"; import assert from "node:assert"; @@ -8,8 +8,8 @@ import { parseDevClawSessionKey } from "../bootstrap-hook.js"; import { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js"; import { selectLevel } from "../model-selector.js"; import { - DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule, - getCompletionEmoji, detectRoleFromLabel, getStateLabels, + DEFAULT_WORKFLOW, getQueueLabels, getCompletionRule, + getCompletionEmoji, getStateLabels, hasWorkflowStates, } from "../workflow.js"; describe("architect tiers", () => { @@ -42,42 +42,38 @@ describe("architect tiers", () => { }); }); -describe("architect workflow states", () => { - it("should include To Design and Designing in state labels", () => { +describe("architect workflow — no dedicated states", () => { + it("should NOT have To Design or Designing in state labels", () => { const labels = getStateLabels(DEFAULT_WORKFLOW); - assert.ok(labels.includes("To Design")); - assert.ok(labels.includes("Designing")); + assert.ok(!labels.includes("To Design"), "To Design should not exist"); + assert.ok(!labels.includes("Designing"), "Designing should not exist"); }); - it("should have To Design as architect queue label", () => { + it("should have no queue labels for architect", () => { const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect"); - assert.deepStrictEqual(queues, ["To Design"]); + assert.deepStrictEqual(queues, []); }); - it("should have Designing as architect active label", () => { - assert.strictEqual(getActiveLabel(DEFAULT_WORKFLOW, "architect"), "Designing"); + it("should report architect has no workflow states", () => { + assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "architect"), false); }); - it("should detect architect role from To Design label", () => { - assert.strictEqual(detectRoleFromLabel(DEFAULT_WORKFLOW, "To Design"), "architect"); + it("should report developer has workflow states", () => { + assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "developer"), true); }); - it("should have architect:done completion rule", () => { - const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done"); - assert.ok(rule); - assert.strictEqual(rule!.from, "Designing"); - assert.strictEqual(rule!.to, "Planning"); + it("should report tester has workflow states", () => { + assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "tester"), true); }); - it("should have architect:blocked completion rule", () => { - const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked"); - assert.ok(rule); - assert.strictEqual(rule!.from, "Designing"); - assert.strictEqual(rule!.to, "Refining"); + it("should have no completion rules for architect (no active state)", () => { + const doneRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done"); + assert.strictEqual(doneRule, null); + const blockedRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked"); + assert.strictEqual(blockedRule, null); }); - it("should have completion emoji by result type", () => { - // Emoji is now keyed by result, not role:result + it("should still have completion emoji for architect results", () => { assert.strictEqual(getCompletionEmoji("architect", "done"), "✅"); assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫"); }); diff --git a/lib/tools/design-task.ts b/lib/tools/research-task.ts similarity index 62% rename from lib/tools/design-task.ts rename to lib/tools/research-task.ts index 37ec7ca..4f72d8a 100644 --- a/lib/tools/design-task.ts +++ b/lib/tools/research-task.ts @@ -1,9 +1,11 @@ /** - * design_task — Spawn an architect to investigate a design problem. + * research_task — Spawn an architect to research a design/architecture problem. * - * Creates a "To Design" issue and optionally dispatches an architect worker. - * The architect investigates systematically, then produces structured findings - * as a GitHub issue in Planning state. + * Creates a Planning issue with rich context and dispatches an architect worker. + * The architect researches the problem and produces detailed findings as issue comments. + * The issue stays in Planning — ready for human review when the architect completes. + * + * No queue states — tool-triggered only. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; @@ -13,33 +15,41 @@ import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; -import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js"; import { loadConfig } from "../config/index.js"; import { selectLevel } from "../model-selector.js"; import { resolveModel } from "../roles/index.js"; -export function createDesignTaskTool(api: OpenClawPluginApi) { +/** Planning label — architect issues go directly here. */ +const PLANNING_LABEL = "Planning"; + +export function createResearchTaskTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ - name: "design_task", - label: "Design Task", - description: `Spawn an architect to investigate a design/architecture problem. Creates a "To Design" issue and dispatches an architect worker with persistent session. + name: "research_task", + label: "Research Task", + description: `Spawn an architect to research a design/architecture problem. Creates a Planning issue and dispatches an architect worker. + +IMPORTANT: Provide a detailed description with enough background context for the architect +to produce actionable, development-ready findings. Include: current state, constraints, +requirements, relevant code paths, and any prior decisions. The output should be detailed +enough for a developer to start implementation immediately. The architect will: -1. Investigate the problem systematically -2. Research alternatives (>= 3 options) -3. Produce structured findings with recommendation -4. Complete with work_finish, moving the issue to Planning +1. Research the problem systematically (codebase, docs, web) +2. Investigate >= 3 alternatives with tradeoffs +3. Produce a recommendation with implementation outline +4. Post findings as issue comments, then complete with work_finish Example: - design_task({ + research_task({ projectGroupId: "-5176490302", - title: "Design: Session persistence strategy", - description: "How should sessions be persisted across restarts?", + title: "Research: Session persistence strategy", + description: "Sessions are lost on restart. Current impl uses in-memory Map in session-store.ts. Constraints: must work with SQLite (already a dep), max 50ms latency on read. Prior discussion in #42 ruled out Redis.", + focusAreas: ["SQLite vs file-based", "migration path", "cache invalidation"], complexity: "complex" })`, parameters: { type: "object", - required: ["projectGroupId", "title"], + required: ["projectGroupId", "title", "description"], properties: { projectGroupId: { type: "string", @@ -47,11 +57,11 @@ Example: }, title: { type: "string", - description: "Design title (e.g., 'Design: Session persistence')", + description: "Research title (e.g., 'Research: Session persistence strategy')", }, description: { type: "string", - description: "What are we designing & why? Include context and constraints.", + description: "Detailed background context: what exists today, why this needs investigation, constraints, relevant code paths, prior decisions. Must be detailed enough for the architect to produce development-ready findings.", }, focusAreas: { type: "array", @@ -81,41 +91,28 @@ Example: if (!groupId) throw new Error("projectGroupId is required"); if (!title) throw new Error("title is required"); + if (!description) throw new Error("description is required — provide detailed background context for the architect"); const { project } = await resolveProject(workspaceDir, groupId); const { provider } = await resolveProvider(project); const pluginConfig = getPluginConfig(api); - - // Derive labels from workflow config - const workflow = await loadWorkflow(workspaceDir, project.name); const role = "architect"; - const queueLabels = getQueueLabels(workflow, role); - const queueLabel = queueLabels[0]; - if (!queueLabel) throw new Error(`No queue state found for role "${role}" in workflow`); - // Build issue body with focus areas - const bodyParts = [description]; + // Build issue body with rich context + const bodyParts = [ + "## Background", + "", + description, + ]; if (focusAreas.length > 0) { bodyParts.push("", "## Focus Areas", ...focusAreas.map(a => `- ${a}`)); } - bodyParts.push( - "", "---", - "", "## Architect Output Template", - "", - "When complete, the architect will produce findings covering:", - "1. **Problem Statement** — Why is this design decision important?", - "2. **Current State** — What exists today? Limitations?", - "3. **Alternatives** (>= 3 options with pros/cons and effort estimates)", - "4. **Recommendation** — Which option and why?", - "5. **Implementation Outline** — What dev tasks are needed?", - "6. **References** — Code, docs, prior art", - ); const issueBody = bodyParts.join("\n"); - // Create issue in queue state - const issue = await provider.createIssue(title, issueBody, queueLabel as StateLabel); + // Create issue directly in Planning state (no queue — tool-triggered only) + const issue = await provider.createIssue(title, issueBody, PLANNING_LABEL as StateLabel); - await auditLog(workspaceDir, "design_task", { + await auditLog(workspaceDir, "research_task", { project: project.name, groupId, issueId: issue.iid, title, complexity, focusAreas, dryRun, }); @@ -132,7 +129,7 @@ Example: return jsonResult({ success: true, dryRun: true, - issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel }, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL }, design: { level, model, status: "dry_run" }, announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`, }); @@ -141,22 +138,19 @@ Example: // Check worker availability const worker = getWorker(project, role); if (worker.active) { - // Issue created but can't dispatch yet — will be picked up by heartbeat return jsonResult({ success: true, - issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel }, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL }, design: { level, status: "queued", - reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue queued for pickup.`, + reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue created in Planning — dispatch manually when architect is free.`, }, - announcement: `📐 Created design task #${issue.iid}: ${title} (queued — ${role} busy)\n🔗 ${issue.web_url}`, + announcement: `📐 Created research task #${issue.iid}: ${title} (architect busy — issue in Planning)\n🔗 ${issue.web_url}`, }); } - // Dispatch worker - const targetLabel = getActiveLabel(workflow, role); - + // Dispatch architect directly — issue stays in Planning (no state transition) const dr = await dispatchTask({ workspaceDir, agentId: ctx.agentId, @@ -168,8 +162,8 @@ Example: issueUrl: issue.web_url, role, level, - fromLabel: queueLabel, - toLabel: targetLabel, + fromLabel: PLANNING_LABEL, + toLabel: PLANNING_LABEL, transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), provider, pluginConfig, @@ -180,7 +174,7 @@ Example: return jsonResult({ success: true, - issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: targetLabel }, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL }, design: { sessionKey: dr.sessionKey, level: dr.level, diff --git a/lib/tools/task-update.test.ts b/lib/tools/task-update.test.ts index 4572158..979a13f 100644 --- a/lib/tools/task-update.test.ts +++ b/lib/tools/task-update.test.ts @@ -18,7 +18,7 @@ describe("task_update tool", () => { it("supports all state labels", () => { const labels = getStateLabels(DEFAULT_WORKFLOW); - assert.strictEqual(labels.length, 12); + assert.strictEqual(labels.length, 10); assert.ok(labels.includes("Planning")); assert.ok(labels.includes("Done")); assert.ok(labels.includes("To Review")); diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index 9686020..870c4ec 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -3,22 +3,27 @@ * * Delegates side-effects to pipeline service: label transition, state update, * issue close/reopen, notifications, and audit logging. + * + * Roles without workflow states (e.g. architect) are handled inline — + * deactivate worker, optionally transition label, and notify. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; -import { getWorker, resolveRepoPath } from "../projects.js"; +import type { StateLabel } from "../providers/provider.js"; +import { deactivateWorker, getWorker, resolveRepoPath } from "../projects.js"; import { executeCompletion, getRule } from "../services/pipeline.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js"; -import { loadWorkflow } from "../workflow.js"; +import { loadWorkflow, hasWorkflowStates, getCompletionEmoji } from "../workflow.js"; +import { notify, getNotificationConfig } from "../notify.js"; export function createWorkFinishTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "work_finish", label: "Work Finish", - description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, + description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, parameters: { type: "object", required: ["role", "result", "projectGroupId"], @@ -44,8 +49,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { const valid = getCompletionResults(role); throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`); } - if (!getRule(role, result)) - throw new Error(`Invalid completion: ${role}:${result}`); // Resolve project + worker const { project } = await resolveProject(workspaceDir, groupId); @@ -56,13 +59,24 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`); const { provider } = await resolveProvider(project); - const repoPath = resolveRepoPath(project.repo); - const issue = await provider.getIssue(issueId); - - const pluginConfig = getPluginConfig(api); const workflow = await loadWorkflow(workspaceDir, project.name); - // Execute completion (pipeline service handles notification with runtime) + // Roles without workflow states (e.g. architect) — handle inline + if (!hasWorkflowStates(workflow, role)) { + return handleStatelessCompletion({ + workspaceDir, groupId, role, result, issueId, summary, + provider, projectName: project.name, channel: project.channel, + pluginConfig: getPluginConfig(api), runtime: api.runtime, + }); + } + + // Standard pipeline completion for roles with workflow states + if (!getRule(role, result)) + throw new Error(`Invalid completion: ${role}:${result}`); + + const repoPath = resolveRepoPath(project.repo); + const pluginConfig = getPluginConfig(api); + const completion = await executeCompletion({ workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath, projectName: project.name, @@ -77,7 +91,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { ...completion, }; - // Audit await auditLog(workspaceDir, "work_finish", { project: project.name, groupId, issue: issueId, role, result, summary: summary ?? null, labelTransition: completion.labelTransition, @@ -87,3 +100,89 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { }, }); } + +/** + * Handle completion for roles without workflow states (e.g. architect). + * + * - done: deactivate worker, issue stays in current state (Planning) + * - blocked: deactivate worker, transition issue to Refining + */ +async function handleStatelessCompletion(opts: { + workspaceDir: string; + groupId: string; + role: string; + result: string; + issueId: number; + summary?: string; + provider: import("../providers/provider.js").IssueProvider; + projectName: string; + channel?: string; + pluginConfig?: Record; + runtime?: import("openclaw/plugin-sdk").PluginRuntime; +}): Promise> { + const { + workspaceDir, groupId, role, result, issueId, summary, + provider, projectName, channel, pluginConfig, runtime, + } = opts; + + const issue = await provider.getIssue(issueId); + + // Deactivate worker + await deactivateWorker(workspaceDir, groupId, role); + + // If blocked, transition to Refining + let labelTransition = "none"; + if (result === "blocked") { + const currentLabel = provider.getCurrentStateLabel(issue) ?? "Planning"; + await provider.transitionLabel(issueId, currentLabel as StateLabel, "Refining" as StateLabel); + labelTransition = `${currentLabel} → Refining`; + } + + // Notification + const nextState = result === "blocked" ? "awaiting human decision" : "awaiting human decision"; + const notifyConfig = getNotificationConfig(pluginConfig); + notify( + { + type: "workerComplete", + project: projectName, + groupId, + issueId, + issueUrl: issue.web_url, + role, + result: result as "done" | "blocked", + summary, + nextState, + }, + { + workspaceDir, + config: notifyConfig, + groupId, + channel: channel ?? "telegram", + runtime, + }, + ).catch((err) => { + auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + }); + + // Build announcement + const emoji = getCompletionEmoji(role, result); + const label = `${role} ${result}`.toUpperCase(); + let announcement = `${emoji} ${label} #${issueId}`; + if (summary) announcement += ` — ${summary}`; + announcement += `\n📋 Issue: ${issue.web_url}`; + if (result === "blocked") announcement += `\nawaiting human decision.`; + + // Audit + await auditLog(workspaceDir, "work_finish", { + project: projectName, groupId, issue: issueId, role, result, + summary: summary ?? null, labelTransition, + }); + + return jsonResult({ + success: true, project: projectName, groupId, issueId, role, result, + labelTransition, + announcement, + nextState, + issueUrl: issue.web_url, + }); +} diff --git a/lib/workflow.ts b/lib/workflow.ts index cda6e47..119a0b7 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -201,25 +201,6 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { on: { [WorkflowEvent.APPROVE]: "todo" }, }, - // ── Architect track ───────────────────────────────────────── - toDesign: { - type: StateType.QUEUE, - role: "architect", - label: "To Design", - color: "#0075ca", - priority: 1, - on: { [WorkflowEvent.PICKUP]: "designing" }, - }, - designing: { - type: StateType.ACTIVE, - role: "architect", - label: "Designing", - color: "#d4c5f9", - on: { - [WorkflowEvent.COMPLETE]: "planning", - [WorkflowEvent.BLOCKED]: "refining", - }, - }, }, }; @@ -437,6 +418,14 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st return Object.entries(workflow.states).find(([, s]) => s.label === label)?.[0] ?? null; } +/** + * Check if a role has any workflow states (queue, active, etc.). + * Roles without workflow states (e.g. architect) are dispatched by tool only. + */ +export function hasWorkflowStates(workflow: WorkflowConfig, role: Role): boolean { + return Object.values(workflow.states).some((s) => s.role === role); +} + // --------------------------------------------------------------------------- // Completion rules — derived from transitions // ---------------------------------------------------------------------------