Merge pull request #191 from laurentenhoor/feat/189-architect-role

feat: Implement Architect role & design_task tool
This commit is contained in:
Lauren ten Hoor
2026-02-14 17:08:41 +08:00
committed by GitHub
22 changed files with 546 additions and 56 deletions

View File

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

View File

@@ -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<string> {
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
try {

View File

@@ -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<void> {
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}`;
}

View File

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

View File

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

View File

@@ -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<ProjectsData>
project.qa = project.qa
? parseWorkerState(project.qa as unknown as Record<string, unknown>)
: emptyWorkerState([]);
project.architect = project.architect
? parseWorkerState(project.architect as unknown as Record<string, unknown>)
: 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<WorkerState>,
): Promise<ProjectsData> {
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<ProjectsData> {
return updateWorker(workspaceDir, groupId, role, {
active: false,

View File

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

View File

@@ -26,24 +26,28 @@ import {
* Kept for backward compatibility.
*/
export const COMPLETION_RULES: Record<string, CompletionRule> = {
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
"qa:pass": { from: "Testing", to: "Done", closeIssue: true },
"qa:fail": { from: "Testing", to: "To Improve", reopenIssue: true },
"qa:refine": { from: "Testing", to: "Refining" },
"dev:blocked": { from: "Doing", to: "Refining" },
"qa:blocked": { from: "Testing", to: "Refining" },
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
"qa:pass": { from: "Testing", to: "Done", closeIssue: true },
"qa:fail": { from: "Testing", to: "To Improve", reopenIssue: true },
"qa:refine": { from: "Testing", to: "Refining" },
"dev:blocked": { from: "Doing", to: "Refining" },
"qa:blocked": { from: "Testing", to: "Refining" },
"architect:done": { from: "Designing", to: "Planning" },
"architect:blocked": { from: "Designing", to: "Refining" },
};
/**
* @deprecated Use getNextStateDescription() from workflow.ts instead.
*/
export const NEXT_STATE: Record<string, string> = {
"dev:done": "QA queue",
"dev:blocked": "moved to Refining - needs human input",
"qa:pass": "Done!",
"qa:fail": "back to DEV",
"qa:refine": "awaiting human decision",
"qa:blocked": "moved to Refining - needs human input",
"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;

View File

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

View File

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

View File

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

View File

@@ -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<string[]
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");

View File

@@ -44,6 +44,77 @@ export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
- Do NOT call work_start, status, health, or project_register
`;
export const DEFAULT_ARCHITECT_INSTRUCTIONS = `# Architect Worker Instructions
You design and investigate architecture/design questions systematically.
## 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 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: "<from task message>", summary: "<brief summary>" })\`
- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **QA refine:** \`work_finish({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
- **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation 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.

View File

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

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);

View File

@@ -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<string, string> = {
"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}`] ?? "📋";
}