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:
2026-02-16 11:27:16 +00:00
74 changed files with 7062 additions and 2153 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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