feat: rename design_task to research_task and update related documentation

This commit is contained in:
Lauren ten Hoor
2026-02-16 18:47:01 +08:00
parent d87b9f68a2
commit b57ede0863
15 changed files with 249 additions and 173 deletions

View File

@@ -1,6 +1,6 @@
/**
* Tests for architect role, design_task tool, and workflow integration.
* Run with: npx tsx --test lib/tools/design-task.test.ts
* Tests for architect role, research_task tool, and workflow integration.
* Run with: npx tsx --test lib/tools/research-task.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert";
@@ -8,8 +8,8 @@ import { parseDevClawSessionKey } from "../bootstrap-hook.js";
import { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js";
import { selectLevel } from "../model-selector.js";
import {
DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule,
getCompletionEmoji, detectRoleFromLabel, getStateLabels,
DEFAULT_WORKFLOW, getQueueLabels, getCompletionRule,
getCompletionEmoji, getStateLabels, hasWorkflowStates,
} from "../workflow.js";
describe("architect tiers", () => {
@@ -42,42 +42,38 @@ describe("architect tiers", () => {
});
});
describe("architect workflow states", () => {
it("should include To Design and Designing in state labels", () => {
describe("architect workflow — no dedicated states", () => {
it("should NOT have To Design or Designing in state labels", () => {
const labels = getStateLabels(DEFAULT_WORKFLOW);
assert.ok(labels.includes("To Design"));
assert.ok(labels.includes("Designing"));
assert.ok(!labels.includes("To Design"), "To Design should not exist");
assert.ok(!labels.includes("Designing"), "Designing should not exist");
});
it("should have To Design as architect queue label", () => {
it("should have no queue labels for architect", () => {
const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect");
assert.deepStrictEqual(queues, ["To Design"]);
assert.deepStrictEqual(queues, []);
});
it("should have Designing as architect active label", () => {
assert.strictEqual(getActiveLabel(DEFAULT_WORKFLOW, "architect"), "Designing");
it("should report architect has no workflow states", () => {
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "architect"), false);
});
it("should detect architect role from To Design label", () => {
assert.strictEqual(detectRoleFromLabel(DEFAULT_WORKFLOW, "To Design"), "architect");
it("should report developer has workflow states", () => {
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "developer"), true);
});
it("should have architect:done completion rule", () => {
const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done");
assert.ok(rule);
assert.strictEqual(rule!.from, "Designing");
assert.strictEqual(rule!.to, "Planning");
it("should report tester has workflow states", () => {
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "tester"), true);
});
it("should have architect:blocked completion rule", () => {
const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked");
assert.ok(rule);
assert.strictEqual(rule!.from, "Designing");
assert.strictEqual(rule!.to, "Refining");
it("should have no completion rules for architect (no active state)", () => {
const doneRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done");
assert.strictEqual(doneRule, null);
const blockedRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked");
assert.strictEqual(blockedRule, null);
});
it("should have completion emoji by result type", () => {
// Emoji is now keyed by result, not role:result
it("should still have completion emoji for architect results", () => {
assert.strictEqual(getCompletionEmoji("architect", "done"), "✅");
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
});

View File

@@ -1,9 +1,11 @@
/**
* design_task Spawn an architect to investigate a design problem.
* research_task Spawn an architect to research a design/architecture problem.
*
* Creates a "To Design" issue and optionally dispatches an architect worker.
* The architect investigates systematically, then produces structured findings
* as a GitHub issue in Planning state.
* Creates a Planning issue with rich context and dispatches an architect worker.
* The architect researches the problem and produces detailed findings as issue comments.
* The issue stays in Planning ready for human review when the architect completes.
*
* No queue states tool-triggered only.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
@@ -13,33 +15,41 @@ import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js";
import { loadConfig } from "../config/index.js";
import { selectLevel } from "../model-selector.js";
import { resolveModel } from "../roles/index.js";
export function createDesignTaskTool(api: OpenClawPluginApi) {
/** Planning label — architect issues go directly here. */
const PLANNING_LABEL = "Planning";
export function createResearchTaskTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
name: "design_task",
label: "Design Task",
description: `Spawn an architect to investigate a design/architecture problem. Creates a "To Design" issue and dispatches an architect worker with persistent session.
name: "research_task",
label: "Research Task",
description: `Spawn an architect to research a design/architecture problem. Creates a Planning issue and dispatches an architect worker.
IMPORTANT: Provide a detailed description with enough background context for the architect
to produce actionable, development-ready findings. Include: current state, constraints,
requirements, relevant code paths, and any prior decisions. The output should be detailed
enough for a developer to start implementation immediately.
The architect will:
1. Investigate the problem systematically
2. Research alternatives (>= 3 options)
3. Produce structured findings with recommendation
4. Complete with work_finish, moving the issue to Planning
1. Research the problem systematically (codebase, docs, web)
2. Investigate >= 3 alternatives with tradeoffs
3. Produce a recommendation with implementation outline
4. Post findings as issue comments, then complete with work_finish
Example:
design_task({
research_task({
projectGroupId: "-5176490302",
title: "Design: Session persistence strategy",
description: "How should sessions be persisted across restarts?",
title: "Research: Session persistence strategy",
description: "Sessions are lost on restart. Current impl uses in-memory Map in session-store.ts. Constraints: must work with SQLite (already a dep), max 50ms latency on read. Prior discussion in #42 ruled out Redis.",
focusAreas: ["SQLite vs file-based", "migration path", "cache invalidation"],
complexity: "complex"
})`,
parameters: {
type: "object",
required: ["projectGroupId", "title"],
required: ["projectGroupId", "title", "description"],
properties: {
projectGroupId: {
type: "string",
@@ -47,11 +57,11 @@ Example:
},
title: {
type: "string",
description: "Design title (e.g., 'Design: Session persistence')",
description: "Research title (e.g., 'Research: Session persistence strategy')",
},
description: {
type: "string",
description: "What are we designing & why? Include context and constraints.",
description: "Detailed background context: what exists today, why this needs investigation, constraints, relevant code paths, prior decisions. Must be detailed enough for the architect to produce development-ready findings.",
},
focusAreas: {
type: "array",
@@ -81,41 +91,28 @@ Example:
if (!groupId) throw new Error("projectGroupId is required");
if (!title) throw new Error("title is required");
if (!description) throw new Error("description is required — provide detailed background context for the architect");
const { project } = await resolveProject(workspaceDir, groupId);
const { provider } = await resolveProvider(project);
const pluginConfig = getPluginConfig(api);
// Derive labels from workflow config
const workflow = await loadWorkflow(workspaceDir, project.name);
const role = "architect";
const queueLabels = getQueueLabels(workflow, role);
const queueLabel = queueLabels[0];
if (!queueLabel) throw new Error(`No queue state found for role "${role}" in workflow`);
// Build issue body with focus areas
const bodyParts = [description];
// Build issue body with rich context
const bodyParts = [
"## Background",
"",
description,
];
if (focusAreas.length > 0) {
bodyParts.push("", "## Focus Areas", ...focusAreas.map(a => `- ${a}`));
}
bodyParts.push(
"", "---",
"", "## Architect Output Template",
"",
"When complete, the architect will produce findings covering:",
"1. **Problem Statement** — Why is this design decision important?",
"2. **Current State** — What exists today? Limitations?",
"3. **Alternatives** (>= 3 options with pros/cons and effort estimates)",
"4. **Recommendation** — Which option and why?",
"5. **Implementation Outline** — What dev tasks are needed?",
"6. **References** — Code, docs, prior art",
);
const issueBody = bodyParts.join("\n");
// Create issue in queue state
const issue = await provider.createIssue(title, issueBody, queueLabel as StateLabel);
// Create issue directly in Planning state (no queue — tool-triggered only)
const issue = await provider.createIssue(title, issueBody, PLANNING_LABEL as StateLabel);
await auditLog(workspaceDir, "design_task", {
await auditLog(workspaceDir, "research_task", {
project: project.name, groupId, issueId: issue.iid,
title, complexity, focusAreas, dryRun,
});
@@ -132,7 +129,7 @@ Example:
return jsonResult({
success: true,
dryRun: true,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel },
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
design: { level, model, status: "dry_run" },
announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
});
@@ -141,22 +138,19 @@ Example:
// Check worker availability
const worker = getWorker(project, role);
if (worker.active) {
// Issue created but can't dispatch yet — will be picked up by heartbeat
return jsonResult({
success: true,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel },
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
design: {
level,
status: "queued",
reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue queued for pickup.`,
reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue created in Planning — dispatch manually when architect is free.`,
},
announcement: `📐 Created design task #${issue.iid}: ${title} (queued — ${role} busy)\n🔗 ${issue.web_url}`,
announcement: `📐 Created research task #${issue.iid}: ${title} (architect busy — issue in Planning)\n🔗 ${issue.web_url}`,
});
}
// Dispatch worker
const targetLabel = getActiveLabel(workflow, role);
// Dispatch architect directly — issue stays in Planning (no state transition)
const dr = await dispatchTask({
workspaceDir,
agentId: ctx.agentId,
@@ -168,8 +162,8 @@ Example:
issueUrl: issue.web_url,
role,
level,
fromLabel: queueLabel,
toLabel: targetLabel,
fromLabel: PLANNING_LABEL,
toLabel: PLANNING_LABEL,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
provider,
pluginConfig,
@@ -180,7 +174,7 @@ Example:
return jsonResult({
success: true,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: targetLabel },
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
design: {
sessionKey: dr.sessionKey,
level: dr.level,

View File

@@ -18,7 +18,7 @@ describe("task_update tool", () => {
it("supports all state labels", () => {
const labels = getStateLabels(DEFAULT_WORKFLOW);
assert.strictEqual(labels.length, 12);
assert.strictEqual(labels.length, 10);
assert.ok(labels.includes("Planning"));
assert.ok(labels.includes("Done"));
assert.ok(labels.includes("To Review"));

View File

@@ -3,22 +3,27 @@
*
* Delegates side-effects to pipeline service: label transition, state update,
* issue close/reopen, notifications, and audit logging.
*
* Roles without workflow states (e.g. architect) are handled inline —
* deactivate worker, optionally transition label, and notify.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { getWorker, resolveRepoPath } from "../projects.js";
import type { StateLabel } from "../providers/provider.js";
import { deactivateWorker, getWorker, resolveRepoPath } from "../projects.js";
import { executeCompletion, getRule } from "../services/pipeline.js";
import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
import { loadWorkflow } from "../workflow.js";
import { loadWorkflow, hasWorkflowStates, getCompletionEmoji } from "../workflow.js";
import { notify, getNotificationConfig } from "../notify.js";
export function createWorkFinishTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
name: "work_finish",
label: "Work Finish",
description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
parameters: {
type: "object",
required: ["role", "result", "projectGroupId"],
@@ -44,8 +49,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
const valid = getCompletionResults(role);
throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`);
}
if (!getRule(role, result))
throw new Error(`Invalid completion: ${role}:${result}`);
// Resolve project + worker
const { project } = await resolveProject(workspaceDir, groupId);
@@ -56,13 +59,24 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
const { provider } = await resolveProvider(project);
const repoPath = resolveRepoPath(project.repo);
const issue = await provider.getIssue(issueId);
const pluginConfig = getPluginConfig(api);
const workflow = await loadWorkflow(workspaceDir, project.name);
// Execute completion (pipeline service handles notification with runtime)
// Roles without workflow states (e.g. architect) — handle inline
if (!hasWorkflowStates(workflow, role)) {
return handleStatelessCompletion({
workspaceDir, groupId, role, result, issueId, summary,
provider, projectName: project.name, channel: project.channel,
pluginConfig: getPluginConfig(api), runtime: api.runtime,
});
}
// Standard pipeline completion for roles with workflow states
if (!getRule(role, result))
throw new Error(`Invalid completion: ${role}:${result}`);
const repoPath = resolveRepoPath(project.repo);
const pluginConfig = getPluginConfig(api);
const completion = await executeCompletion({
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
projectName: project.name,
@@ -77,7 +91,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
...completion,
};
// Audit
await auditLog(workspaceDir, "work_finish", {
project: project.name, groupId, issue: issueId, role, result,
summary: summary ?? null, labelTransition: completion.labelTransition,
@@ -87,3 +100,89 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
},
});
}
/**
* Handle completion for roles without workflow states (e.g. architect).
*
* - done: deactivate worker, issue stays in current state (Planning)
* - blocked: deactivate worker, transition issue to Refining
*/
async function handleStatelessCompletion(opts: {
workspaceDir: string;
groupId: string;
role: string;
result: string;
issueId: number;
summary?: string;
provider: import("../providers/provider.js").IssueProvider;
projectName: string;
channel?: string;
pluginConfig?: Record<string, unknown>;
runtime?: import("openclaw/plugin-sdk").PluginRuntime;
}): Promise<ReturnType<typeof jsonResult>> {
const {
workspaceDir, groupId, role, result, issueId, summary,
provider, projectName, channel, pluginConfig, runtime,
} = opts;
const issue = await provider.getIssue(issueId);
// Deactivate worker
await deactivateWorker(workspaceDir, groupId, role);
// If blocked, transition to Refining
let labelTransition = "none";
if (result === "blocked") {
const currentLabel = provider.getCurrentStateLabel(issue) ?? "Planning";
await provider.transitionLabel(issueId, currentLabel as StateLabel, "Refining" as StateLabel);
labelTransition = `${currentLabel} → Refining`;
}
// Notification
const nextState = result === "blocked" ? "awaiting human decision" : "awaiting human decision";
const notifyConfig = getNotificationConfig(pluginConfig);
notify(
{
type: "workerComplete",
project: projectName,
groupId,
issueId,
issueUrl: issue.web_url,
role,
result: result as "done" | "blocked",
summary,
nextState,
},
{
workspaceDir,
config: notifyConfig,
groupId,
channel: channel ?? "telegram",
runtime,
},
).catch((err) => {
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
});
// Build announcement
const emoji = getCompletionEmoji(role, result);
const label = `${role} ${result}`.toUpperCase();
let announcement = `${emoji} ${label} #${issueId}`;
if (summary) announcement += `${summary}`;
announcement += `\n📋 Issue: ${issue.web_url}`;
if (result === "blocked") announcement += `\nawaiting human decision.`;
// Audit
await auditLog(workspaceDir, "work_finish", {
project: projectName, groupId, issue: issueId, role, result,
summary: summary ?? null, labelTransition,
});
return jsonResult({
success: true, project: projectName, groupId, issueId, role, result,
labelTransition,
announcement,
nextState,
issueUrl: issue.web_url,
});
}