Merge pull request #191 from laurentenhoor/feat/189-architect-role
feat: Implement Architect role & design_task tool
This commit is contained in:
14
index.ts
14
index.ts
@@ -10,6 +10,7 @@ import { createProjectRegisterTool } from "./lib/tools/project-register.js";
|
|||||||
import { createSetupTool } from "./lib/tools/setup.js";
|
import { createSetupTool } from "./lib/tools/setup.js";
|
||||||
import { createOnboardTool } from "./lib/tools/onboard.js";
|
import { createOnboardTool } from "./lib/tools/onboard.js";
|
||||||
import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js";
|
import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js";
|
||||||
|
import { createDesignTaskTool } from "./lib/tools/design-task.js";
|
||||||
import { registerCli } from "./lib/cli.js";
|
import { registerCli } from "./lib/cli.js";
|
||||||
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
||||||
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
||||||
@@ -44,6 +45,14 @@ const plugin = {
|
|||||||
tester: { type: "string" },
|
tester: { type: "string" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
architect: {
|
||||||
|
type: "object",
|
||||||
|
description: "Architect tier models",
|
||||||
|
properties: {
|
||||||
|
opus: { type: "string" },
|
||||||
|
sonnet: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
projectExecution: {
|
projectExecution: {
|
||||||
@@ -99,6 +108,9 @@ const plugin = {
|
|||||||
api.registerTool(createTaskUpdateTool(api), { names: ["task_update"] });
|
api.registerTool(createTaskUpdateTool(api), { names: ["task_update"] });
|
||||||
api.registerTool(createTaskCommentTool(api), { names: ["task_comment"] });
|
api.registerTool(createTaskCommentTool(api), { names: ["task_comment"] });
|
||||||
|
|
||||||
|
// Architect
|
||||||
|
api.registerTool(createDesignTaskTool(api), { names: ["design_task"] });
|
||||||
|
|
||||||
// Operations
|
// Operations
|
||||||
api.registerTool(createStatusTool(api), { names: ["status"] });
|
api.registerTool(createStatusTool(api), { names: ["status"] });
|
||||||
api.registerTool(createHealthTool(), { names: ["health"] });
|
api.registerTool(createHealthTool(), { names: ["health"] });
|
||||||
@@ -124,7 +136,7 @@ const plugin = {
|
|||||||
registerBootstrapHook(api);
|
registerBootstrapHook(api);
|
||||||
|
|
||||||
api.logger.info(
|
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)",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|||||||
*/
|
*/
|
||||||
export function parseDevClawSessionKey(
|
export function parseDevClawSessionKey(
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
): { projectName: string; role: "dev" | "qa" } | null {
|
): { projectName: string; role: "dev" | "qa" | "architect" } | null {
|
||||||
// Match `:subagent:` prefix, then capture everything up to the last `-dev-` or `-qa-`
|
// Match `:subagent:` prefix, then capture everything up to the last `-dev-`, `-qa-`, or `-architect-`
|
||||||
const match = sessionKey.match(/:subagent:(.+)-(dev|qa)-[^-]+$/);
|
const match = sessionKey.match(/:subagent:(.+)-(dev|qa|architect)-[^-]+$/);
|
||||||
if (!match) return null;
|
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(
|
export async function loadRoleInstructions(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
role: "dev" | "qa",
|
role: "dev" | "qa" | "architect",
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export type DispatchOpts = {
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueDescription: string;
|
issueDescription: string;
|
||||||
issueUrl: string;
|
issueUrl: string;
|
||||||
role: "dev" | "qa";
|
role: "dev" | "qa" | "architect";
|
||||||
/** Developer level (junior, medior, senior, reviewer) or raw model ID */
|
/** Developer level (junior, medior, senior, reviewer, opus, sonnet) or raw model ID */
|
||||||
level: string;
|
level: string;
|
||||||
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
|
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
|
||||||
fromLabel: string;
|
fromLabel: string;
|
||||||
@@ -63,7 +63,7 @@ export type DispatchResult = {
|
|||||||
*/
|
*/
|
||||||
export function buildTaskMessage(opts: {
|
export function buildTaskMessage(opts: {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
role: "dev" | "qa";
|
role: "dev" | "qa" | "architect";
|
||||||
issueId: number;
|
issueId: number;
|
||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueDescription: string;
|
issueDescription: string;
|
||||||
@@ -79,7 +79,7 @@ export function buildTaskMessage(opts: {
|
|||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const availableResults =
|
const availableResults =
|
||||||
role === "dev"
|
role === "dev" || role === "architect"
|
||||||
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
|
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
|
||||||
: '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)';
|
: '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)';
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ function sendToAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function recordWorkerState(
|
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" },
|
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await activateWorker(workspaceDir, groupId, role, {
|
await activateWorker(workspaceDir, groupId, role, {
|
||||||
@@ -302,7 +302,7 @@ function buildAnnouncement(
|
|||||||
level: string, role: string, sessionAction: "spawn" | "send",
|
level: string, role: string, sessionAction: "spawn" | "send",
|
||||||
issueId: number, issueTitle: string, issueUrl: string,
|
issueId: number, issueTitle: string, issueUrl: string,
|
||||||
): 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";
|
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
||||||
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const COMPLEX_KEYWORDS = [
|
|||||||
export function selectLevel(
|
export function selectLevel(
|
||||||
issueTitle: string,
|
issueTitle: string,
|
||||||
issueDescription: string,
|
issueDescription: string,
|
||||||
role: "dev" | "qa",
|
role: "dev" | "qa" | "architect",
|
||||||
): LevelSelection {
|
): LevelSelection {
|
||||||
if (role === "qa") {
|
if (role === "qa") {
|
||||||
return {
|
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 text = `${issueTitle} ${issueDescription}`.toLowerCase();
|
||||||
const wordCount = text.split(/\s+/).length;
|
const wordCount = text.split(/\s+/).length;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type NotifyEvent =
|
|||||||
issueId: number;
|
issueId: number;
|
||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueUrl: string;
|
issueUrl: string;
|
||||||
role: "dev" | "qa";
|
role: "dev" | "qa" | "architect";
|
||||||
level: string;
|
level: string;
|
||||||
sessionAction: "spawn" | "send";
|
sessionAction: "spawn" | "send";
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ export type NotifyEvent =
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
issueId: number;
|
issueId: number;
|
||||||
issueUrl: string;
|
issueUrl: string;
|
||||||
role: "dev" | "qa";
|
role: "dev" | "qa" | "architect";
|
||||||
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
nextState?: string;
|
nextState?: string;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type Project = {
|
|||||||
maxQaWorkers?: number;
|
maxQaWorkers?: number;
|
||||||
dev: WorkerState;
|
dev: WorkerState;
|
||||||
qa: WorkerState;
|
qa: WorkerState;
|
||||||
|
architect: WorkerState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectsData = {
|
export type ProjectsData = {
|
||||||
@@ -86,6 +87,9 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
|
|||||||
project.qa = project.qa
|
project.qa = project.qa
|
||||||
? parseWorkerState(project.qa as unknown as Record<string, unknown>)
|
? parseWorkerState(project.qa as unknown as Record<string, unknown>)
|
||||||
: emptyWorkerState([]);
|
: emptyWorkerState([]);
|
||||||
|
project.architect = project.architect
|
||||||
|
? parseWorkerState(project.architect as unknown as Record<string, unknown>)
|
||||||
|
: emptyWorkerState([]);
|
||||||
if (!project.channel) {
|
if (!project.channel) {
|
||||||
project.channel = "telegram";
|
project.channel = "telegram";
|
||||||
}
|
}
|
||||||
@@ -113,7 +117,7 @@ export function getProject(
|
|||||||
|
|
||||||
export function getWorker(
|
export function getWorker(
|
||||||
project: Project,
|
project: Project,
|
||||||
role: "dev" | "qa",
|
role: "dev" | "qa" | "architect",
|
||||||
): WorkerState {
|
): WorkerState {
|
||||||
return project[role];
|
return project[role];
|
||||||
}
|
}
|
||||||
@@ -125,7 +129,7 @@ export function getWorker(
|
|||||||
export async function updateWorker(
|
export async function updateWorker(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
role: "dev" | "qa",
|
role: "dev" | "qa" | "architect",
|
||||||
updates: Partial<WorkerState>,
|
updates: Partial<WorkerState>,
|
||||||
): Promise<ProjectsData> {
|
): Promise<ProjectsData> {
|
||||||
const data = await readProjects(workspaceDir);
|
const data = await readProjects(workspaceDir);
|
||||||
@@ -153,7 +157,7 @@ export async function updateWorker(
|
|||||||
export async function activateWorker(
|
export async function activateWorker(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
role: "dev" | "qa",
|
role: "dev" | "qa" | "architect",
|
||||||
params: {
|
params: {
|
||||||
issueId: string;
|
issueId: string;
|
||||||
level: string;
|
level: string;
|
||||||
@@ -183,7 +187,7 @@ export async function activateWorker(
|
|||||||
export async function deactivateWorker(
|
export async function deactivateWorker(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
role: "dev" | "qa",
|
role: "dev" | "qa" | "architect",
|
||||||
): Promise<ProjectsData> {
|
): Promise<ProjectsData> {
|
||||||
return updateWorker(workspaceDir, groupId, role, {
|
return updateWorker(workspaceDir, groupId, role, {
|
||||||
active: false,
|
active: false,
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ async function performHealthPass(
|
|||||||
const { provider } = await createProvider({ repo: project.repo });
|
const { provider } = await createProvider({ repo: project.repo });
|
||||||
let fixedCount = 0;
|
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)
|
// Check worker health (session liveness, label consistency, etc)
|
||||||
const healthFixes = await checkWorkerHealth({
|
const healthFixes = await checkWorkerHealth({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const COMPLETION_RULES: Record<string, CompletionRule> = {
|
|||||||
"qa:refine": { from: "Testing", to: "Refining" },
|
"qa:refine": { from: "Testing", to: "Refining" },
|
||||||
"dev:blocked": { from: "Doing", to: "Refining" },
|
"dev:blocked": { from: "Doing", to: "Refining" },
|
||||||
"qa:blocked": { from: "Testing", to: "Refining" },
|
"qa:blocked": { from: "Testing", to: "Refining" },
|
||||||
|
"architect:done": { from: "Designing", to: "Planning" },
|
||||||
|
"architect:blocked": { from: "Designing", to: "Refining" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +46,8 @@ export const NEXT_STATE: Record<string, string> = {
|
|||||||
"qa:fail": "back to DEV",
|
"qa:fail": "back to DEV",
|
||||||
"qa:refine": "awaiting human decision",
|
"qa:refine": "awaiting human decision",
|
||||||
"qa:blocked": "moved to Refining - needs human input",
|
"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
|
// Re-export CompletionRule type for backward compatibility
|
||||||
@@ -77,7 +81,7 @@ export function getRule(
|
|||||||
export async function executeCompletion(opts: {
|
export async function executeCompletion(opts: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
role: "dev" | "qa";
|
role: "dev" | "qa" | "architect";
|
||||||
result: string;
|
result: string;
|
||||||
issueId: number;
|
issueId: number;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createProvider } from "../providers/index.js";
|
|||||||
import { selectLevel } from "../model-selector.js";
|
import { selectLevel } from "../model-selector.js";
|
||||||
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
||||||
import { dispatchTask } from "../dispatch.js";
|
import { dispatchTask } from "../dispatch.js";
|
||||||
import { DEV_LEVELS, QA_LEVELS, isDevLevel } from "../tiers.js";
|
import { DEV_LEVELS, QA_LEVELS, ARCHITECT_LEVELS, isDevLevel, levelRole } from "../tiers.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
getQueueLabels,
|
getQueueLabels,
|
||||||
@@ -56,10 +56,11 @@ export function detectLevelFromLabels(labels: string[]): string | null {
|
|||||||
const level = l.slice(dot + 1);
|
const level = l.slice(dot + 1);
|
||||||
if (role === "dev" && (DEV_LEVELS as readonly string[]).includes(level)) return level;
|
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 === "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
|
// 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;
|
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 provider = opts.provider ?? (await createProvider({ repo: project.repo })).provider;
|
||||||
const roleExecution = project.roleExecution ?? "parallel";
|
const roleExecution = project.roleExecution ?? "parallel";
|
||||||
const roles: Role[] = targetRole ? [targetRole] : ["dev", "qa"];
|
const roles: Role[] = targetRole ? [targetRole] : ["dev", "qa", "architect"];
|
||||||
|
|
||||||
const pickups: TickAction[] = [];
|
const pickups: TickAction[] = [];
|
||||||
const skipped: TickResult["skipped"] = [];
|
const skipped: TickResult["skipped"] = [];
|
||||||
@@ -185,7 +186,9 @@ export async function projectTick(opts: {
|
|||||||
skipped.push({ role, reason: `Already active (#${worker.issueId})` });
|
skipped.push({ role, reason: `Already active (#${worker.issueId})` });
|
||||||
continue;
|
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" });
|
skipped.push({ role, reason: "Sequential: other role active" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -244,10 +247,9 @@ export async function projectTick(opts: {
|
|||||||
function resolveLevelForIssue(issue: Issue, role: Role): string {
|
function resolveLevelForIssue(issue: Issue, role: Role): string {
|
||||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||||
if (labelLevel) {
|
if (labelLevel) {
|
||||||
// QA role but label specifies a dev level → heuristic picks the right QA level
|
const labelRole = levelRole(labelLevel);
|
||||||
if (role === "qa" && isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level;
|
// If label level belongs to a different role, use heuristic for correct role
|
||||||
// DEV role but label specifies a QA level → heuristic picks the right dev level
|
if (labelRole && labelRole !== role) return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||||
if (role === "dev" && !isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level;
|
|
||||||
return labelLevel;
|
return labelLevel;
|
||||||
}
|
}
|
||||||
return selectLevel(issue.title, issue.description ?? "", role).level;
|
return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export type ModelAssignment = {
|
|||||||
reviewer: string;
|
reviewer: string;
|
||||||
tester: string;
|
tester: string;
|
||||||
};
|
};
|
||||||
|
architect: {
|
||||||
|
opus: string;
|
||||||
|
sonnet: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +38,7 @@ export async function selectModelsWithLLM(
|
|||||||
return {
|
return {
|
||||||
dev: { junior: model, medior: model, senior: model },
|
dev: { junior: model, medior: model, senior: model },
|
||||||
qa: { reviewer: model, tester: 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": {
|
"qa": {
|
||||||
"reviewer": "provider/model-name",
|
"reviewer": "provider/model-name",
|
||||||
"tester": "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));
|
console.log("LLM returned:", JSON.stringify(assignment, null, 2));
|
||||||
|
|
||||||
// Validate the structure
|
// 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 (
|
if (
|
||||||
!assignment.dev?.junior ||
|
!assignment.dev?.junior ||
|
||||||
!assignment.dev?.medior ||
|
!assignment.dev?.medior ||
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type ModelAssignment = {
|
|||||||
reviewer: string;
|
reviewer: string;
|
||||||
tester: string;
|
tester: string;
|
||||||
};
|
};
|
||||||
|
architect: {
|
||||||
|
opus: string;
|
||||||
|
sonnet: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +45,7 @@ export async function assignModels(
|
|||||||
return {
|
return {
|
||||||
dev: { junior: model, medior: model, senior: model },
|
dev: { junior: model, medior: model, senior: model },
|
||||||
qa: { reviewer: model, tester: 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)} |`,
|
`| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`,
|
||||||
`| QA | reviewer | ${assignment.qa.reviewer.padEnd(24)} |`,
|
`| QA | reviewer | ${assignment.qa.reviewer.padEnd(24)} |`,
|
||||||
`| QA | tester | ${assignment.qa.tester.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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
HEARTBEAT_MD_TEMPLATE,
|
HEARTBEAT_MD_TEMPLATE,
|
||||||
DEFAULT_DEV_INSTRUCTIONS,
|
DEFAULT_DEV_INSTRUCTIONS,
|
||||||
DEFAULT_QA_INSTRUCTIONS,
|
DEFAULT_QA_INSTRUCTIONS,
|
||||||
|
DEFAULT_ARCHITECT_INSTRUCTIONS,
|
||||||
} from "../templates.js";
|
} from "../templates.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +50,11 @@ export async function scaffoldWorkspace(workspacePath: string): Promise<string[]
|
|||||||
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
||||||
filesWritten.push("projects/roles/default/qa.md");
|
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)
|
// log/ directory (audit.log created on first write)
|
||||||
const logDir = path.join(workspacePath, "log");
|
const logDir = path.join(workspacePath, "log");
|
||||||
|
|||||||
@@ -44,6 +44,77 @@ export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
|||||||
- Do NOT call work_start, status, health, or project_register
|
- 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)
|
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
|
||||||
|
|
||||||
## If You Are a Sub-Agent (DEV/QA Worker)
|
## If You Are a Sub-Agent (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 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 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>" })\`
|
- **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.
|
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 |
|
| \`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_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 |
|
| \`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
|
### Pipeline Flow
|
||||||
|
|
||||||
@@ -148,6 +221,8 @@ Planning → To Do → Doing → To Test → Testing → Done
|
|||||||
To Improve → Doing (fix cycle)
|
To Improve → Doing (fix cycle)
|
||||||
↓
|
↓
|
||||||
Refining (human decision)
|
Refining (human decision)
|
||||||
|
|
||||||
|
To Design → Designing → Planning (design complete)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Issue labels are the single source of truth for task state.
|
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
|
- **medior** — standard: features, bug fixes, multi-file changes
|
||||||
- **senior** — complex: architecture, system-wide refactoring, 5+ services
|
- **senior** — complex: architecture, system-wide refactoring, 5+ services
|
||||||
- **reviewer** — QA: code inspection, validation, test runs
|
- **reviewer** — QA: code inspection, validation, test runs
|
||||||
|
- **opus** — Architect: complex, high-impact design investigations
|
||||||
|
- **sonnet** — Architect: standard feature design investigations
|
||||||
|
|
||||||
### Picking Up Work
|
### 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 "fail" → issue moves to "To Improve" → scheduler dispatches DEV
|
||||||
- QA "pass" → Done, no further dispatch
|
- QA "pass" → Done, no further dispatch
|
||||||
- QA "refine" / blocked → needs human input
|
- 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.
|
**Always include issue URLs** in your response — these are in the \`announcement\` fields.
|
||||||
|
|
||||||
|
|||||||
29
lib/tiers.ts
29
lib/tiers.ts
@@ -7,10 +7,12 @@
|
|||||||
|
|
||||||
export const DEV_LEVELS = ["junior", "medior", "senior"] as const;
|
export const DEV_LEVELS = ["junior", "medior", "senior"] as const;
|
||||||
export const QA_LEVELS = ["reviewer", "tester"] 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 DevLevel = (typeof DEV_LEVELS)[number];
|
||||||
export type QaLevel = (typeof QA_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. */
|
/** Default models, nested by role. */
|
||||||
export const DEFAULT_MODELS = {
|
export const DEFAULT_MODELS = {
|
||||||
@@ -23,6 +25,10 @@ export const DEFAULT_MODELS = {
|
|||||||
reviewer: "anthropic/claude-sonnet-4-5",
|
reviewer: "anthropic/claude-sonnet-4-5",
|
||||||
tester: "anthropic/claude-haiku-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. */
|
/** Emoji used in announcements, nested by role. */
|
||||||
@@ -36,6 +42,10 @@ export const LEVEL_EMOJI = {
|
|||||||
reviewer: "🔍",
|
reviewer: "🔍",
|
||||||
tester: "👀",
|
tester: "👀",
|
||||||
},
|
},
|
||||||
|
architect: {
|
||||||
|
opus: "🏗️",
|
||||||
|
sonnet: "📐",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Check if a level belongs to the dev role. */
|
/** 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);
|
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. */
|
/** 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 (isDevLevel(level)) return "dev";
|
||||||
if (isQaLevel(level)) return "qa";
|
if (isQaLevel(level)) return "qa";
|
||||||
|
if (isArchitectLevel(level)) return "architect";
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All valid worker roles. */
|
||||||
|
export type WorkerRole = "dev" | "qa" | "architect";
|
||||||
|
|
||||||
/** Get the default model for a role + level. */
|
/** 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];
|
return (DEFAULT_MODELS[role] as Record<string, string>)[level];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the emoji for a role + 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];
|
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)
|
* 3. Passthrough (treat as raw model ID)
|
||||||
*/
|
*/
|
||||||
export function resolveModel(
|
export function resolveModel(
|
||||||
role: "dev" | "qa",
|
role: WorkerRole,
|
||||||
level: string,
|
level: string,
|
||||||
pluginConfig?: Record<string, unknown>,
|
pluginConfig?: Record<string, unknown>,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
105
lib/tools/design-task.test.ts
Normal file
105
lib/tools/design-task.test.ts
Normal 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
186
lib/tools/design-task.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export function createHealthTool() {
|
|||||||
if (!project) continue;
|
if (!project) continue;
|
||||||
const { provider } = await resolveProvider(project);
|
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)
|
// Worker health check (session liveness, label consistency, etc)
|
||||||
const healthFixes = await checkWorkerHealth({
|
const healthFixes = await checkWorkerHealth({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
|
|||||||
import { resolveRepoPath } from "../projects.js";
|
import { resolveRepoPath } from "../projects.js";
|
||||||
import { createProvider } from "../providers/index.js";
|
import { createProvider } from "../providers/index.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { DEV_LEVELS, QA_LEVELS } from "../tiers.js";
|
import { DEV_LEVELS, QA_LEVELS, ARCHITECT_LEVELS } from "../tiers.js";
|
||||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js";
|
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scaffold project-specific prompt files.
|
* Scaffold project-specific prompt files.
|
||||||
@@ -43,6 +43,14 @@ async function scaffoldPromptFiles(workspaceDir: string, projectName: string): P
|
|||||||
created = true;
|
created = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectArchitect = path.join(projectDir, "architect.md");
|
||||||
|
try {
|
||||||
|
await fs.access(projectArchitect);
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(projectArchitect, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8");
|
||||||
|
created = true;
|
||||||
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +149,7 @@ export function createProjectRegisterTool() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Create all 8 state labels (idempotent)
|
// 4. Create all state labels (idempotent)
|
||||||
await provider.ensureAllStateLabels();
|
await provider.ensureAllStateLabels();
|
||||||
|
|
||||||
// 5. Add project to projects.json
|
// 5. Add project to projects.json
|
||||||
@@ -156,6 +164,7 @@ export function createProjectRegisterTool() {
|
|||||||
roleExecution,
|
roleExecution,
|
||||||
dev: emptyWorkerState([...DEV_LEVELS]),
|
dev: emptyWorkerState([...DEV_LEVELS]),
|
||||||
qa: emptyWorkerState([...QA_LEVELS]),
|
qa: emptyWorkerState([...QA_LEVELS]),
|
||||||
|
architect: emptyWorkerState([...ARCHITECT_LEVELS]),
|
||||||
};
|
};
|
||||||
|
|
||||||
await writeProjects(workspaceDir, data);
|
await writeProjects(workspaceDir, data);
|
||||||
@@ -184,7 +193,7 @@ export function createProjectRegisterTool() {
|
|||||||
repo,
|
repo,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
deployBranch,
|
deployBranch,
|
||||||
labelsCreated: 8,
|
labelsCreated: 10,
|
||||||
promptsScaffolded: promptsCreated,
|
promptsScaffolded: promptsCreated,
|
||||||
announcement,
|
announcement,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
|||||||
level: project.qa.level,
|
level: project.qa.level,
|
||||||
startTime: project.qa.startTime,
|
startTime: project.qa.startTime,
|
||||||
},
|
},
|
||||||
|
architect: {
|
||||||
|
active: project.architect.active,
|
||||||
|
issueId: project.architect.issueId,
|
||||||
|
level: project.architect.level,
|
||||||
|
startTime: project.architect.startTime,
|
||||||
|
},
|
||||||
queue: queueCounts,
|
queue: queueCounts,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["role", "result", "projectGroupId"],
|
required: ["role", "result", "projectGroupId"],
|
||||||
properties: {
|
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" },
|
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
||||||
projectGroupId: { type: "string", description: "Project group ID" },
|
projectGroupId: { type: "string", description: "Project group ID" },
|
||||||
summary: { type: "string", description: "Brief summary" },
|
summary: { type: "string", description: "Brief summary" },
|
||||||
@@ -30,7 +30,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const role = params.role as "dev" | "qa";
|
const role = params.role as "dev" | "qa" | "architect";
|
||||||
const result = params.result as string;
|
const result = params.result as string;
|
||||||
const groupId = params.projectGroupId as string;
|
const groupId = params.projectGroupId as string;
|
||||||
const summary = params.summary as string | undefined;
|
const summary = params.summary as string | undefined;
|
||||||
@@ -40,6 +40,8 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
// Validate role:result
|
// Validate role:result
|
||||||
if (role === "dev" && result !== "done" && result !== "blocked")
|
if (role === "dev" && result !== "done" && result !== "blocked")
|
||||||
throw new Error(`DEV can only complete with "done" or "blocked", got "${result}"`);
|
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")
|
if (role === "qa" && result === "done")
|
||||||
throw new Error(`QA cannot use "done". Use "pass", "fail", "refine", or "blocked".`);
|
throw new Error(`QA cannot use "done". Use "pass", "fail", "refine", or "blocked".`);
|
||||||
if (!getRule(role, result))
|
if (!getRule(role, result))
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
properties: {
|
properties: {
|
||||||
projectGroupId: { type: "string", description: "Project group ID." },
|
projectGroupId: { type: "string", description: "Project group ID." },
|
||||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
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." },
|
level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const issueIdParam = params.issueId as number | undefined;
|
const issueIdParam = params.issueId as number | undefined;
|
||||||
const roleParam = params.role as "dev" | "qa" | undefined;
|
const roleParam = params.role as "dev" | "qa" | "architect" | undefined;
|
||||||
const groupId = params.projectGroupId as string;
|
const groupId = params.projectGroupId as string;
|
||||||
const levelParam = (params.level ?? params.tier) as string | undefined;
|
const levelParam = (params.level ?? params.tier) as string | undefined;
|
||||||
const workspaceDir = requireWorkspaceDir(ctx);
|
const workspaceDir = requireWorkspaceDir(ctx);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import path from "node:path";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type StateType = "queue" | "active" | "hold" | "terminal";
|
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 TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
|
||||||
|
|
||||||
export type TransitionTarget = string | {
|
export type TransitionTarget = string | {
|
||||||
@@ -118,6 +118,24 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
|||||||
label: "Done",
|
label: "Done",
|
||||||
color: "#5cb85c",
|
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:fail": "FAIL",
|
||||||
"qa:refine": "REFINE",
|
"qa:refine": "REFINE",
|
||||||
"qa:blocked": "BLOCKED",
|
"qa:blocked": "BLOCKED",
|
||||||
|
"architect:done": "COMPLETE",
|
||||||
|
"architect:blocked": "BLOCKED",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,6 +389,8 @@ export function getCompletionEmoji(role: Role, result: string): string {
|
|||||||
"qa:refine": "🤔",
|
"qa:refine": "🤔",
|
||||||
"dev:blocked": "🚫",
|
"dev:blocked": "🚫",
|
||||||
"qa:blocked": "🚫",
|
"qa:blocked": "🚫",
|
||||||
|
"architect:done": "🏗️",
|
||||||
|
"architect:blocked": "🚫",
|
||||||
};
|
};
|
||||||
return map[`${role}:${result}`] ?? "📋";
|
return map[`${role}:${result}`] ?? "📋";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user