feat: Implement Architect role & design_task tool (#189)

Adds the Architect role for design/architecture investigations with
persistent sessions and structured design proposals.

## New Features

- **Architect role** with opus (complex) and sonnet (standard) levels
- **design_task tool** — Creates To Design issues and dispatches architect
- **Workflow states:** To Design → Designing → Planning
- **Completion rules:** architect:done → Planning, architect:blocked → Refining
- **Auto-level selection** based on complexity keywords

## Files Changed (22 files, 546 additions)

### New Files
- lib/tools/design-task.ts — design_task tool implementation
- lib/tools/design-task.test.ts — 16 tests for architect functionality

### Core Changes
- lib/tiers.ts — ARCHITECT_LEVELS, WorkerRole type, models, emoji
- lib/workflow.ts — toDesign/designing states, completion rules
- lib/projects.ts — architect WorkerState on Project type
- lib/dispatch.ts — architect role support in dispatch pipeline
- lib/services/pipeline.ts — architect completion rules
- lib/model-selector.ts — architect level selection heuristic

### Integration
- index.ts — Register design_task tool, architect config schema
- lib/notify.ts — architect role in notifications
- lib/bootstrap-hook.ts — architect session key parsing
- lib/services/tick.ts — architect in queue processing
- lib/services/heartbeat.ts — architect in health checks
- lib/tools/health.ts — architect in health scans
- lib/tools/status.ts — architect in status dashboard
- lib/tools/work-start.ts — architect role option
- lib/tools/work-finish.ts — architect validation
- lib/tools/project-register.ts — architect labels + role scaffolding
- lib/templates.ts — architect instructions + AGENTS.md updates
- lib/setup/workspace.ts — architect role file scaffolding
- lib/setup/smart-model-selector.ts — architect in model assignment
- lib/setup/llm-model-selector.ts — architect in LLM prompt
This commit is contained in:
Lauren ten Hoor
2026-02-14 17:08:17 +08:00
parent 310230772b
commit 57c78f3656
22 changed files with 546 additions and 56 deletions

View File

@@ -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" });
});
});

186
lib/tools/design-task.ts Normal file
View File

@@ -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<string, unknown>) {
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,
});
},
});
}

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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,
};
}),

View File

@@ -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<string, unknown>) {
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))

View File

@@ -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<string, unknown>) {
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);