diff --git a/index.ts b/index.ts index 9bff56e..d00cc61 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +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 { registerCli } from "./lib/cli.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js"; import { registerBootstrapHook } from "./lib/bootstrap-hook.js"; @@ -44,6 +45,14 @@ const plugin = { tester: { type: "string" }, }, }, + architect: { + type: "object", + description: "Architect tier models", + properties: { + opus: { type: "string" }, + sonnet: { type: "string" }, + }, + }, }, }, projectExecution: { @@ -99,6 +108,9 @@ const plugin = { api.registerTool(createTaskUpdateTool(api), { names: ["task_update"] }); api.registerTool(createTaskCommentTool(api), { names: ["task_comment"] }); + // Architect + api.registerTool(createDesignTaskTool(api), { names: ["design_task"] }); + // Operations api.registerTool(createStatusTool(api), { names: ["status"] }); api.registerTool(createHealthTool(), { names: ["health"] }); @@ -124,7 +136,7 @@ const plugin = { registerBootstrapHook(api); api.logger.info( - "DevClaw plugin registered (11 tools, 1 CLI command group, 1 service, 1 hook)", + "DevClaw plugin registered (12 tools, 1 CLI command group, 1 service, 1 hook)", ); }, }; diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index c50955e..a17e7aa 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -23,11 +23,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; */ export function parseDevClawSessionKey( sessionKey: string, -): { projectName: string; role: "dev" | "qa" } | null { - // Match `:subagent:` prefix, then capture everything up to the last `-dev-` or `-qa-` - const match = sessionKey.match(/:subagent:(.+)-(dev|qa)-[^-]+$/); +): { projectName: string; role: "dev" | "qa" | "architect" } | null { + // Match `:subagent:` prefix, then capture everything up to the last `-dev-`, `-qa-`, or `-architect-` + const match = sessionKey.match(/:subagent:(.+)-(dev|qa|architect)-[^-]+$/); if (!match) return null; - return { projectName: match[1], role: match[2] as "dev" | "qa" }; + return { projectName: match[1], role: match[2] as "dev" | "qa" | "architect" }; } /** @@ -40,7 +40,7 @@ export function parseDevClawSessionKey( export async function loadRoleInstructions( workspaceDir: string, projectName: string, - role: "dev" | "qa", + role: "dev" | "qa" | "architect", ): Promise { const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); try { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 1c01cb2..53f64f9 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -25,8 +25,8 @@ export type DispatchOpts = { issueTitle: string; issueDescription: string; issueUrl: string; - role: "dev" | "qa"; - /** Developer level (junior, medior, senior, reviewer) or raw model ID */ + role: "dev" | "qa" | "architect"; + /** Developer level (junior, medior, senior, reviewer, opus, sonnet) or raw model ID */ level: string; /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ fromLabel: string; @@ -63,7 +63,7 @@ export type DispatchResult = { */ export function buildTaskMessage(opts: { projectName: string; - role: "dev" | "qa"; + role: "dev" | "qa" | "architect"; issueId: number; issueTitle: string; issueDescription: string; @@ -79,7 +79,7 @@ export function buildTaskMessage(opts: { } = opts; const availableResults = - role === "dev" + 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)'; @@ -267,7 +267,7 @@ function sendToAgent( } async function recordWorkerState( - workspaceDir: string, groupId: string, role: "dev" | "qa", + workspaceDir: string, groupId: string, role: "dev" | "qa" | "architect", opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" }, ): Promise { await activateWorker(workspaceDir, groupId, role, { @@ -302,7 +302,7 @@ function buildAnnouncement( level: string, role: string, sessionAction: "spawn" | "send", issueId: number, issueTitle: string, issueUrl: string, ): string { - const emoji = levelEmoji(role as "dev" | "qa", level) ?? (role === "qa" ? "šŸ”" : "šŸ”§"); + const emoji = levelEmoji(role as "dev" | "qa" | "architect", level) ?? (role === "qa" ? "šŸ”" : role === "architect" ? "šŸ—ļø" : "šŸ”§"); 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 1c1dd6c..cb24b5f 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -51,7 +51,7 @@ const COMPLEX_KEYWORDS = [ export function selectLevel( issueTitle: string, issueDescription: string, - role: "dev" | "qa", + role: "dev" | "qa" | "architect", ): LevelSelection { if (role === "qa") { return { @@ -60,6 +60,17 @@ export function selectLevel( }; } + if (role === "architect") { + const text = `${issueTitle} ${issueDescription}`.toLowerCase(); + const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); + return { + level: isComplex ? "opus" : "sonnet", + reason: isComplex + ? "Complex design task — using opus for depth" + : "Standard design task — using sonnet", + }; + } + const text = `${issueTitle} ${issueDescription}`.toLowerCase(); const wordCount = text.split(/\s+/).length; diff --git a/lib/notify.ts b/lib/notify.ts index 3b26f07..27cec5d 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"; + role: "dev" | "qa" | "architect"; level: string; sessionAction: "spawn" | "send"; } @@ -31,7 +31,7 @@ export type NotifyEvent = groupId: string; issueId: number; issueUrl: string; - role: "dev" | "qa"; + role: "dev" | "qa" | "architect"; result: "done" | "pass" | "fail" | "refine" | "blocked"; summary?: string; nextState?: string; diff --git a/lib/projects.ts b/lib/projects.ts index af8a87e..da2f687 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -28,6 +28,7 @@ export type Project = { maxQaWorkers?: number; dev: WorkerState; qa: WorkerState; + architect: WorkerState; }; export type ProjectsData = { @@ -86,6 +87,9 @@ export async function readProjects(workspaceDir: string): Promise project.qa = project.qa ? parseWorkerState(project.qa as unknown as Record) : emptyWorkerState([]); + project.architect = project.architect + ? parseWorkerState(project.architect as unknown as Record) + : emptyWorkerState([]); if (!project.channel) { project.channel = "telegram"; } @@ -113,7 +117,7 @@ export function getProject( export function getWorker( project: Project, - role: "dev" | "qa", + role: "dev" | "qa" | "architect", ): WorkerState { return project[role]; } @@ -125,7 +129,7 @@ export function getWorker( export async function updateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa", + role: "dev" | "qa" | "architect", updates: Partial, ): Promise { const data = await readProjects(workspaceDir); @@ -153,7 +157,7 @@ export async function updateWorker( export async function activateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa", + role: "dev" | "qa" | "architect", params: { issueId: string; level: string; @@ -183,7 +187,7 @@ export async function activateWorker( export async function deactivateWorker( workspaceDir: string, groupId: string, - role: "dev" | "qa", + role: "dev" | "qa" | "architect", ): Promise { return updateWorker(workspaceDir, groupId, role, { active: false, diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index f82b640..5505021 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -306,7 +306,7 @@ async function performHealthPass( const { provider } = await createProvider({ repo: project.repo }); let fixedCount = 0; - for (const role of ["dev", "qa"] as const) { + for (const role of ["dev", "qa", "architect"] as const) { // Check worker health (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 68df960..5537623 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -26,24 +26,28 @@ import { * 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" }, + "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", + "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 @@ -77,7 +81,7 @@ export function getRule( export async function executeCompletion(opts: { workspaceDir: string; groupId: string; - role: "dev" | "qa"; + role: "dev" | "qa" | "architect"; result: string; issueId: number; summary?: string; diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 3f606ec..29bb853 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -11,7 +11,7 @@ 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 { DEV_LEVELS, QA_LEVELS, isDevLevel } from "../tiers.js"; +import { DEV_LEVELS, QA_LEVELS, ARCHITECT_LEVELS, isDevLevel, levelRole } from "../tiers.js"; import { DEFAULT_WORKFLOW, getQueueLabels, @@ -56,10 +56,11 @@ export function detectLevelFromLabels(labels: string[]): string | null { const level = l.slice(dot + 1); if (role === "dev" && (DEV_LEVELS as readonly string[]).includes(level)) return level; if (role === "qa" && (QA_LEVELS as readonly string[]).includes(level)) return level; + if (role === "architect" && (ARCHITECT_LEVELS as readonly string[]).includes(level)) return level; } // Fallback: plain level name - const all = [...DEV_LEVELS, ...QA_LEVELS] as readonly string[]; + const all = [...DEV_LEVELS, ...QA_LEVELS, ...ARCHITECT_LEVELS] as readonly string[]; return all.find((l) => lower.includes(l)) ?? null; } @@ -164,7 +165,7 @@ export async function projectTick(opts: { const provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider; const roleExecution = project.roleExecution ?? "parallel"; - const roles: Role[] = targetRole ? [targetRole] : ["dev", "qa"]; + const roles: Role[] = targetRole ? [targetRole] : ["dev", "qa", "architect"]; const pickups: TickAction[] = []; const skipped: TickResult["skipped"] = []; @@ -185,7 +186,9 @@ export async function projectTick(opts: { skipped.push({ role, reason: `Already active (#${worker.issueId})` }); continue; } - if (roleExecution === "sequential" && getWorker(fresh, role === "dev" ? "qa" : "dev").active) { + // Check sequential role execution: any other role must be inactive + const otherRoles = (["dev", "qa", "architect"] as const).filter(r => r !== role); + if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r).active)) { skipped.push({ role, reason: "Sequential: other role active" }); continue; } @@ -244,10 +247,9 @@ export async function projectTick(opts: { function resolveLevelForIssue(issue: Issue, role: Role): string { const labelLevel = detectLevelFromLabels(issue.labels); if (labelLevel) { - // QA role but label specifies a dev level → heuristic picks the right QA level - if (role === "qa" && isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level; - // DEV role but label specifies a QA level → heuristic picks the right dev level - if (role === "dev" && !isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level; + const labelRole = levelRole(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; } return selectLevel(issue.title, issue.description ?? "", role).level; diff --git a/lib/setup/llm-model-selector.ts b/lib/setup/llm-model-selector.ts index 41dd90a..a3668b4 100644 --- a/lib/setup/llm-model-selector.ts +++ b/lib/setup/llm-model-selector.ts @@ -15,6 +15,10 @@ export type ModelAssignment = { reviewer: string; tester: string; }; + architect: { + opus: string; + sonnet: string; + }; }; /** @@ -34,6 +38,7 @@ export async function selectModelsWithLLM( return { dev: { junior: model, medior: model, senior: model }, qa: { reviewer: model, tester: model }, + architect: { opus: model, sonnet: model }, }; } @@ -70,6 +75,10 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): "qa": { "reviewer": "provider/model-name", "tester": "provider/model-name" + }, + "architect": { + "opus": "provider/model-name", + "sonnet": "provider/model-name" } }`; @@ -119,6 +128,14 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation): 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 = { + opus: assignment.dev?.senior ?? availableModels[0].model, + sonnet: assignment.dev?.medior ?? availableModels[0].model, + }; + } + if ( !assignment.dev?.junior || !assignment.dev?.medior || diff --git a/lib/setup/smart-model-selector.ts b/lib/setup/smart-model-selector.ts index d10b5c4..eb78eb5 100644 --- a/lib/setup/smart-model-selector.ts +++ b/lib/setup/smart-model-selector.ts @@ -14,6 +14,10 @@ export type ModelAssignment = { reviewer: string; tester: string; }; + architect: { + opus: string; + sonnet: string; + }; }; /** @@ -41,6 +45,7 @@ export async function assignModels( return { dev: { junior: model, medior: model, senior: model }, qa: { reviewer: model, tester: model }, + architect: { opus: model, sonnet: model }, }; } @@ -67,6 +72,8 @@ export function formatAssignment(assignment: ModelAssignment): string { `| 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)} |`, ]; return lines.join("\n"); } diff --git a/lib/setup/workspace.ts b/lib/setup/workspace.ts index db5c182..ce86b61 100644 --- a/lib/setup/workspace.ts +++ b/lib/setup/workspace.ts @@ -10,6 +10,7 @@ import { HEARTBEAT_MD_TEMPLATE, DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, + DEFAULT_ARCHITECT_INSTRUCTIONS, } from "../templates.js"; /** @@ -49,6 +50,11 @@ export async function scaffoldWorkspace(workspacePath: string): Promise= 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 + +## Output Format + +Structure your findings as: + +### Problem Statement +Why is this design decision important? + +### Current State +What exists today? Current limitations? + +### Alternatives Investigated + +**Option A: [Name]** +- Pros: ... +- Cons: ... +- Effort estimate: X hours + +**Option B: [Name]** +- Pros: ... +- Cons: ... +- Effort estimate: X hours + +**Option C: [Name]** +- Pros: ... +- Cons: ... +- Effort estimate: X hours + +### Recommendation +**Option X** is recommended because: +- [Evidence-based reasoning] +- [Alignment with project goals] +- [Long-term implications] + +### Implementation Outline +- [ ] Task 1: [Description] +- [ ] Task 2: [Description] +- [ ] Task 3: [Description] + +### References +- [Code examples, prior art, related issues] + +## Available Tools + +- web_search, web_fetch (research patterns) +- Read files (explore codebase) +- exec (run commands, search code) + +## Completion + +When done, call work_finish with: +- role: "architect" +- result: "done" +- summary: Brief summary of your recommendation + +Your session is persistent — you may be called back for refinements. +Do NOT call work_start, status, health, or project_register. +`; + export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw) ## If You Are a Sub-Agent (DEV/QA Worker) @@ -70,6 +141,7 @@ When you are done, **call \`work_finish\` yourself** — do not just announce in - **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: "" })\` +- **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "", summary: "" })\` The \`projectGroupId\` is included in your task message. @@ -139,6 +211,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 | ### Pipeline Flow @@ -148,6 +221,8 @@ Planning → To Do → Doing → To Test → Testing → Done To Improve → Doing (fix cycle) ↓ Refining (human decision) + +To Design → Designing → Planning (design complete) \`\`\` Issue labels are the single source of truth for task state. @@ -160,6 +235,8 @@ Evaluate each task and pass the appropriate developer level to \`work_start\`: - **medior** — 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 ### Picking Up Work @@ -177,6 +254,7 @@ Workers call \`work_finish\` themselves — the label transition, state update, - QA "fail" → issue moves to "To Improve" → scheduler dispatches DEV - QA "pass" → Done, no further dispatch - QA "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. diff --git a/lib/tiers.ts b/lib/tiers.ts index de4af82..98e4d76 100644 --- a/lib/tiers.ts +++ b/lib/tiers.ts @@ -7,10 +7,12 @@ export const DEV_LEVELS = ["junior", "medior", "senior"] as const; export const QA_LEVELS = ["reviewer", "tester"] as const; +export const ARCHITECT_LEVELS = ["opus", "sonnet"] as const; export type DevLevel = (typeof DEV_LEVELS)[number]; export type QaLevel = (typeof QA_LEVELS)[number]; -export type Level = DevLevel | QaLevel; +export type ArchitectLevel = (typeof ARCHITECT_LEVELS)[number]; +export type Level = DevLevel | QaLevel | ArchitectLevel; /** Default models, nested by role. */ export const DEFAULT_MODELS = { @@ -23,6 +25,10 @@ export const DEFAULT_MODELS = { reviewer: "anthropic/claude-sonnet-4-5", tester: "anthropic/claude-haiku-4-5", }, + architect: { + opus: "anthropic/claude-opus-4-5", + sonnet: "anthropic/claude-sonnet-4-5", + }, }; /** Emoji used in announcements, nested by role. */ @@ -36,6 +42,10 @@ export const LEVEL_EMOJI = { reviewer: "šŸ”", tester: "šŸ‘€", }, + architect: { + opus: "šŸ—ļø", + sonnet: "šŸ“", + }, }; /** Check if a level belongs to the dev role. */ @@ -48,20 +58,29 @@ export function isQaLevel(value: string): value is QaLevel { return (QA_LEVELS as readonly string[]).includes(value); } +/** Check if a level belongs to the architect role. */ +export function isArchitectLevel(value: string): value is ArchitectLevel { + return (ARCHITECT_LEVELS as readonly string[]).includes(value); +} + /** Determine the role a level belongs to. */ -export function levelRole(level: string): "dev" | "qa" | undefined { +export function levelRole(level: string): WorkerRole | undefined { if (isDevLevel(level)) return "dev"; if (isQaLevel(level)) return "qa"; + if (isArchitectLevel(level)) return "architect"; return undefined; } +/** All valid worker roles. */ +export type WorkerRole = "dev" | "qa" | "architect"; + /** Get the default model for a role + level. */ -export function defaultModel(role: "dev" | "qa", level: string): string | undefined { +export function defaultModel(role: WorkerRole, level: string): string | undefined { return (DEFAULT_MODELS[role] as Record)[level]; } /** Get the emoji for a role + level. */ -export function levelEmoji(role: "dev" | "qa", level: string): string | undefined { +export function levelEmoji(role: WorkerRole, level: string): string | undefined { return (LEVEL_EMOJI[role] as Record)[level]; } @@ -74,7 +93,7 @@ export function levelEmoji(role: "dev" | "qa", level: string): string | undefine * 3. Passthrough (treat as raw model ID) */ export function resolveModel( - role: "dev" | "qa", + role: WorkerRole, level: string, pluginConfig?: Record, ): string { diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts new file mode 100644 index 0000000..ecd7536 --- /dev/null +++ b/lib/tools/design-task.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for architect role, design_task tool, and workflow integration. + * Run with: npx tsx --test lib/tools/design-task.test.ts + */ +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 { selectLevel } from "../model-selector.js"; +import { + DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule, + getCompletionEmoji, detectRoleFromLabel, getStateLabels, +} from "../workflow.js"; + +describe("architect tiers", () => { + it("should recognize architect levels", () => { + assert.strictEqual(isArchitectLevel("opus"), true); + assert.strictEqual(isArchitectLevel("sonnet"), true); + assert.strictEqual(isArchitectLevel("medior"), false); + }); + + it("should map architect levels to role", () => { + assert.strictEqual(levelRole("opus"), "architect"); + assert.strictEqual(levelRole("sonnet"), "architect"); + }); + + 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"); + }); + + it("should resolve architect model from config", () => { + const config = { models: { architect: { opus: "custom/model" } } }; + assert.strictEqual(resolveModel("architect", "opus", config), "custom/model"); + }); + + it("should have architect emoji", () => { + assert.strictEqual(levelEmoji("architect", "opus"), "šŸ—ļø"); + assert.strictEqual(levelEmoji("architect", "sonnet"), "šŸ“"); + }); +}); + +describe("architect workflow states", () => { + it("should include To Design and Designing in state labels", () => { + const labels = getStateLabels(DEFAULT_WORKFLOW); + assert.ok(labels.includes("To Design")); + assert.ok(labels.includes("Designing")); + }); + + it("should have To Design as architect queue label", () => { + const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect"); + assert.deepStrictEqual(queues, ["To Design"]); + }); + + it("should have Designing as architect active label", () => { + assert.strictEqual(getActiveLabel(DEFAULT_WORKFLOW, "architect"), "Designing"); + }); + + it("should detect architect role from To Design label", () => { + assert.strictEqual(detectRoleFromLabel(DEFAULT_WORKFLOW, "To Design"), "architect"); + }); + + 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 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 architect completion emoji", () => { + assert.strictEqual(getCompletionEmoji("architect", "done"), "šŸ—ļø"); + assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫"); + }); +}); + +describe("architect model selection", () => { + it("should select sonnet for standard design tasks", () => { + const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect"); + assert.strictEqual(result.level, "sonnet"); + }); + + it("should select opus for complex design tasks", () => { + const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect"); + assert.strictEqual(result.level, "opus"); + }); +}); + +describe("architect session key parsing", () => { + it("should parse architect session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-opus"); + assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" }); + }); + + it("should parse architect sonnet session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-sonnet"); + assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" }); + }); +}); diff --git a/lib/tools/design-task.ts b/lib/tools/design-task.ts new file mode 100644 index 0000000..2c9a1ea --- /dev/null +++ b/lib/tools/design-task.ts @@ -0,0 +1,186 @@ +/** + * design_task — Spawn an architect to investigate a design 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. + */ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { jsonResult } from "openclaw/plugin-sdk"; +import type { ToolContext } from "../types.js"; +import type { StateLabel } from "../providers/provider.js"; +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"; + +export function createDesignTaskTool(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. + +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 + +Example: + design_task({ + projectGroupId: "-5176490302", + title: "Design: Session persistence strategy", + description: "How should sessions be persisted across restarts?", + complexity: "complex" + })`, + parameters: { + type: "object", + required: ["projectGroupId", "title"], + properties: { + projectGroupId: { + type: "string", + description: "Project group ID", + }, + title: { + type: "string", + description: "Design title (e.g., 'Design: Session persistence')", + }, + description: { + type: "string", + description: "What are we designing & why? Include context and constraints.", + }, + focusAreas: { + type: "array", + items: { type: "string" }, + description: "Specific areas to investigate (e.g., ['performance', 'scalability', 'simplicity'])", + }, + complexity: { + type: "string", + enum: ["simple", "medium", "complex"], + description: "Suggests architect level: simple/medium → sonnet, complex → opus. Defaults to medium.", + }, + dryRun: { + type: "boolean", + description: "Preview without executing. Defaults to false.", + }, + }, + }, + + async execute(_id: string, params: Record) { + const groupId = params.projectGroupId as string; + const title = params.title as string; + const description = (params.description as string) ?? ""; + const focusAreas = (params.focusAreas as string[]) ?? []; + const complexity = (params.complexity as "simple" | "medium" | "complex") ?? "medium"; + const dryRun = (params.dryRun as boolean) ?? false; + const workspaceDir = requireWorkspaceDir(ctx); + + if (!groupId) throw new Error("projectGroupId is required"); + if (!title) throw new Error("title is required"); + + const { project } = await resolveProject(workspaceDir, groupId); + const { provider } = await resolveProvider(project); + + // Build issue body with focus areas + const bodyParts = [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 To Design state + const issue = await provider.createIssue(title, issueBody, "To Design" 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" ? "opus" : "sonnet"; + + 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}`, + }); + } + + // Check architect availability + const worker = getWorker(project, "architect"); + 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" }, + design: { + level, + status: "queued", + reason: `Architect already active on #${worker.issueId}. Issue queued for pickup.`, + }, + announcement: `šŸ“ Created design task #${issue.iid}: ${title} (queued — architect busy)\nšŸ”— ${issue.web_url}`, + }); + } + + // Dispatch architect + const workflow = DEFAULT_WORKFLOW; + const targetLabel = getActiveLabel(workflow, "architect"); + const pluginConfig = getPluginConfig(api); + + const dr = await dispatchTask({ + workspaceDir, + agentId: ctx.agentId, + groupId, + project, + issueId: issue.iid, + issueTitle: issue.title, + issueDescription: issueBody, + issueUrl: issue.web_url, + role: "architect", + level, + fromLabel: "To Design", + toLabel: targetLabel, + transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), + provider, + pluginConfig, + channel: project.channel, + sessionKey: ctx.sessionKey, + runtime: api.runtime, + }); + + return jsonResult({ + success: true, + issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: targetLabel }, + design: { + sessionKey: dr.sessionKey, + level: dr.level, + model: dr.model, + sessionAction: dr.sessionAction, + status: "in_progress", + }, + project: project.name, + announcement: dr.announcement, + }); + }, + }); +} diff --git a/lib/tools/health.ts b/lib/tools/health.ts index 25434a5..4d053a8 100644 --- a/lib/tools/health.ts +++ b/lib/tools/health.ts @@ -51,7 +51,7 @@ export function createHealthTool() { if (!project) continue; const { provider } = await resolveProvider(project); - for (const role of ["dev", "qa"] as const) { + for (const role of ["dev", "qa", "architect"] as const) { // Worker health check (session liveness, label consistency, etc) const healthFixes = await checkWorkerHealth({ workspaceDir, diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index e21739a..ff4efac 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -14,8 +14,8 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js"; import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; -import { DEV_LEVELS, QA_LEVELS } from "../tiers.js"; -import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js"; +import { DEV_LEVELS, QA_LEVELS, ARCHITECT_LEVELS } from "../tiers.js"; +import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js"; /** * Scaffold project-specific prompt files. @@ -43,6 +43,14 @@ async function scaffoldPromptFiles(workspaceDir: string, projectName: string): P created = true; } + const projectArchitect = path.join(projectDir, "architect.md"); + try { + await fs.access(projectArchitect); + } catch { + await fs.writeFile(projectArchitect, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8"); + created = true; + } + return created; } @@ -141,7 +149,7 @@ export function createProjectRegisterTool() { ); } - // 4. Create all 8 state labels (idempotent) + // 4. Create all state labels (idempotent) await provider.ensureAllStateLabels(); // 5. Add project to projects.json @@ -156,6 +164,7 @@ export function createProjectRegisterTool() { roleExecution, dev: emptyWorkerState([...DEV_LEVELS]), qa: emptyWorkerState([...QA_LEVELS]), + architect: emptyWorkerState([...ARCHITECT_LEVELS]), }; await writeProjects(workspaceDir, data); @@ -184,7 +193,7 @@ export function createProjectRegisterTool() { repo, baseBranch, deployBranch, - labelsCreated: 8, + labelsCreated: 10, promptsScaffolded: promptsCreated, announcement, }); diff --git a/lib/tools/status.ts b/lib/tools/status.ts index 1a5443d..05339c0 100644 --- a/lib/tools/status.ts +++ b/lib/tools/status.ts @@ -68,6 +68,12 @@ export function createStatusTool(api: OpenClawPluginApi) { level: project.qa.level, startTime: project.qa.startTime, }, + architect: { + active: project.architect.active, + issueId: project.architect.issueId, + level: project.architect.level, + startTime: project.architect.startTime, + }, queue: queueCounts, }; }), diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index 3df3f3c..5384bb5 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -21,7 +21,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { type: "object", required: ["role", "result", "projectGroupId"], properties: { - role: { type: "string", enum: ["dev", "qa"], description: "Worker role" }, + role: { type: "string", enum: ["dev", "qa", "architect"], description: "Worker role" }, result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" }, projectGroupId: { type: "string", description: "Project group ID" }, summary: { type: "string", description: "Brief summary" }, @@ -30,7 +30,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { }, async execute(_id: string, params: Record) { - const role = params.role as "dev" | "qa"; + const role = params.role as "dev" | "qa" | "architect"; const result = params.result as string; const groupId = params.projectGroupId as string; const summary = params.summary as string | undefined; @@ -40,6 +40,8 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { // Validate role:result if (role === "dev" && result !== "done" && result !== "blocked") throw new Error(`DEV can only complete with "done" or "blocked", got "${result}"`); + if (role === "architect" && result !== "done" && result !== "blocked") + throw new Error(`ARCHITECT can only complete with "done" or "blocked", got "${result}"`); if (role === "qa" && result === "done") throw new Error(`QA cannot use "done". Use "pass", "fail", "refine", or "blocked".`); if (!getRule(role, result)) diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index d7acdde..604eb9a 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -28,14 +28,14 @@ export function createWorkStartTool(api: OpenClawPluginApi) { properties: { projectGroupId: { type: "string", description: "Project group ID." }, issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." }, - role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." }, + role: { type: "string", enum: ["dev", "qa", "architect"], description: "Worker role. Auto-detected from label if omitted." }, level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." }, }, }, async execute(_id: string, params: Record) { const issueIdParam = params.issueId as number | undefined; - const roleParam = params.role as "dev" | "qa" | undefined; + const roleParam = params.role as "dev" | "qa" | "architect" | undefined; const groupId = params.projectGroupId as string; const levelParam = (params.level ?? params.tier) as string | undefined; const workspaceDir = requireWorkspaceDir(ctx); diff --git a/lib/workflow.ts b/lib/workflow.ts index 4a27943..c5908ec 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -17,7 +17,7 @@ import path from "node:path"; // --------------------------------------------------------------------------- export type StateType = "queue" | "active" | "hold" | "terminal"; -export type Role = "dev" | "qa"; +export type Role = "dev" | "qa" | "architect"; export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue"; export type TransitionTarget = string | { @@ -118,6 +118,24 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { 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", + }, + }, }, }; @@ -295,6 +313,8 @@ const RESULT_TO_EVENT: Record = { "qa:fail": "FAIL", "qa:refine": "REFINE", "qa:blocked": "BLOCKED", + "architect:done": "COMPLETE", + "architect:blocked": "BLOCKED", }; /** @@ -369,6 +389,8 @@ export function getCompletionEmoji(role: Role, result: string): string { "qa:refine": "šŸ¤”", "dev:blocked": "🚫", "qa:blocked": "🚫", + "architect:done": "šŸ—ļø", + "architect:blocked": "🚫", }; return map[`${role}:${result}`] ?? "šŸ“‹"; }