Creates a single source of truth for all worker roles via lib/roles/.
## New: lib/roles/
- **registry.ts** — All role definitions (dev, qa, architect) with
levels, models, emoji, completion results, session key patterns
- **types.ts** — RoleConfig interface
- **selectors.ts** — Query helpers: getRole(), getLevelsForRole(),
resolveModel(), isValidResult(), roleForLevel(), etc.
- **index.ts** — Barrel exports
## Migrated Files
- **lib/tiers.ts** — Now delegates to registry (backward compat kept)
- **lib/dispatch.ts** — Uses registry for emoji resolution
- **lib/bootstrap-hook.ts** — Uses registry for session key pattern
- **lib/services/tick.ts** — Uses registry for level detection
- **lib/services/heartbeat.ts** — Uses registry for role iteration
- **lib/tools/health.ts** — Uses registry for role iteration
- **lib/tools/work-start.ts** — Uses registry for role enum
- **lib/tools/work-finish.ts** — Uses registry for result validation
- **lib/tools/project-register.ts** — Uses registry for level lists
## Key Benefits
- Adding a new role = add entry to registry.ts (single file)
- No more scattered role unions ("dev" | "qa" | "architect")
- Type-safe role/level/result validation from registry
- Session key pattern auto-generated from registry
- All 64 tests passing (22 new registry tests + 42 existing)
129 lines
6.5 KiB
TypeScript
129 lines
6.5 KiB
TypeScript
/**
|
|
* work_start — Pick up a task from the issue queue.
|
|
*
|
|
* Context-aware: ONLY works in project group chats.
|
|
* Auto-detects: projectGroupId, role, level, issueId.
|
|
* Picks up only the explicitly requested issue (auto-tick disabled).
|
|
*/
|
|
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 { 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 { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
|
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
|
|
|
export function createWorkStartTool(api: OpenClawPluginApi) {
|
|
return (ctx: ToolContext) => ({
|
|
name: "work_start",
|
|
label: "Work Start",
|
|
description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, level assignment, session creation, dispatch, and audit. Picks up only the explicitly requested issue.`,
|
|
parameters: {
|
|
type: "object",
|
|
required: ["projectGroupId"],
|
|
properties: {
|
|
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." },
|
|
},
|
|
},
|
|
|
|
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 groupId = params.projectGroupId as string;
|
|
const levelParam = (params.level ?? params.tier) as string | undefined;
|
|
const workspaceDir = requireWorkspaceDir(ctx);
|
|
|
|
if (!groupId) throw new Error("projectGroupId is required");
|
|
const { project } = await resolveProject(workspaceDir, groupId);
|
|
const { provider } = await resolveProvider(project);
|
|
|
|
// TODO: Load per-project workflow when supported
|
|
const workflow = DEFAULT_WORKFLOW;
|
|
|
|
// Find issue
|
|
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
|
|
let currentLabel: StateLabel;
|
|
if (issueIdParam !== undefined) {
|
|
issue = await provider.getIssue(issueIdParam);
|
|
const label = provider.getCurrentStateLabel(issue);
|
|
if (!label) throw new Error(`Issue #${issueIdParam} has no recognized state label`);
|
|
currentLabel = label;
|
|
} else {
|
|
const next = await findNextIssue(provider, roleParam, workflow);
|
|
if (!next) return jsonResult({ success: false, error: `No issues available. Queue is empty.` });
|
|
issue = next.issue;
|
|
currentLabel = next.label;
|
|
}
|
|
|
|
// Detect role
|
|
const detectedRole = detectRoleFromLabel(currentLabel, workflow);
|
|
if (!detectedRole) throw new Error(`Label "${currentLabel}" doesn't map to a role`);
|
|
const role = roleParam ?? detectedRole;
|
|
if (roleParam && roleParam !== detectedRole) throw new Error(`Role mismatch: "${currentLabel}" → ${detectedRole}, requested ${roleParam}`);
|
|
|
|
// 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`);
|
|
}
|
|
|
|
// Get target label from workflow
|
|
const targetLabel = getActiveLabel(workflow, role);
|
|
|
|
// Select level
|
|
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"; }
|
|
} else {
|
|
const s = selectLevel(issue.title, issue.description ?? "", role);
|
|
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
|
|
}
|
|
}
|
|
|
|
// Dispatch (pass runtime for direct API access)
|
|
const pluginConfig = getPluginConfig(api);
|
|
const dr = await dispatchTask({
|
|
workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid,
|
|
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
|
role, level: selectedLevel, fromLabel: currentLabel, 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,
|
|
});
|
|
|
|
// Auto-tick disabled per issue #125 - work_start should only pick up the explicitly requested issue
|
|
// The heartbeat service fills parallel slots automatically
|
|
|
|
const output: Record<string, unknown> = {
|
|
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
|
|
role, level: dr.level, model: dr.model, sessionAction: dr.sessionAction,
|
|
announcement: dr.announcement, labelTransition: `${currentLabel} → ${targetLabel}`,
|
|
levelReason, levelSource,
|
|
autoDetected: { role: !roleParam, issueId: issueIdParam === undefined, level: !levelParam },
|
|
};
|
|
// tickPickups removed with auto-tick
|
|
|
|
return jsonResult(output);
|
|
},
|
|
});
|
|
}
|