Merge upstream/main with Gitea support preserved
- design_task → research_task (breaking change) - QA role → Tester role (renamed) - Auto-merge for approved PRs - Enhanced workspace scaffolding - Preserved Gitea provider support (github|gitlab|gitea) - Preserved business hours scheduling (stashed)
This commit is contained in:
@@ -102,7 +102,7 @@ export function createAutoConfigureModelsTool(api: OpenClawPluginApi) {
|
||||
if (modelCount === 1) {
|
||||
message += "ℹ️ Only one authenticated model found — assigned to all roles.";
|
||||
} else {
|
||||
message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → medior/reviewer, Tier 3 → junior/tester).";
|
||||
message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → mid, Tier 3 → junior).";
|
||||
}
|
||||
|
||||
if (preferProvider) {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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" });
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { readProjects, getProject } from "../projects.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js";
|
||||
import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js";
|
||||
import { getAllRoleIds } from "../roles/index.js";
|
||||
|
||||
export function createHealthTool() {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -52,13 +51,13 @@ export function createHealthTool() {
|
||||
if (!project) continue;
|
||||
const { provider } = await resolveProvider(project);
|
||||
|
||||
for (const role of getAllRoleIds()) {
|
||||
for (const role of Object.keys(project.workers)) {
|
||||
// Worker health check (session liveness, label consistency, etc)
|
||||
const healthFixes = await checkWorkerHealth({
|
||||
workspaceDir,
|
||||
groupId: pid,
|
||||
project,
|
||||
role: role as any,
|
||||
role,
|
||||
sessions,
|
||||
autoFix: fix,
|
||||
provider,
|
||||
@@ -70,7 +69,7 @@ export function createHealthTool() {
|
||||
workspaceDir,
|
||||
groupId: pid,
|
||||
project,
|
||||
role: role as any,
|
||||
role,
|
||||
autoFix: fix,
|
||||
provider,
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export function createOnboardTool(api: OpenClawPluginApi) {
|
||||
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
||||
: configured && hasWorkspace ? "reconfigure" : "first-run";
|
||||
|
||||
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
||||
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext();
|
||||
|
||||
return jsonResult({
|
||||
success: true, mode, configured, instructions,
|
||||
|
||||
@@ -15,40 +15,29 @@ import { resolveRepoPath } from "../projects.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js";
|
||||
import { ExecutionMode, getRoleLabels } from "../workflow.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
|
||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||
|
||||
/**
|
||||
* Scaffold project-specific prompt files.
|
||||
* Scaffold project-specific prompt files for all registered roles.
|
||||
* Returns true if files were created, false if they already existed.
|
||||
*/
|
||||
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
||||
const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const promptsDir = path.join(workspaceDir, DATA_DIR, "projects", projectName, "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
|
||||
const projectDev = path.join(projectDir, "dev.md");
|
||||
const projectQa = path.join(projectDir, "qa.md");
|
||||
let created = false;
|
||||
|
||||
try {
|
||||
await fs.access(projectDev);
|
||||
} catch {
|
||||
await fs.writeFile(projectDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
|
||||
created = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(projectQa);
|
||||
} catch {
|
||||
await fs.writeFile(projectQa, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
||||
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;
|
||||
for (const role of getAllRoleIds()) {
|
||||
const filePath = path.join(promptsDir, `${role}.md`);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`;
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
created = true;
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
@@ -97,7 +86,7 @@ export function createProjectRegisterTool() {
|
||||
},
|
||||
roleExecution: {
|
||||
type: "string",
|
||||
enum: ["parallel", "sequential"],
|
||||
enum: Object.values(ExecutionMode),
|
||||
description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.",
|
||||
},
|
||||
},
|
||||
@@ -112,7 +101,7 @@ export function createProjectRegisterTool() {
|
||||
const baseBranch = params.baseBranch as string;
|
||||
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
||||
const deployUrl = (params.deployUrl as string) ?? "";
|
||||
const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel";
|
||||
const roleExecution = (params.roleExecution as ExecutionMode) ?? ExecutionMode.PARALLEL;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
@@ -122,7 +111,8 @@ export function createProjectRegisterTool() {
|
||||
// 1. Check project not already registered (allow re-register if incomplete)
|
||||
const data = await readProjects(workspaceDir);
|
||||
const existing = data.projects[groupId];
|
||||
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
|
||||
const existingWorkers = existing?.workers ?? {};
|
||||
if (existing && Object.values(existingWorkers).some(w => w.sessions && Object.keys(w.sessions).length > 0)) {
|
||||
throw new Error(
|
||||
`Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`,
|
||||
);
|
||||
@@ -162,7 +152,20 @@ export function createProjectRegisterTool() {
|
||||
// 4. Create all state labels (idempotent)
|
||||
await provider.ensureAllStateLabels();
|
||||
|
||||
// 4b. Create role:level + step routing labels (e.g. developer:junior, review:human, test:skip)
|
||||
const resolvedConfig = await loadConfig(workspaceDir, name);
|
||||
const roleLabels = getRoleLabels(resolvedConfig.roles);
|
||||
for (const { name: labelName, color } of roleLabels) {
|
||||
await provider.ensureLabel(labelName, color);
|
||||
}
|
||||
|
||||
// 5. Add project to projects.json
|
||||
// Build workers map from all registered roles
|
||||
const workers: Record<string, import("../projects.js").WorkerState> = {};
|
||||
for (const role of getAllRoleIds()) {
|
||||
workers[role] = emptyWorkerState([...getLevelsForRole(role)]);
|
||||
}
|
||||
|
||||
data.projects[groupId] = {
|
||||
name,
|
||||
repo,
|
||||
@@ -173,9 +176,7 @@ export function createProjectRegisterTool() {
|
||||
channel,
|
||||
provider: providerType,
|
||||
roleExecution,
|
||||
dev: emptyWorkerState([...getLevelsForRole("dev")]),
|
||||
qa: emptyWorkerState([...getLevelsForRole("qa")]),
|
||||
architect: emptyWorkerState([...getLevelsForRole("architect")]),
|
||||
workers,
|
||||
};
|
||||
|
||||
await writeProjects(workspaceDir, data);
|
||||
|
||||
@@ -15,18 +15,18 @@ describe("status execution-aware sequencing", () => {
|
||||
});
|
||||
|
||||
describe("role assignment", () => {
|
||||
it("should assign To Improve to dev", () => {
|
||||
// To Improve = dev work
|
||||
it("should assign To Improve to developer", () => {
|
||||
// To Improve = developer work
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("should assign To Do to dev", () => {
|
||||
// To Do = dev work
|
||||
it("should assign To Do to developer", () => {
|
||||
// To Do = developer work
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("should assign To Test to qa", () => {
|
||||
// To Test = qa work
|
||||
it("should assign To Test to tester", () => {
|
||||
// To Test = tester work
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
@@ -43,12 +43,12 @@ describe("status execution-aware sequencing", () => {
|
||||
});
|
||||
|
||||
it("should support parallel role execution within project", () => {
|
||||
// DEV and QA can run simultaneously
|
||||
// Developer and Tester can run simultaneously
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("should support sequential role execution within project", () => {
|
||||
// DEV and QA alternate
|
||||
// Developer and Tester alternate
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
|
||||
104
lib/tools/research-task.test.ts
Normal file
104
lib/tools/research-task.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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";
|
||||
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, getCompletionRule,
|
||||
getCompletionEmoji, getStateLabels, hasWorkflowStates,
|
||||
} from "../workflow.js";
|
||||
|
||||
describe("architect tiers", () => {
|
||||
it("should recognize architect levels", () => {
|
||||
assert.strictEqual(isLevelForRole("junior", "architect"), true);
|
||||
assert.strictEqual(isLevelForRole("senior", "architect"), true);
|
||||
assert.strictEqual(isLevelForRole("medior", "architect"), false);
|
||||
});
|
||||
|
||||
it("should map architect levels to role", () => {
|
||||
// "junior" and "senior" appear in developer first (registry order), so roleForLevel returns "developer"
|
||||
// This is expected — use isLevelForRole for role-specific checks
|
||||
assert.strictEqual(roleForLevel("junior"), "developer");
|
||||
assert.strictEqual(roleForLevel("senior"), "developer");
|
||||
});
|
||||
|
||||
it("should resolve default architect models", () => {
|
||||
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
|
||||
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("should resolve architect model from resolved role config", () => {
|
||||
const resolvedRole = { models: { senior: "custom/model" }, levels: ["junior", "senior"], defaultLevel: "junior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||
assert.strictEqual(resolveModel("architect", "senior", resolvedRole), "custom/model");
|
||||
});
|
||||
|
||||
it("should have architect emoji", () => {
|
||||
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
|
||||
assert.strictEqual(getEmoji("architect", "junior"), "📐");
|
||||
});
|
||||
});
|
||||
|
||||
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"), "To Design should not exist");
|
||||
assert.ok(!labels.includes("Designing"), "Designing should not exist");
|
||||
});
|
||||
|
||||
it("should have no queue labels for architect", () => {
|
||||
const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect");
|
||||
assert.deepStrictEqual(queues, []);
|
||||
});
|
||||
|
||||
it("should report architect has no workflow states", () => {
|
||||
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "architect"), false);
|
||||
});
|
||||
|
||||
it("should report developer has workflow states", () => {
|
||||
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "developer"), true);
|
||||
});
|
||||
|
||||
it("should report tester has workflow states", () => {
|
||||
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "tester"), true);
|
||||
});
|
||||
|
||||
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 still have completion emoji for architect results", () => {
|
||||
assert.strictEqual(getCompletionEmoji("architect", "done"), "✅");
|
||||
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect model selection", () => {
|
||||
it("should select junior for standard design tasks", () => {
|
||||
const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect");
|
||||
assert.strictEqual(result.level, "junior");
|
||||
});
|
||||
|
||||
it("should select senior for complex design tasks", () => {
|
||||
const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect");
|
||||
assert.strictEqual(result.level, "senior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect session key parsing", () => {
|
||||
it("should parse architect session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-senior");
|
||||
assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" });
|
||||
});
|
||||
|
||||
it("should parse architect junior session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-junior");
|
||||
assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" });
|
||||
});
|
||||
});
|
||||
190
lib/tools/research-task.ts
Normal file
190
lib/tools/research-task.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* research_task — Spawn an architect to research a design/architecture problem.
|
||||
*
|
||||
* 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";
|
||||
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 { loadConfig } from "../config/index.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { resolveModel } from "../roles/index.js";
|
||||
|
||||
/** Planning label — architect issues go directly here. */
|
||||
const PLANNING_LABEL = "Planning";
|
||||
|
||||
export function createResearchTaskTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
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. 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:
|
||||
research_task({
|
||||
projectGroupId: "-5176490302",
|
||||
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", "description"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Project group ID",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Research title (e.g., 'Research: Session persistence strategy')",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
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",
|
||||
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 → junior, complex → senior. 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");
|
||||
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);
|
||||
const role = "architect";
|
||||
|
||||
// Build issue body with rich context
|
||||
const bodyParts = [
|
||||
"## Background",
|
||||
"",
|
||||
description,
|
||||
];
|
||||
if (focusAreas.length > 0) {
|
||||
bodyParts.push("", "## Focus Areas", ...focusAreas.map(a => `- ${a}`));
|
||||
}
|
||||
const issueBody = bodyParts.join("\n");
|
||||
|
||||
// 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, "research_task", {
|
||||
project: project.name, groupId, issueId: issue.iid,
|
||||
title, complexity, focusAreas, dryRun,
|
||||
});
|
||||
|
||||
// Select level: use complexity hint to guide the heuristic
|
||||
const level = complexity === "complex"
|
||||
? selectLevel(title, "system-wide " + description, role).level
|
||||
: selectLevel(title, description, role).level;
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
const resolvedRole = resolvedConfig.roles[role];
|
||||
const model = resolveModel(role, level, resolvedRole);
|
||||
|
||||
if (dryRun) {
|
||||
return jsonResult({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check worker availability
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) {
|
||||
return jsonResult({
|
||||
success: true,
|
||||
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 created in Planning — dispatch manually when architect is free.`,
|
||||
},
|
||||
announcement: `📐 Created research task #${issue.iid}: ${title} (architect busy — issue in Planning)\n🔗 ${issue.web_url}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatch architect directly — issue stays in Planning (no state transition)
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issueBody,
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
level,
|
||||
fromLabel: PLANNING_LABEL,
|
||||
toLabel: PLANNING_LABEL,
|
||||
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: PLANNING_LABEL },
|
||||
design: {
|
||||
sessionKey: dr.sessionKey,
|
||||
level: dr.level,
|
||||
model: dr.model,
|
||||
sessionAction: dr.sessionAction,
|
||||
status: "in_progress",
|
||||
},
|
||||
project: project.name,
|
||||
announcement: dr.announcement,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { runSetup, type SetupOpts } from "../setup/index.js";
|
||||
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js";
|
||||
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { ExecutionMode } from "../workflow.js";
|
||||
|
||||
export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "setup",
|
||||
label: "Setup",
|
||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, devclaw/projects.json, devclaw/prompts/, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -36,44 +37,22 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
models: {
|
||||
type: "object",
|
||||
description: "Model overrides per role and level.",
|
||||
properties: {
|
||||
dev: {
|
||||
properties: Object.fromEntries(
|
||||
getAllRoleIds().map((role) => [role, {
|
||||
type: "object",
|
||||
description: "Developer level models",
|
||||
properties: {
|
||||
junior: {
|
||||
description: `${role.toUpperCase()} level models`,
|
||||
properties: Object.fromEntries(
|
||||
getLevelsForRole(role).map((level) => [level, {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.dev.junior}`,
|
||||
},
|
||||
medior: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.dev.medior}`,
|
||||
},
|
||||
senior: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.dev.senior}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
qa: {
|
||||
type: "object",
|
||||
description: "QA level models",
|
||||
properties: {
|
||||
reviewer: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.qa.reviewer}`,
|
||||
},
|
||||
tester: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.qa.tester}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`,
|
||||
}]),
|
||||
),
|
||||
}]),
|
||||
),
|
||||
},
|
||||
projectExecution: {
|
||||
type: "string",
|
||||
enum: ["parallel", "sequential"],
|
||||
enum: Object.values(ExecutionMode),
|
||||
description: "Project execution mode. Default: parallel.",
|
||||
},
|
||||
},
|
||||
@@ -90,8 +69,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||
models: params.models as SetupOpts["models"],
|
||||
projectExecution: params.projectExecution as
|
||||
| "parallel"
|
||||
| "sequential"
|
||||
| ExecutionMode
|
||||
| undefined,
|
||||
});
|
||||
|
||||
@@ -107,12 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"Models:",
|
||||
...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`),
|
||||
...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`),
|
||||
"",
|
||||
);
|
||||
lines.push("Models:");
|
||||
for (const [role, levels] of Object.entries(result.models)) {
|
||||
for (const [level, model] of Object.entries(levels)) {
|
||||
lines.push(` ${role}.${level}: ${model}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`));
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
|
||||
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
|
||||
import { DEFAULT_WORKFLOW } from "../workflow.js";
|
||||
import { loadWorkflow, ExecutionMode } from "../workflow.js";
|
||||
|
||||
export function createStatusTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -30,10 +30,10 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
||||
const groupId = params.projectGroupId as string | undefined;
|
||||
|
||||
const pluginConfig = getPluginConfig(api);
|
||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
|
||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
|
||||
|
||||
// TODO: Load per-project workflow when supported
|
||||
const workflow = DEFAULT_WORKFLOW;
|
||||
// Load workspace-level workflow (per-project loaded inside map)
|
||||
const workflow = await loadWorkflow(workspaceDir);
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectIds = groupId ? [groupId] : Object.keys(data.projects);
|
||||
@@ -52,28 +52,22 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
||||
queueCounts[label] = issues.length;
|
||||
}
|
||||
|
||||
// Build dynamic workers summary
|
||||
const workers: Record<string, { active: boolean; issueId: string | null; level: string | null; startTime: string | null }> = {};
|
||||
for (const [role, worker] of Object.entries(project.workers)) {
|
||||
workers[role] = {
|
||||
active: worker.active,
|
||||
issueId: worker.issueId,
|
||||
level: worker.level,
|
||||
startTime: worker.startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: project.name,
|
||||
groupId: pid,
|
||||
roleExecution: project.roleExecution ?? "parallel",
|
||||
dev: {
|
||||
active: project.dev.active,
|
||||
issueId: project.dev.issueId,
|
||||
level: project.dev.level,
|
||||
startTime: project.dev.startTime,
|
||||
},
|
||||
qa: {
|
||||
active: project.qa.active,
|
||||
issueId: project.qa.issueId,
|
||||
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,
|
||||
},
|
||||
roleExecution: project.roleExecution ?? ExecutionMode.PARALLEL,
|
||||
workers,
|
||||
queue: queueCounts,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* task_comment — Add review comments or notes to an issue.
|
||||
*
|
||||
* Use cases:
|
||||
* - QA worker adds review feedback without blocking pass/fail
|
||||
* - DEV worker posts implementation notes
|
||||
* - Tester worker adds review feedback without blocking pass/fail
|
||||
* - Developer worker posts implementation notes
|
||||
* - Orchestrator adds summary comments
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
@@ -11,10 +11,11 @@ import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||
import { getAllRoleIds, getFallbackEmoji } from "../roles/index.js";
|
||||
|
||||
/** Valid author roles for attribution */
|
||||
const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const;
|
||||
type AuthorRole = (typeof AUTHOR_ROLES)[number];
|
||||
/** Valid author roles for attribution — all registry roles + orchestrator */
|
||||
const AUTHOR_ROLES = [...getAllRoleIds(), "orchestrator"];
|
||||
type AuthorRole = string;
|
||||
|
||||
export function createTaskCommentTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -23,15 +24,15 @@ export function createTaskCommentTool(api: OpenClawPluginApi) {
|
||||
description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change.
|
||||
|
||||
Use cases:
|
||||
- QA adds review feedback without blocking pass/fail
|
||||
- DEV posts implementation notes or progress updates
|
||||
- Tester adds review feedback without blocking pass/fail
|
||||
- Developer posts implementation notes or progress updates
|
||||
- Orchestrator adds summary comments
|
||||
- Cross-referencing related issues or PRs
|
||||
|
||||
Examples:
|
||||
- Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" }
|
||||
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "qa" }
|
||||
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "dev" }`,
|
||||
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "tester" }
|
||||
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "developer" }`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "issueId", "body"],
|
||||
@@ -73,7 +74,7 @@ Examples:
|
||||
const issue = await provider.getIssue(issueId);
|
||||
|
||||
const commentBody = authorRole
|
||||
? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}`
|
||||
? `${getRoleEmoji(authorRole)} **${authorRole.toUpperCase()}**: ${body}`
|
||||
: body;
|
||||
|
||||
await provider.addComment(issueId, commentBody);
|
||||
@@ -99,8 +100,7 @@ Examples:
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROLE_EMOJI: Record<AuthorRole, string> = {
|
||||
dev: "👨💻",
|
||||
qa: "🔍",
|
||||
orchestrator: "🎛️",
|
||||
};
|
||||
function getRoleEmoji(role: string): string {
|
||||
if (role === "orchestrator") return "🎛️";
|
||||
return getFallbackEmoji(role);
|
||||
}
|
||||
|
||||
@@ -13,19 +13,23 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||
|
||||
/** Derive the initial state label from the workflow config. */
|
||||
const INITIAL_LABEL = DEFAULT_WORKFLOW.states[DEFAULT_WORKFLOW.initial].label;
|
||||
|
||||
export function createTaskCreateTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_create",
|
||||
label: "Task Create",
|
||||
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
|
||||
|
||||
**IMPORTANT:** Always creates in "Planning" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "Planning" issues require human review before entering the queue.
|
||||
**IMPORTANT:** Always creates in "${INITIAL_LABEL}" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "${INITIAL_LABEL}" issues require human review before entering the queue.
|
||||
|
||||
Examples:
|
||||
- Default: { title: "Fix login bug" } → created in Planning
|
||||
- Default: { title: "Fix login bug" } → created in ${INITIAL_LABEL}
|
||||
- User says "create and start working": { title: "Implement auth", description: "...", label: "To Do" }`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
@@ -45,8 +49,8 @@ Examples:
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
description: `State label. Defaults to "Planning" — only use "To Do" when the user explicitly asks to start work immediately.`,
|
||||
enum: STATE_LABELS,
|
||||
description: `State label. Defaults to "${INITIAL_LABEL}" — only use "To Do" when the user explicitly asks to start work immediately.`,
|
||||
enum: getStateLabels(DEFAULT_WORKFLOW),
|
||||
},
|
||||
assignees: {
|
||||
type: "array",
|
||||
@@ -64,7 +68,7 @@ Examples:
|
||||
const groupId = params.projectGroupId as string;
|
||||
const title = params.title as string;
|
||||
const description = (params.description as string) ?? "";
|
||||
const label = (params.label as StateLabel) ?? "Planning";
|
||||
const label = (params.label as StateLabel) ?? INITIAL_LABEL;
|
||||
const assignees = (params.assignees as string[] | undefined) ?? [];
|
||||
const pickup = (params.pickup as boolean) ?? false;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
@@ -1,63 +1,133 @@
|
||||
/**
|
||||
* Integration test for task_update tool.
|
||||
* Tests for task_update tool — state transitions and level overrides.
|
||||
*
|
||||
* Run manually: node --loader ts-node/esm lib/tools/task-update.test.ts
|
||||
* Run: npx tsx --test lib/tools/task-update.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels, ReviewPolicy, resolveReviewRouting } from "../workflow.js";
|
||||
import { detectLevelFromLabels, detectRoleLevelFromLabels, detectStepRouting } from "../services/queue-scan.js";
|
||||
|
||||
describe("task_update tool", () => {
|
||||
it("has correct schema", () => {
|
||||
// Verify the tool signature matches requirements
|
||||
const requiredParams = ["projectGroupId", "issueId", "state"];
|
||||
const optionalParams = ["reason"];
|
||||
|
||||
// Schema validation would go here in a real test
|
||||
assert.ok(true, "Schema structure is valid");
|
||||
// state is now optional — at least one of state or level required
|
||||
const requiredParams = ["projectGroupId", "issueId"];
|
||||
assert.strictEqual(requiredParams.length, 2);
|
||||
});
|
||||
|
||||
it("supports all state labels", () => {
|
||||
const validStates = [
|
||||
"Planning",
|
||||
"To Do",
|
||||
"Doing",
|
||||
"To Test",
|
||||
"Testing",
|
||||
"Done",
|
||||
"To Improve",
|
||||
"Refining",
|
||||
];
|
||||
|
||||
// In a real test, we'd verify these against the tool's enum
|
||||
assert.strictEqual(validStates.length, 8);
|
||||
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
||||
assert.strictEqual(labels.length, 10);
|
||||
assert.ok(labels.includes("Planning"));
|
||||
assert.ok(labels.includes("Done"));
|
||||
assert.ok(labels.includes("To Review"));
|
||||
});
|
||||
|
||||
it("validates required parameters", () => {
|
||||
// Test cases:
|
||||
// - Missing projectGroupId → Error
|
||||
// - Missing issueId → Error
|
||||
// - Missing state → Error
|
||||
// - Invalid state → Error
|
||||
// - Valid params → Success
|
||||
// At least one of state or level required
|
||||
assert.ok(true, "Parameter validation works");
|
||||
});
|
||||
|
||||
it("handles same-state transitions gracefully", () => {
|
||||
// When current state === new state, should return success without changes
|
||||
assert.ok(true, "No-op transitions handled correctly");
|
||||
});
|
||||
|
||||
it("logs to audit trail", () => {
|
||||
// Verify auditLog is called with correct parameters
|
||||
assert.ok(true, "Audit logging works");
|
||||
});
|
||||
});
|
||||
|
||||
// Test scenarios for manual verification:
|
||||
// 1. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning" })
|
||||
// → Should transition from "To Do" to "Planning"
|
||||
// 2. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning", reason: "Needs more discussion" })
|
||||
// → Should log reason in audit trail
|
||||
// 3. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "To Do" })
|
||||
// → Should transition back from "Planning" to "To Do"
|
||||
describe("detectLevelFromLabels — colon format", () => {
|
||||
it("should detect level from colon-format labels", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["developer:senior", "Doing"]), "senior");
|
||||
assert.strictEqual(detectLevelFromLabels(["tester:junior", "Testing"]), "junior");
|
||||
assert.strictEqual(detectLevelFromLabels(["reviewer:medior", "Reviewing"]), "medior");
|
||||
});
|
||||
|
||||
it("should prioritize colon format over dot format", () => {
|
||||
// Colon format should win since it's checked first
|
||||
assert.strictEqual(detectLevelFromLabels(["developer:senior", "dev.junior"]), "senior");
|
||||
});
|
||||
|
||||
it("should fall back to dot format", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["developer.senior", "Doing"]), "senior");
|
||||
});
|
||||
|
||||
it("should fall back to plain level name", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["senior", "Doing"]), "senior");
|
||||
});
|
||||
|
||||
it("should return null when no level found", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["Doing", "bug"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectRoleLevelFromLabels", () => {
|
||||
it("should detect role and level from colon-format labels", () => {
|
||||
const result = detectRoleLevelFromLabels(["developer:senior", "Doing"]);
|
||||
assert.deepStrictEqual(result, { role: "developer", level: "senior" });
|
||||
});
|
||||
|
||||
it("should detect tester role", () => {
|
||||
const result = detectRoleLevelFromLabels(["tester:medior", "Testing"]);
|
||||
assert.deepStrictEqual(result, { role: "tester", level: "medior" });
|
||||
});
|
||||
|
||||
it("should return null for step routing labels", () => {
|
||||
// review:human is a step routing label, not a role:level label
|
||||
const result = detectRoleLevelFromLabels(["review:human", "Doing"]);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it("should return null when no colon labels present", () => {
|
||||
assert.strictEqual(detectRoleLevelFromLabels(["Doing", "bug"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectStepRouting", () => {
|
||||
it("should detect review:human", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:human", "Doing"], "review"), "human");
|
||||
});
|
||||
|
||||
it("should detect review:agent", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:agent", "To Review"], "review"), "agent");
|
||||
});
|
||||
|
||||
it("should detect review:skip", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:skip", "To Review"], "review"), "skip");
|
||||
});
|
||||
|
||||
it("should detect test:skip", () => {
|
||||
assert.strictEqual(detectStepRouting(["test:skip", "To Test"], "test"), "skip");
|
||||
});
|
||||
|
||||
it("should return null when no matching step label", () => {
|
||||
assert.strictEqual(detectStepRouting(["developer:senior", "Doing"], "review"), null);
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
assert.strictEqual(detectStepRouting(["Review:Human", "Doing"], "review"), "human");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReviewRouting", () => {
|
||||
it("should return review:human for HUMAN policy", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "junior"), "review:human");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "senior"), "review:human");
|
||||
});
|
||||
|
||||
it("should return review:agent for AGENT policy", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "junior"), "review:agent");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "senior"), "review:agent");
|
||||
});
|
||||
|
||||
it("should return review:human for AUTO + senior", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "senior"), "review:human");
|
||||
});
|
||||
|
||||
it("should return review:agent for AUTO + non-senior", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "junior"), "review:agent");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "medior"), "review:agent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,27 +10,31 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels, findStateByLabel } from "../workflow.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||
|
||||
export function createTaskUpdateTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_update",
|
||||
label: "Task Update",
|
||||
description: `Change issue state programmatically. Use this when you need to update an issue's status without going through the full pickup/complete flow.
|
||||
description: `Change issue state and/or role:level assignment. Use this when you need to update an issue's status or override the assigned level.
|
||||
|
||||
Use cases:
|
||||
- Orchestrator or worker needs to change state manually
|
||||
- Manual status adjustments (e.g., Planning → To Do after approval)
|
||||
- Override the assigned level (e.g., escalate to senior for human review)
|
||||
- Force human review via level change
|
||||
- Failed auto-transitions that need correction
|
||||
- Bulk state changes
|
||||
|
||||
Examples:
|
||||
- Simple: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
||||
- With reason: { projectGroupId: "-123456789", issueId: 42, state: "To Do", reason: "Approved for development" }`,
|
||||
- State only: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
||||
- Level only: { projectGroupId: "-123456789", issueId: 42, level: "senior" }
|
||||
- Both: { projectGroupId: "-123456789", issueId: 42, state: "To Do", level: "senior", reason: "Escalating to senior" }`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "issueId", "state"],
|
||||
required: ["projectGroupId", "issueId"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
@@ -42,12 +46,16 @@ Examples:
|
||||
},
|
||||
state: {
|
||||
type: "string",
|
||||
enum: STATE_LABELS,
|
||||
description: `New state for the issue. One of: ${STATE_LABELS.join(", ")}`,
|
||||
enum: getStateLabels(DEFAULT_WORKFLOW),
|
||||
description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`,
|
||||
},
|
||||
level: {
|
||||
type: "string",
|
||||
description: "Override the role:level assignment (e.g., 'senior', 'junior'). Detects role from current state label.",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Optional audit log reason for the state change",
|
||||
description: "Optional audit log reason for the change",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,41 +63,86 @@ Examples:
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const issueId = params.issueId as number;
|
||||
const newState = params.state as StateLabel;
|
||||
const newState = (params.state as StateLabel) ?? undefined;
|
||||
const newLevel = (params.level as string) ?? undefined;
|
||||
const reason = (params.reason as string) ?? undefined;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
if (!newState && !newLevel) {
|
||||
throw new Error("At least one of 'state' or 'level' must be provided.");
|
||||
}
|
||||
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
const { provider, type: providerType } = await resolveProvider(project);
|
||||
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const currentState = provider.getCurrentStateLabel(issue);
|
||||
if (!currentState) {
|
||||
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`);
|
||||
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform update.`);
|
||||
}
|
||||
|
||||
if (currentState === newState) {
|
||||
return jsonResult({
|
||||
success: true, issueId, state: newState, changed: false,
|
||||
message: `Issue #${issueId} is already in state "${newState}".`,
|
||||
project: project.name, provider: providerType,
|
||||
});
|
||||
let stateChanged = false;
|
||||
let levelChanged = false;
|
||||
let fromLevel: string | undefined;
|
||||
|
||||
// Handle state transition
|
||||
if (newState && currentState !== newState) {
|
||||
await provider.transitionLabel(issueId, currentState, newState);
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
await provider.transitionLabel(issueId, currentState, newState);
|
||||
// Handle level override
|
||||
if (newLevel) {
|
||||
// Detect role from current (or new) state label
|
||||
const effectiveState = newState ?? currentState;
|
||||
const workflow = (await loadConfig(workspaceDir, project.name)).workflow;
|
||||
const stateConfig = findStateByLabel(workflow, effectiveState);
|
||||
const role = stateConfig?.role;
|
||||
if (!role) {
|
||||
throw new Error(`Cannot determine role from state "${effectiveState}". Level can only be set on role-assigned states.`);
|
||||
}
|
||||
|
||||
// Validate level exists for role
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
const roleConfig = resolvedConfig.roles[role];
|
||||
if (!roleConfig || !roleConfig.levels.includes(newLevel)) {
|
||||
throw new Error(`Invalid level "${newLevel}" for role "${role}". Valid levels: ${roleConfig?.levels.join(", ") ?? "none"}`);
|
||||
}
|
||||
|
||||
// Remove old role:* labels, add new role:level
|
||||
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
|
||||
fromLevel = oldRoleLabels[0]?.split(":")[1];
|
||||
if (oldRoleLabels.length > 0) {
|
||||
await provider.removeLabels(issueId, oldRoleLabels);
|
||||
}
|
||||
await provider.addLabel(issueId, `${role}:${newLevel}`);
|
||||
levelChanged = fromLevel !== newLevel;
|
||||
}
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "task_update", {
|
||||
project: project.name, groupId, issueId,
|
||||
fromState: currentState, toState: newState,
|
||||
...(stateChanged ? { fromState: currentState, toState: newState } : {}),
|
||||
...(levelChanged ? { fromLevel: fromLevel ?? null, toLevel: newLevel } : {}),
|
||||
reason: reason ?? null, provider: providerType,
|
||||
});
|
||||
|
||||
// Build announcement
|
||||
const parts: string[] = [];
|
||||
if (stateChanged) parts.push(`"${currentState}" → "${newState}"`);
|
||||
if (levelChanged) parts.push(`level: ${fromLevel ?? "none"} → ${newLevel}`);
|
||||
const changeDesc = parts.join(", ");
|
||||
|
||||
return jsonResult({
|
||||
success: true, issueId, issueTitle: issue.title,
|
||||
state: newState, changed: true,
|
||||
labelTransition: `${currentState} → ${newState}`,
|
||||
...(newState ? { state: newState } : {}),
|
||||
...(newLevel ? { level: newLevel } : {}),
|
||||
changed: stateChanged || levelChanged,
|
||||
...(stateChanged ? { labelTransition: `${currentState} → ${newState}` } : {}),
|
||||
project: project.name, provider: providerType,
|
||||
announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`,
|
||||
announcement: stateChanged || levelChanged
|
||||
? `🔄 Updated #${issueId}: ${changeDesc}${reason ? ` (${reason})` : ""}`
|
||||
: `Issue #${issueId} is already in the requested state.`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,27 +3,33 @@
|
||||
*
|
||||
* 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 { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.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, 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: DEV done/blocked, QA pass/fail/refine/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"],
|
||||
properties: {
|
||||
role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked", "approve", "reject"], description: "Completion result" },
|
||||
projectGroupId: { type: "string", description: "Project group ID" },
|
||||
summary: { type: "string", description: "Brief summary" },
|
||||
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
|
||||
@@ -31,7 +37,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const role = params.role as "dev" | "qa" | "architect";
|
||||
const role = params.role as string;
|
||||
const result = params.result as string;
|
||||
const groupId = params.projectGroupId as string;
|
||||
const summary = params.summary as string | undefined;
|
||||
@@ -43,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);
|
||||
@@ -55,18 +59,31 @@ 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 workflow = await loadWorkflow(workspaceDir, project.name);
|
||||
|
||||
// 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);
|
||||
|
||||
// Execute completion (pipeline service handles notification with runtime)
|
||||
const completion = await executeCompletion({
|
||||
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
|
||||
projectName: project.name,
|
||||
channel: project.channel,
|
||||
pluginConfig,
|
||||
runtime: api.runtime,
|
||||
workflow,
|
||||
});
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
@@ -74,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,
|
||||
@@ -84,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,11 +12,10 @@ import type { StateLabel } from "../providers/provider.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { getWorker } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
||||
import { isDevLevel } from "../tiers.js";
|
||||
import { getAllRoleIds } from "../roles/index.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectRoleLevelFromLabels } from "../services/queue-scan.js";
|
||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
||||
import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js";
|
||||
|
||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -30,13 +29,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
projectGroupId: { type: "string", description: "Project group ID." },
|
||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
||||
role: { type: "string", enum: getAllRoleIds(), 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: "Worker level (junior/mid/senior). 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" | "architect" | undefined;
|
||||
const roleParam = params.role as string | undefined;
|
||||
const groupId = params.projectGroupId as string;
|
||||
const levelParam = (params.level ?? params.tier) as string | undefined;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
@@ -45,8 +44,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
const { provider } = await resolveProvider(project);
|
||||
|
||||
// TODO: Load per-project workflow when supported
|
||||
const workflow = DEFAULT_WORKFLOW;
|
||||
const workflow = await loadWorkflow(workspaceDir, project.name);
|
||||
|
||||
// Find issue
|
||||
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
|
||||
@@ -72,24 +70,27 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
// Check worker availability
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
||||
if ((project.roleExecution ?? "parallel") === "sequential") {
|
||||
const other = role === "dev" ? "qa" : "dev";
|
||||
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
|
||||
if ((project.roleExecution ?? ExecutionMode.PARALLEL) === ExecutionMode.SEQUENTIAL) {
|
||||
for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
|
||||
if (otherRole !== role && otherWorker.active) {
|
||||
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get target label from workflow
|
||||
const targetLabel = getActiveLabel(workflow, role);
|
||||
|
||||
// Select level
|
||||
// Select level: LLM param → own role label → inherit other role label → heuristic
|
||||
let selectedLevel: string, levelReason: string, levelSource: string;
|
||||
if (levelParam) {
|
||||
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
||||
} else {
|
||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||
if (labelLevel) {
|
||||
if (role === "qa" && isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = `QA overrides dev level "${labelLevel}"`; levelSource = "role-override"; }
|
||||
else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; }
|
||||
else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; }
|
||||
const roleLevel = detectRoleLevelFromLabels(issue.labels);
|
||||
if (roleLevel?.role === role) {
|
||||
selectedLevel = roleLevel.level; levelReason = `Label: "${role}:${roleLevel.level}"`; levelSource = "label";
|
||||
} else if (roleLevel && getLevelsForRole(role).includes(roleLevel.level)) {
|
||||
selectedLevel = roleLevel.level; levelReason = `Inherited from ${roleLevel.role}:${roleLevel.level}`; levelSource = "inherited";
|
||||
} else {
|
||||
const s = selectLevel(issue.title, issue.description ?? "", role);
|
||||
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
|
||||
|
||||
Reference in New Issue
Block a user