refactor: migrate role handling from tiers to roles module

- Removed the deprecated tiers.ts file and migrated all related functionality to roles/index.js.
- Updated tests and tools to reflect the new role structure, replacing references to "dev", "qa", and "architect" with "developer", "tester", and "architect".
- Adjusted workflow configurations and state management to accommodate the new role naming conventions.
- Enhanced project registration and health check tools to support dynamic role handling.
- Updated task creation, update, and completion processes to align with the new role definitions.
- Improved documentation and comments to clarify role responsibilities and usage.
This commit is contained in:
Lauren ten Hoor
2026-02-15 18:32:10 +08:00
parent 6a99752e5f
commit 0e24a68882
44 changed files with 1162 additions and 762 deletions

View File

@@ -5,7 +5,7 @@
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 { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js";
import { selectLevel } from "../model-selector.js";
import {
DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule,
@@ -14,21 +14,21 @@ import {
describe("architect tiers", () => {
it("should recognize architect levels", () => {
assert.strictEqual(isArchitectLevel("junior"), true);
assert.strictEqual(isArchitectLevel("senior"), true);
assert.strictEqual(isArchitectLevel("mid"), false);
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 dev first (registry order), so roleForLevel returns "dev"
// This is expected — use isArchitectLevel for architect-specific checks
assert.strictEqual(levelRole("junior"), "dev");
assert.strictEqual(levelRole("senior"), "dev");
// "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(defaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(defaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-5");
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
});
it("should resolve architect model from config", () => {
@@ -37,8 +37,8 @@ describe("architect tiers", () => {
});
it("should have architect emoji", () => {
assert.strictEqual(levelEmoji("architect", "senior"), "🏗️");
assert.strictEqual(levelEmoji("architect", "junior"), "📐");
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
assert.strictEqual(getEmoji("architect", "junior"), "📐");
});
});
@@ -76,8 +76,9 @@ describe("architect workflow states", () => {
assert.strictEqual(rule!.to, "Refining");
});
it("should have architect completion emoji", () => {
assert.strictEqual(getCompletionEmoji("architect", "done"), "🏗️");
it("should have completion emoji by result type", () => {
// Emoji is now keyed by result, not role:result
assert.strictEqual(getCompletionEmoji("architect", "done"), "✅");
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
});
});

View File

@@ -13,7 +13,9 @@ 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";
import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js";
import { selectLevel } from "../model-selector.js";
import { resolveModel } from "../roles/index.js";
export function createDesignTaskTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -81,6 +83,14 @@ Example:
const { project } = await resolveProject(workspaceDir, groupId);
const { provider } = await resolveProvider(project);
const pluginConfig = getPluginConfig(api);
// Derive labels from workflow config
const workflow = await loadWorkflow(workspaceDir, project.name);
const role = "architect";
const queueLabels = getQueueLabels(workflow, role);
const queueLabel = queueLabels[0];
if (!queueLabel) throw new Error(`No queue state found for role "${role}" in workflow`);
// Build issue body with focus areas
const bodyParts = [description];
@@ -101,51 +111,48 @@ Example:
);
const issueBody = bodyParts.join("\n");
// Create issue in To Design state
const issue = await provider.createIssue(title, issueBody, "To Design" as StateLabel);
// Create issue in queue state
const issue = await provider.createIssue(title, issueBody, queueLabel 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" ? "senior" : "junior";
// 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 model = resolveModel(role, level, pluginConfig);
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}`,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel },
design: { level, model, status: "dry_run" },
announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
});
}
// Check architect availability
const worker = getWorker(project, "architect");
// Check worker availability
const worker = getWorker(project, role);
if (worker.active) {
// Issue created but can't dispatch yet — will be picked up by heartbeat
return jsonResult({
success: true,
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" },
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: queueLabel },
design: {
level,
status: "queued",
reason: `Architect already active on #${worker.issueId}. Issue queued for pickup.`,
reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue queued for pickup.`,
},
announcement: `📐 Created design task #${issue.iid}: ${title} (queued — architect busy)\n🔗 ${issue.web_url}`,
announcement: `📐 Created design task #${issue.iid}: ${title} (queued — ${role} busy)\n🔗 ${issue.web_url}`,
});
}
// Dispatch architect
const workflow = DEFAULT_WORKFLOW;
const targetLabel = getActiveLabel(workflow, "architect");
const pluginConfig = getPluginConfig(api);
// Dispatch worker
const targetLabel = getActiveLabel(workflow, role);
const dr = await dispatchTask({
workspaceDir,
@@ -156,9 +163,9 @@ Example:
issueTitle: issue.title,
issueDescription: issueBody,
issueUrl: issue.web_url,
role: "architect",
role,
level,
fromLabel: "To Design",
fromLabel: queueLabel,
toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
provider,

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

@@ -15,40 +15,26 @@ 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 { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.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 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(projectDir, `${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;
@@ -122,7 +108,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.`,
);
@@ -153,6 +140,12 @@ export function createProjectRegisterTool() {
await provider.ensureAllStateLabels();
// 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,
@@ -163,9 +156,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

@@ -8,8 +8,7 @@ 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 { DEFAULT_MODELS } from "../tiers.js";
import { getLevelsForRole } from "../roles/index.js";
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -37,44 +36,18 @@ 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}`,
},
mid: {
type: "string",
description: `Default: ${DEFAULT_MODELS.dev.mid}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.dev.senior}`,
},
},
},
qa: {
type: "object",
description: "QA level models",
properties: {
junior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.qa.junior}`,
},
mid: {
type: "string",
description: `Default: ${DEFAULT_MODELS.qa.mid}`,
},
senior: {
type: "string",
description: `Default: ${DEFAULT_MODELS.qa.senior}`,
},
},
},
},
description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`,
}]),
),
}]),
),
},
projectExecution: {
type: "string",
@@ -112,13 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) {
"",
);
}
lines.push(
"Models:",
...getLevelsForRole("dev").map((t) => ` dev.${t}: ${result.models.dev[t]}`),
...getLevelsForRole("qa").map((t) => ` qa.${t}: ${result.models.qa[t]}`),
...getLevelsForRole("architect").map((t) => ` architect.${t}: ${result.models.architect[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 } from "../workflow.js";
export function createStatusTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -32,8 +32,8 @@ export function createStatusTool(api: OpenClawPluginApi) {
const pluginConfig = getPluginConfig(api);
const projectExecution = (pluginConfig?.projectExecution as string) ?? "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,
},
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";
@@ -13,7 +13,7 @@ import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
/** Valid author roles for attribution */
const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const;
const AUTHOR_ROLES = ["developer", "tester", "orchestrator"] as const;
type AuthorRole = (typeof AUTHOR_ROLES)[number];
export function createTaskCommentTool(api: OpenClawPluginApi) {
@@ -23,15 +23,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"],
@@ -100,7 +100,7 @@ Examples:
// ---------------------------------------------------------------------------
const ROLE_EMOJI: Record<AuthorRole, string> = {
dev: "👨‍💻",
qa: "🔍",
developer: "👨‍💻",
tester: "🔍",
orchestrator: "🎛️",
};

View File

@@ -13,7 +13,8 @@ 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";
export function createTaskCreateTool(api: OpenClawPluginApi) {
@@ -46,7 +47,7 @@ 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,
enum: getStateLabels(DEFAULT_WORKFLOW),
},
assignees: {
type: "array",

View File

@@ -10,7 +10,8 @@ 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";
export function createTaskUpdateTool(api: OpenClawPluginApi) {
@@ -42,8 +43,8 @@ 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(", ")}`,
},
reason: {
type: "string",

View File

@@ -8,16 +8,17 @@ 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 { executeCompletion, getRule } from "../services/pipeline.js";
import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
import { loadWorkflow } from "../workflow.js";
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/blocked, Tester pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
parameters: {
type: "object",
required: ["role", "result", "projectGroupId"],
@@ -31,7 +32,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;
@@ -59,6 +60,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
const issue = await provider.getIssue(issueId);
const pluginConfig = getPluginConfig(api);
const workflow = await loadWorkflow(workspaceDir, project.name);
// Execute completion (pipeline service handles notification with runtime)
const completion = await executeCompletion({
@@ -67,6 +69,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
channel: project.channel,
pluginConfig,
runtime: api.runtime,
workflow,
});
const output: Record<string, unknown> = {

View File

@@ -13,10 +13,9 @@ 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 { getAllRoleIds, isLevelForRole } from "../roles/index.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
import { loadWorkflow, getActiveLabel } from "../workflow.js";
export function createWorkStartTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -36,7 +35,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
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 };
@@ -73,8 +71,11 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
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`);
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
@@ -87,9 +88,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
} 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"; }
if (!isLevelForRole(labelLevel, role)) {
// Label level belongs to a different role — use heuristic for this role
const s = selectLevel(issue.title, issue.description ?? "", role);
selectedLevel = s.level; levelReason = `${role} overrides other role's level "${labelLevel}"`; levelSource = "role-override";
} else {
selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label";
}
} else {
const s = selectLevel(issue.title, issue.description ?? "", role);
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";