refactor: rename 'tier' to 'level' across the codebase

- Updated WorkerState type to use 'level' instead of 'tier'.
- Modified functions related to worker state management, including parseWorkerState, emptyWorkerState, getSessionForLevel, activateWorker, and deactivateWorker to reflect the new terminology.
- Adjusted health check logic to utilize 'level' instead of 'tier'.
- Refactored tick and setup tools to accommodate the change from 'tier' to 'level', including model configuration and workspace scaffolding.
- Updated tests to ensure consistency with the new 'level' terminology.
- Revised documentation and comments to reflect the changes in terminology from 'tier' to 'level'.
This commit is contained in:
Lauren ten Hoor
2026-02-11 03:04:17 +08:00
parent 1f95ad4518
commit 5df4b912c9
18 changed files with 296 additions and 278 deletions

View File

@@ -5,7 +5,7 @@
*/ */
import type { Command } from "commander"; import type { Command } from "commander";
import { runSetup } from "./setup/index.js"; import { runSetup } from "./setup/index.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js"; import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "./tiers.js";
/** /**
* Register the `devclaw` CLI command group on a Commander program. * Register the `devclaw` CLI command group on a Commander program.
@@ -27,18 +27,24 @@ export function registerCli(program: Command): void {
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`) .option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`) .option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`)
.action(async (opts) => { .action(async (opts) => {
const models: Partial<Record<Tier, string>> = {}; const dev: Record<string, string> = {};
if (opts.junior) models["dev.junior"] = opts.junior; const qa: Record<string, string> = {};
if (opts.medior) models["dev.medior"] = opts.medior; if (opts.junior) dev.junior = opts.junior;
if (opts.senior) models["dev.senior"] = opts.senior; if (opts.medior) dev.medior = opts.medior;
if (opts.reviewer) models["qa.reviewer"] = opts.reviewer; if (opts.senior) dev.senior = opts.senior;
if (opts.tester) models["qa.tester"] = opts.tester; if (opts.reviewer) qa.reviewer = opts.reviewer;
if (opts.tester) qa.tester = opts.tester;
const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0;
const models = hasOverrides
? { ...(Object.keys(dev).length > 0 && { dev }), ...(Object.keys(qa).length > 0 && { qa }) }
: undefined;
const result = await runSetup({ const result = await runSetup({
newAgentName: opts.newAgent, newAgentName: opts.newAgent,
agentId: opts.agent, agentId: opts.agent,
workspacePath: opts.workspace, workspacePath: opts.workspace,
models: Object.keys(models).length > 0 ? models : undefined, models,
}); });
if (result.agentCreated) { if (result.agentCreated) {
@@ -46,9 +52,8 @@ export function registerCli(program: Command): void {
} }
console.log("Models configured:"); console.log("Models configured:");
for (const tier of ALL_TIERS) { for (const t of DEV_LEVELS) console.log(` dev.${t}: ${result.models.dev[t]}`);
console.log(` ${tier}: ${result.models[tier]}`); for (const t of QA_LEVELS) console.log(` qa.${t}: ${result.models.qa[t]}`);
}
console.log("Files written:"); console.log("Files written:");
for (const file of result.filesWritten) { for (const file of result.filesWritten) {

View File

@@ -12,10 +12,10 @@ import { log as auditLog } from "./audit.js";
import { import {
type Project, type Project,
activateWorker, activateWorker,
getSessionForTier, getSessionForLevel,
getWorker, getWorker,
} from "./projects.js"; } from "./projects.js";
import { tierEmoji, resolveTierToModel } from "./tiers.js"; import { resolveModel, levelEmoji } from "./tiers.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -29,8 +29,8 @@ export type DispatchOpts = {
issueDescription: string; issueDescription: string;
issueUrl: string; issueUrl: string;
role: "dev" | "qa"; role: "dev" | "qa";
/** Developer tier (junior, medior, senior, qa) or raw model ID */ /** Developer level (junior, medior, senior, reviewer) or raw model ID */
tier: string; level: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
fromLabel: string; fromLabel: string;
/** Label to transition TO (e.g. "Doing", "Testing") */ /** Label to transition TO (e.g. "Doing", "Testing") */
@@ -46,14 +46,14 @@ export type DispatchOpts = {
export type DispatchResult = { export type DispatchResult = {
sessionAction: "spawn" | "send"; sessionAction: "spawn" | "send";
sessionKey: string; sessionKey: string;
tier: string; level: string;
model: string; model: string;
announcement: string; announcement: string;
}; };
/** /**
* Build the task message sent to a worker session. * Build the task message sent to a worker session.
* Reads role-specific instructions from workspace/projects/prompts/<project>/<role>.md. * Reads role-specific instructions from workspace/projects/roles/<project>/<role>.md (falls back to projects/roles/default/).
*/ */
export async function buildTaskMessage(opts: { export async function buildTaskMessage(opts: {
workspaceDir: string; workspaceDir: string;
@@ -125,13 +125,13 @@ export async function dispatchTask(
): Promise<DispatchResult> { ): Promise<DispatchResult> {
const { const {
workspaceDir, agentId, groupId, project, issueId, issueTitle, workspaceDir, agentId, groupId, project, issueId, issueTitle,
issueDescription, issueUrl, role, tier, fromLabel, toLabel, issueDescription, issueUrl, role, level, fromLabel, toLabel,
transitionLabel, pluginConfig, transitionLabel, pluginConfig,
} = opts; } = opts;
const model = resolveTierToModel(tier, pluginConfig); const model = resolveModel(role, level, pluginConfig);
const worker = getWorker(project, role); const worker = getWorker(project, role);
const existingSessionKey = getSessionForTier(worker, tier); const existingSessionKey = getSessionForLevel(worker, level);
const sessionAction = existingSessionKey ? "send" : "spawn"; const sessionAction = existingSessionKey ? "send" : "spawn";
const taskMessage = await buildTaskMessage({ const taskMessage = await buildTaskMessage({
@@ -147,7 +147,7 @@ export async function dispatchTask(
try { try {
sessionKey = await ensureSession(sessionAction, sessionKey, { sessionKey = await ensureSession(sessionAction, sessionKey, {
agentId, projectName: project.name, role, tier, model, agentId, projectName: project.name, role, level, model,
}); });
await sendToAgent(sessionKey!, taskMessage, { await sendToAgent(sessionKey!, taskMessage, {
@@ -158,7 +158,7 @@ export async function dispatchTask(
dispatched = true; dispatched = true;
await recordWorkerState(workspaceDir, groupId, role, { await recordWorkerState(workspaceDir, groupId, role, {
issueId, tier, sessionKey: sessionKey!, sessionAction, issueId, level, sessionKey: sessionKey!, sessionAction,
}); });
} catch (err) { } catch (err) {
if (dispatched) { if (dispatched) {
@@ -179,13 +179,13 @@ export async function dispatchTask(
await auditDispatch(workspaceDir, { await auditDispatch(workspaceDir, {
project: project.name, groupId, issueId, issueTitle, project: project.name, groupId, issueId, issueTitle,
role, tier, model, sessionAction, sessionKey: sessionKey!, role, level, model, sessionAction, sessionKey: sessionKey!,
fromLabel, toLabel, fromLabel, toLabel,
}); });
const announcement = buildAnnouncement(tier, role, sessionAction, issueId, issueTitle, issueUrl); const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl);
return { sessionAction, sessionKey: sessionKey!, tier, model, announcement }; return { sessionAction, sessionKey: sessionKey!, level, model, announcement };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -195,19 +195,21 @@ export async function dispatchTask(
async function loadRoleInstructions( async function loadRoleInstructions(
workspaceDir: string, projectName: string, role: "dev" | "qa", workspaceDir: string, projectName: string, role: "dev" | "qa",
): Promise<string> { ): Promise<string> {
const projectFile = path.join(workspaceDir, "projects", "prompts", projectName, `${role}.md`); const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* none */ } try { return await fs.readFile(projectFile, "utf-8"); } catch { /* none */ }
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ }
return ""; return "";
} }
async function ensureSession( async function ensureSession(
action: "spawn" | "send", action: "spawn" | "send",
existingKey: string | null, existingKey: string | null,
opts: { agentId?: string; projectName: string; role: string; tier: string; model: string }, opts: { agentId?: string; projectName: string; role: string; level: string; model: string },
): Promise<string> { ): Promise<string> {
if (action === "send") return existingKey!; if (action === "send") return existingKey!;
const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.tier}`; const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.level}`;
await execFileAsync( await execFileAsync(
"openclaw", "openclaw",
["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })], ["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })],
@@ -239,12 +241,12 @@ function sendToAgent(
async function recordWorkerState( async function recordWorkerState(
workspaceDir: string, groupId: string, role: "dev" | "qa", workspaceDir: string, groupId: string, role: "dev" | "qa",
opts: { issueId: number; tier: string; sessionKey: string; sessionAction: "spawn" | "send" }, opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
): Promise<void> { ): Promise<void> {
const params: { issueId: string; tier: string; sessionKey?: string; startTime: string } = { const params: { issueId: string; level: string; sessionKey?: string; startTime: string } = {
issueId: String(opts.issueId), issueId: String(opts.issueId),
tier: opts.tier, level: opts.level,
startTime: new Date().toISOString(), // Always reset startTime for new task assignment startTime: new Date().toISOString(),
}; };
if (opts.sessionAction === "spawn") { if (opts.sessionAction === "spawn") {
params.sessionKey = opts.sessionKey; params.sessionKey = opts.sessionKey;
@@ -256,27 +258,27 @@ async function auditDispatch(
workspaceDir: string, workspaceDir: string,
opts: { opts: {
project: string; groupId: string; issueId: number; issueTitle: string; project: string; groupId: string; issueId: number; issueTitle: string;
role: string; tier: string; model: string; sessionAction: string; role: string; level: string; model: string; sessionAction: string;
sessionKey: string; fromLabel: string; toLabel: string; sessionKey: string; fromLabel: string; toLabel: string;
}, },
): Promise<void> { ): Promise<void> {
await auditLog(workspaceDir, "work_start", { await auditLog(workspaceDir, "work_start", {
project: opts.project, groupId: opts.groupId, project: opts.project, groupId: opts.groupId,
issue: opts.issueId, issueTitle: opts.issueTitle, issue: opts.issueId, issueTitle: opts.issueTitle,
role: opts.role, tier: opts.tier, role: opts.role, level: opts.level,
sessionAction: opts.sessionAction, sessionKey: opts.sessionKey, sessionAction: opts.sessionAction, sessionKey: opts.sessionKey,
labelTransition: `${opts.fromLabel}${opts.toLabel}`, labelTransition: `${opts.fromLabel}${opts.toLabel}`,
}); });
await auditLog(workspaceDir, "model_selection", { await auditLog(workspaceDir, "model_selection", {
issue: opts.issueId, role: opts.role, tier: opts.tier, model: opts.model, issue: opts.issueId, role: opts.role, level: opts.level, model: opts.model,
}); });
} }
function buildAnnouncement( function buildAnnouncement(
tier: string, role: string, sessionAction: "spawn" | "send", level: string, role: string, sessionAction: "spawn" | "send",
issueId: number, issueTitle: string, issueUrl: string, issueId: number, issueTitle: string, issueUrl: string,
): string { ): string {
const emoji = tierEmoji(tier) ?? (role === "qa" ? "🔍" : "🔧"); const emoji = levelEmoji(role as "dev" | "qa", level) ?? (role === "qa" ? "🔍" : "🔧");
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
} }

View File

@@ -1,11 +1,11 @@
/** /**
* Model selection for dev/qa tasks. * Model selection for dev/qa tasks.
* Keyword heuristic fallback — used when the orchestrator doesn't specify a tier. * Keyword heuristic fallback — used when the orchestrator doesn't specify a level.
* Returns full tier names (dev.junior, dev.medior, dev.senior, qa.reviewer, qa.tester). * Returns plain level names (junior, medior, senior, reviewer, tester).
*/ */
export type TierRecommendation = { export type LevelSelection = {
tier: string; level: string;
reason: string; reason: string;
}; };
@@ -39,24 +39,24 @@ const COMPLEX_KEYWORDS = [
]; ];
/** /**
* Select appropriate developer tier based on task description. * Select appropriate developer level based on task description.
* *
* Developer tiers: * Developer levels:
* - dev.junior: very simple (typos, single-file fixes, CSS tweaks) * - junior: very simple (typos, single-file fixes, CSS tweaks)
* - dev.medior: standard DEV (features, bug fixes, multi-file changes) * - medior: standard DEV (features, bug fixes, multi-file changes)
* - dev.senior: deep/architectural (system-wide refactoring, novel design) * - senior: deep/architectural (system-wide refactoring, novel design)
* - qa.reviewer: QA code inspection and validation * - reviewer: QA code inspection and validation
* - qa.tester: QA manual testing * - tester: QA manual testing
*/ */
export function selectTier( export function selectLevel(
issueTitle: string, issueTitle: string,
issueDescription: string, issueDescription: string,
role: "dev" | "qa", role: "dev" | "qa",
): TierRecommendation { ): LevelSelection {
if (role === "qa") { if (role === "qa") {
return { return {
tier: "qa.reviewer", level: "reviewer",
reason: "Default QA tier for code inspection and validation", reason: "Default QA level for code inspection and validation",
}; };
} }
@@ -67,7 +67,7 @@ export function selectTier(
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw)); const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
if (isSimple && wordCount < 100) { if (isSimple && wordCount < 100) {
return { return {
tier: "dev.junior", level: "junior",
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`, reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
}; };
} }
@@ -76,14 +76,14 @@ export function selectTier(
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
if (isComplex || wordCount > 500) { if (isComplex || wordCount > 500) {
return { return {
tier: "dev.senior", level: "senior",
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`, reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
}; };
} }
// Default: medior for standard dev work // Default: medior for standard dev work
return { return {
tier: "dev.medior", level: "medior",
reason: "Standard dev task — multi-file changes, features, bug fixes", reason: "Standard dev task — multi-file changes, features, bug fixes",
}; };
} }

View File

@@ -34,7 +34,7 @@ export type NotifyEvent =
issueTitle: string; issueTitle: string;
issueUrl: string; issueUrl: string;
role: "dev" | "qa"; role: "dev" | "qa";
tier: string; level: string;
sessionAction: "spawn" | "send"; sessionAction: "spawn" | "send";
} }
| { | {
@@ -69,7 +69,7 @@ function buildMessage(event: NotifyEvent): string {
switch (event.type) { switch (event.type) {
case "workerStart": { case "workerStart": {
const action = event.sessionAction === "spawn" ? "🚀 Started" : "▶️ Resumed"; const action = event.sessionAction === "spawn" ? "🚀 Started" : "▶️ Resumed";
return `${action} ${event.role.toUpperCase()} (${event.tier}) on #${event.issueId}: ${event.issueTitle}\n🔗 ${event.issueUrl}`; return `${action} ${event.role.toUpperCase()} (${event.level}) on #${event.issueId}: ${event.issueTitle}\n🔗 ${event.issueUrl}`;
} }
case "workerComplete": { case "workerComplete": {
@@ -253,7 +253,7 @@ export async function notifyTickPickups(
issueTitle: pickup.issueTitle, issueTitle: pickup.issueTitle,
issueUrl: pickup.issueUrl, issueUrl: pickup.issueUrl,
role: pickup.role, role: pickup.role,
tier: pickup.tier, level: pickup.level,
sessionAction: pickup.sessionAction, sessionAction: pickup.sessionAction,
}, },
{ {

View File

@@ -5,7 +5,7 @@
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { ALL_TIERS, defaultModel } from "./tiers.js"; import { DEFAULT_MODELS } from "./tiers.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Detection // Detection
@@ -38,12 +38,15 @@ export async function hasWorkspaceFiles(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildModelTable(pluginConfig?: Record<string, unknown>): string { function buildModelTable(pluginConfig?: Record<string, unknown>): string {
const models = const cfg = (pluginConfig as { models?: { dev?: Record<string, string>; qa?: Record<string, string> } })?.models;
(pluginConfig as { models?: Record<string, string> })?.models ?? {}; const lines: string[] = [];
return ALL_TIERS.map( for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
(t) => for (const [level, defaultModel] of Object.entries(levels)) {
` - **${t}**: ${models[t] || defaultModel(t)} (default: ${defaultModel(t)})`, const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel;
).join("\n"); lines.push(` - **${role} ${level}**: ${model} (default: ${defaultModel})`);
}
}
return lines.join("\n");
} }
export function buildReconfigContext( export function buildReconfigContext(
@@ -57,7 +60,7 @@ The user wants to reconfigure DevClaw. Current model configuration:
${modelTable} ${modelTable}
## What can be changed ## What can be changed
1. **Model tiers** — call \`setup\` with a \`models\` object containing only the tiers to change 1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change
2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files) 2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
3. **Register new projects** — use \`project_register\` 3. **Register new projects** — use \`project_register\`
@@ -67,12 +70,28 @@ Ask what they want to change, then call the appropriate tool.
} }
export function buildOnboardToolContext(): string { export function buildOnboardToolContext(): string {
// Build the model table dynamically from DEFAULT_MODELS
const rows: string[] = [];
const purposes: Record<string, string> = {
junior: "Typos, single-file fixes",
medior: "Features, bug fixes",
senior: "Architecture, refactoring",
reviewer: "Code review",
tester: "Testing",
};
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
for (const [level, model] of Object.entries(levels)) {
rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`);
}
}
const modelTable = rows.join("\n");
return `# DevClaw Onboarding return `# DevClaw Onboarding
## What is DevClaw? ## What is DevClaw?
DevClaw turns each Telegram group into an autonomous development team: DevClaw turns each Telegram group into an autonomous development team:
- An **orchestrator** that manages backlogs and delegates work - An **orchestrator** that manages backlogs and delegates work
- **DEV workers** (junior/medior/senior tiers) that write code in isolated sessions - **DEV workers** (junior/medior/senior levels) that write code in isolated sessions
- **QA workers** that review code and run tests - **QA workers** that review code and run tests
- Atomic tools for label transitions, session dispatch, state management, and audit logging - Atomic tools for label transitions, session dispatch, state management, and audit logging
@@ -92,21 +111,17 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de
- If none selected, user can add bindings manually later via openclaw.json - If none selected, user can add bindings manually later via openclaw.json
**Step 2: Model Configuration** **Step 2: Model Configuration**
Show the default tier-to-model mapping and ask if they want to customize: Show the default level-to-model mapping and ask if they want to customize:
| Tier | Default Model | Purpose | | Role | Level | Default Model | Purpose |
|------|---------------|---------| |------|-------|---------------|---------|
| dev.junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes | ${modelTable}
| dev.medior | anthropic/claude-sonnet-4-5 | Features, bug fixes |
| dev.senior | anthropic/claude-opus-4-5 | Architecture, refactoring |
| qa.reviewer | anthropic/claude-sonnet-4-5 | Code review |
| qa.tester | anthropic/claude-haiku-4-5 | Testing |
If the defaults are fine, proceed. If customizing, ask which tiers to change. If the defaults are fine, proceed. If customizing, ask which levels to change.
**Step 3: Run Setup** **Step 3: Run Setup**
Call \`setup\` with the collected answers: Call \`setup\` with the collected answers:
- Current agent: \`setup({})\` or \`setup({ models: { ... } })\` - Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\`
- New agent: \`setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\` - New agent: \`setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
- \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding - \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding

View File

@@ -9,7 +9,7 @@ export type WorkerState = {
active: boolean; active: boolean;
issueId: string | null; issueId: string | null;
startTime: string | null; startTime: string | null;
tier: string | null; level: string | null;
sessions: Record<string, string | null>; sessions: Record<string, string | null>;
}; };
@@ -39,36 +39,36 @@ function parseWorkerState(worker: Record<string, unknown>): WorkerState {
active: worker.active as boolean, active: worker.active as boolean,
issueId: worker.issueId as string | null, issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null, startTime: worker.startTime as string | null,
tier: worker.tier as string | null, level: (worker.level ?? worker.tier ?? null) as string | null,
sessions: (worker.sessions as Record<string, string | null>) ?? {}, sessions: (worker.sessions as Record<string, string | null>) ?? {},
}; };
} }
/** /**
* Create a blank WorkerState with null sessions for given tier names. * Create a blank WorkerState with null sessions for given level names.
*/ */
export function emptyWorkerState(tiers: string[]): WorkerState { export function emptyWorkerState(levels: string[]): WorkerState {
const sessions: Record<string, string | null> = {}; const sessions: Record<string, string | null> = {};
for (const t of tiers) { for (const l of levels) {
sessions[t] = null; sessions[l] = null;
} }
return { return {
active: false, active: false,
issueId: null, issueId: null,
startTime: null, startTime: null,
tier: null, level: null,
sessions, sessions,
}; };
} }
/** /**
* Get session key for a specific tier from a worker's sessions map. * Get session key for a specific level from a worker's sessions map.
*/ */
export function getSessionForTier( export function getSessionForLevel(
worker: WorkerState, worker: WorkerState,
tier: string, level: string,
): string | null { ): string | null {
return worker.sessions[tier] ?? null; return worker.sessions[level] ?? null;
} }
function projectsPath(workspaceDir: string): string { function projectsPath(workspaceDir: string): string {
@@ -148,7 +148,7 @@ export async function updateWorker(
/** /**
* Mark a worker as active with a new task. * Mark a worker as active with a new task.
* Stores session key in sessions[tier] when a new session is spawned. * Stores session key in sessions[level] when a new session is spawned.
*/ */
export async function activateWorker( export async function activateWorker(
workspaceDir: string, workspaceDir: string,
@@ -156,7 +156,7 @@ export async function activateWorker(
role: "dev" | "qa", role: "dev" | "qa",
params: { params: {
issueId: string; issueId: string;
tier: string; level: string;
sessionKey?: string; sessionKey?: string;
startTime?: string; startTime?: string;
}, },
@@ -164,10 +164,10 @@ export async function activateWorker(
const updates: Partial<WorkerState> = { const updates: Partial<WorkerState> = {
active: true, active: true,
issueId: params.issueId, issueId: params.issueId,
tier: params.tier, level: params.level,
}; };
if (params.sessionKey !== undefined) { if (params.sessionKey !== undefined) {
updates.sessions = { [params.tier]: params.sessionKey }; updates.sessions = { [params.level]: params.sessionKey };
} }
if (params.startTime !== undefined) { if (params.startTime !== undefined) {
updates.startTime = params.startTime; updates.startTime = params.startTime;
@@ -177,7 +177,7 @@ export async function activateWorker(
/** /**
* Mark a worker as inactive after task completion. * Mark a worker as inactive after task completion.
* Preserves sessions map and tier for reuse via updateWorker's spread. * Preserves sessions map and level for reuse via updateWorker's spread.
* Clears startTime to prevent stale timestamps on inactive workers. * Clears startTime to prevent stale timestamps on inactive workers.
*/ */
export async function deactivateWorker( export async function deactivateWorker(

View File

@@ -6,7 +6,7 @@
*/ */
import type { StateLabel } from "../providers/provider.js"; import type { StateLabel } from "../providers/provider.js";
import { import {
getSessionForTier, getSessionForLevel,
getWorker, getWorker,
updateWorker, updateWorker,
type Project, type Project,
@@ -19,7 +19,7 @@ export type HealthIssue = {
groupId: string; groupId: string;
role: "dev" | "qa"; role: "dev" | "qa";
message: string; message: string;
tier?: string | null; level?: string | null;
sessionKey?: string | null; sessionKey?: string | null;
hoursActive?: number; hoursActive?: number;
issueId?: string | null; issueId?: string | null;
@@ -46,7 +46,7 @@ export async function checkWorkerHealth(opts: {
const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts; const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts;
const fixes: HealthFix[] = []; const fixes: HealthFix[] = [];
const worker = getWorker(project, role); const worker = getWorker(project, role);
const sessionKey = worker.tier ? getSessionForTier(worker, worker.tier) : null; const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null;
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test"; const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
@@ -62,14 +62,14 @@ export async function checkWorkerHealth(opts: {
} }
} }
// Check 1: Active but no session key for current tier // Check 1: Active but no session key for current level
if (worker.active && !sessionKey) { if (worker.active && !sessionKey) {
const fix: HealthFix = { const fix: HealthFix = {
issue: { issue: {
type: "active_no_session", severity: "critical", type: "active_no_session", severity: "critical",
project: project.name, groupId, role, project: project.name, groupId, role,
tier: worker.tier, level: worker.level,
message: `${role.toUpperCase()} active but no session for tier "${worker.tier}"`, message: `${role.toUpperCase()} active but no session for level "${worker.level}"`,
}, },
fixed: false, fixed: false,
}; };
@@ -86,7 +86,7 @@ export async function checkWorkerHealth(opts: {
issue: { issue: {
type: "zombie_session", severity: "critical", type: "zombie_session", severity: "critical",
project: project.name, groupId, role, project: project.name, groupId, role,
sessionKey, tier: worker.tier, sessionKey, level: worker.level,
message: `${role.toUpperCase()} session not in active sessions list`, message: `${role.toUpperCase()} session not in active sessions list`,
}, },
fixed: false, fixed: false,
@@ -94,7 +94,7 @@ export async function checkWorkerHealth(opts: {
if (autoFix) { if (autoFix) {
await revertIssueLabel(fix); await revertIssueLabel(fix);
const sessions = { ...worker.sessions }; const sessions = { ...worker.sessions };
if (worker.tier) sessions[worker.tier] = null; if (worker.level) sessions[worker.level] = null;
await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, startTime: null, sessions }); await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, startTime: null, sessions });
fix.fixed = true; fix.fixed = true;
} }

View File

@@ -7,10 +7,10 @@
import type { Issue, StateLabel } from "../providers/provider.js"; import type { Issue, StateLabel } from "../providers/provider.js";
import type { IssueProvider } from "../providers/provider.js"; import type { IssueProvider } from "../providers/provider.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { selectTier } from "../model-selector.js"; import { selectLevel } from "../model-selector.js";
import { getWorker, getSessionForTier, readProjects } from "../projects.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { ALL_TIERS, isDevTier, type Tier } from "../tiers.js"; import { DEV_LEVELS, QA_LEVELS, isDevLevel } from "../tiers.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared constants + helpers (used by tick, work-start, auto-pickup) // Shared constants + helpers (used by tick, work-start, auto-pickup)
@@ -20,9 +20,22 @@ export const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
export const QA_LABELS: StateLabel[] = ["To Test"]; export const QA_LABELS: StateLabel[] = ["To Test"];
export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"]; export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
export function detectTierFromLabels(labels: string[]): Tier | null { export function detectLevelFromLabels(labels: string[]): string | null {
const lower = labels.map((l) => l.toLowerCase()); const lower = labels.map((l) => l.toLowerCase());
return (ALL_TIERS as readonly string[]).find((t) => lower.includes(t)) as Tier | undefined ?? null;
// Match role.level labels (e.g., "dev.senior", "qa.reviewer")
for (const l of lower) {
const dot = l.indexOf(".");
if (dot === -1) continue;
const role = l.slice(0, dot);
const level = l.slice(dot + 1);
if (role === "dev" && (DEV_LEVELS as readonly string[]).includes(level)) return level;
if (role === "qa" && (QA_LEVELS as readonly string[]).includes(level)) return level;
}
// Fallback: plain level name
const all = [...DEV_LEVELS, ...QA_LEVELS] as readonly string[];
return all.find((l) => lower.includes(l)) ?? null;
} }
export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null { export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
@@ -77,7 +90,7 @@ export type TickAction = {
issueTitle: string; issueTitle: string;
issueUrl: string; issueUrl: string;
role: "dev" | "qa"; role: "dev" | "qa";
tier: string; level: string;
sessionAction: "spawn" | "send"; sessionAction: "spawn" | "send";
announcement: string; announcement: string;
}; };
@@ -145,14 +158,14 @@ export async function projectTick(opts: {
const { issue, label: currentLabel } = next; const { issue, label: currentLabel } = next;
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
// Tier selection: label → heuristic // Level selection: label → heuristic
const selectedTier = resolveTierForIssue(issue, role); const selectedLevel = resolveLevelForIssue(issue, role);
if (dryRun) { if (dryRun) {
pickups.push({ pickups.push({
project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url,
role, tier: selectedTier, role, level: selectedLevel,
sessionAction: getSessionForTier(worker, selectedTier) ? "send" : "spawn", sessionAction: getSessionForLevel(worker, selectedLevel) ? "send" : "spawn",
announcement: `[DRY RUN] Would pick up #${issue.iid}`, announcement: `[DRY RUN] Would pick up #${issue.iid}`,
}); });
} else { } else {
@@ -160,13 +173,13 @@ export async function projectTick(opts: {
const dr = await dispatchTask({ const dr = await dispatchTask({
workspaceDir, agentId, groupId, project: fresh, issueId: issue.iid, workspaceDir, agentId, groupId, project: fresh, issueId: issue.iid,
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel, role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
pluginConfig, sessionKey, pluginConfig, sessionKey,
}); });
pickups.push({ pickups.push({
project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url,
role, tier: dr.tier, sessionAction: dr.sessionAction, announcement: dr.announcement, role, level: dr.level, sessionAction: dr.sessionAction, announcement: dr.announcement,
}); });
} catch (err) { } catch (err) {
skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` }); skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` });
@@ -184,16 +197,16 @@ export async function projectTick(opts: {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Determine the tier for an issue based on labels, role overrides, and heuristic fallback. * Determine the level for an issue based on labels, role overrides, and heuristic fallback.
*/ */
function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string { function resolveLevelForIssue(issue: Issue, role: "dev" | "qa"): string {
const labelTier = detectTierFromLabels(issue.labels); const labelLevel = detectLevelFromLabels(issue.labels);
if (labelTier) { if (labelLevel) {
// QA role but label specifies a dev tier → heuristic picks the right QA tier // QA role but label specifies a dev level → heuristic picks the right QA level
if (role === "qa" && isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier; if (role === "qa" && isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level;
// DEV role but label specifies a QA tier → heuristic picks the right dev tier // DEV role but label specifies a QA level → heuristic picks the right dev level
if (role === "dev" && !isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier; if (role === "dev" && !isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level;
return labelTier; return labelLevel;
} }
return selectTier(issue.title, issue.description ?? "", role).tier; return selectLevel(issue.title, issue.description ?? "", role).level;
} }

View File

@@ -1,36 +1,20 @@
/** /**
* setup/config.ts — Plugin config writer (openclaw.json). * setup/config.ts — Plugin config writer (openclaw.json).
* *
* Handles: model tier config, devClawAgentIds, tool restrictions, subagent cleanup. * Handles: model level config, devClawAgentIds, tool restrictions, subagent cleanup.
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { DEV_TIERS, QA_TIERS, tierName, type Tier } from "../tiers.js";
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
function openclawConfigPath(): string { function openclawConfigPath(): string {
return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json"); return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json");
} }
/** /**
* Convert flat tier map to nested role-tier structure. * Write DevClaw model level config and devClawAgentIds to openclaw.json plugins section.
*/
function buildRoleTierModels(models: Record<Tier, string>): { dev: Record<string, string>; qa: Record<string, string> } {
const dev: Record<string, string> = {};
const qa: Record<string, string> = {};
for (const tier of DEV_TIERS) {
dev[tierName(tier)] = models[tier];
}
for (const tier of QA_TIERS) {
qa[tierName(tier)] = models[tier];
}
return { dev, qa };
}
/**
* Write DevClaw model tier config and devClawAgentIds to openclaw.json plugins section.
* *
* Also configures: * Also configures:
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents * - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
@@ -39,7 +23,7 @@ function buildRoleTierModels(models: Record<Tier, string>): { dev: Record<string
* Read-modify-write to preserve existing config. * Read-modify-write to preserve existing config.
*/ */
export async function writePluginConfig( export async function writePluginConfig(
models: Record<Tier, string>, models: ModelConfig,
agentId?: string, agentId?: string,
projectExecution?: "parallel" | "sequential", projectExecution?: "parallel" | "sequential",
): Promise<void> { ): Promise<void> {
@@ -47,7 +31,7 @@ export async function writePluginConfig(
const config = JSON.parse(await fs.readFile(configPath, "utf-8")); const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
ensurePluginStructure(config); ensurePluginStructure(config);
config.plugins.entries.devclaw.config.models = buildRoleTierModels(models); config.plugins.entries.devclaw.config.models = models;
if (projectExecution) { if (projectExecution) {
config.plugins.entries.devclaw.config.projectExecution = projectExecution; config.plugins.entries.devclaw.config.projectExecution = projectExecution;

View File

@@ -4,12 +4,14 @@
* Coordinates: agent creation → model config → workspace scaffolding. * Coordinates: agent creation → model config → workspace scaffolding.
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command. * Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
*/ */
import { ALL_TIERS, allDefaultModels, type Tier } from "../tiers.js"; import { DEFAULT_MODELS } from "../tiers.js";
import { migrateChannelBinding } from "../binding-manager.js"; import { migrateChannelBinding } from "../binding-manager.js";
import { createAgent, resolveWorkspacePath } from "./agent.js"; import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js"; import { writePluginConfig } from "./config.js";
import { scaffoldWorkspace } from "./workspace.js"; import { scaffoldWorkspace } from "./workspace.js";
export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
export type SetupOpts = { export type SetupOpts = {
/** Create a new agent with this name. Mutually exclusive with agentId. */ /** Create a new agent with this name. Mutually exclusive with agentId. */
newAgentName?: string; newAgentName?: string;
@@ -21,8 +23,8 @@ export type SetupOpts = {
agentId?: string; agentId?: string;
/** Override workspace path (auto-detected from agent if not given). */ /** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string; workspacePath?: string;
/** Model overrides per tier. Missing tiers use defaults. */ /** Model overrides per role.level. Missing levels use defaults. */
models?: Partial<Record<Tier, string>>; models?: { dev?: Partial<Record<string, string>>; qa?: Partial<Record<string, string>> };
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential"; projectExecution?: "parallel" | "sequential";
}; };
@@ -31,7 +33,7 @@ export type SetupResult = {
agentId: string; agentId: string;
agentCreated: boolean; agentCreated: boolean;
workspacePath: string; workspacePath: string;
models: Record<Tier, string>; models: ModelConfig;
filesWritten: string[]; filesWritten: string[];
warnings: string[]; warnings: string[];
bindingMigrated?: { bindingMigrated?: {
@@ -107,14 +109,20 @@ async function tryMigrateBinding(
} }
} }
function buildModelConfig(overrides?: Partial<Record<Tier, string>>): Record<Tier, string> { function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
const models = allDefaultModels(); const dev: Record<string, string> = { ...DEFAULT_MODELS.dev };
if (overrides) { const qa: Record<string, string> = { ...DEFAULT_MODELS.qa };
for (const [tier, model] of Object.entries(overrides)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) { if (overrides?.dev) {
models[tier as Tier] = model; for (const [level, model] of Object.entries(overrides.dev)) {
} if (model) dev[level] = model;
} }
} }
return models; if (overrides?.qa) {
for (const [level, model] of Object.entries(overrides.qa)) {
if (model) qa[level] = model;
}
}
return { dev, qa };
} }

View File

@@ -8,6 +8,8 @@ import path from "node:path";
import { import {
AGENTS_MD_TEMPLATE, AGENTS_MD_TEMPLATE,
HEARTBEAT_MD_TEMPLATE, HEARTBEAT_MD_TEMPLATE,
DEFAULT_DEV_INSTRUCTIONS,
DEFAULT_QA_INSTRUCTIONS,
} from "../templates.js"; } from "../templates.js";
/** /**
@@ -34,6 +36,20 @@ export async function scaffoldWorkspace(workspacePath: string): Promise<string[]
filesWritten.push("projects/projects.json"); filesWritten.push("projects/projects.json");
} }
// projects/roles/default/ (fallback role instructions)
const defaultRolesDir = path.join(projectsDir, "roles", "default");
await fs.mkdir(defaultRolesDir, { recursive: true });
const devRolePath = path.join(defaultRolesDir, "dev.md");
if (!await fileExists(devRolePath)) {
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
filesWritten.push("projects/roles/default/dev.md");
}
const qaRolePath = path.join(defaultRolesDir, "qa.md");
if (!await fileExists(qaRolePath)) {
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
filesWritten.push("projects/roles/default/qa.md");
}
// log/ directory (audit.log created on first write) // log/ directory (audit.log created on first write)
const logDir = path.join(workspacePath, "log"); const logDir = path.join(workspacePath, "log");
await fs.mkdir(logDir, { recursive: true }); await fs.mkdir(logDir, { recursive: true });

View File

@@ -101,7 +101,7 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
| \`task_update\` | Update issue title, description, or labels | | \`task_update\` | Update issue title, description, or labels |
| \`status\` | Task queue and worker state per project (lightweight dashboard) | | \`status\` | Task queue and worker state per project (lightweight dashboard) |
| \`health\` | Scan worker health: zombies, stale workers, orphaned state. Pass fix=true to auto-fix | | \`health\` | Scan worker health: zombies, stale workers, orphaned state. Pass fix=true to auto-fix |
| \`work_start\` | End-to-end: label transition, tier assignment, session create/reuse, dispatch with role instructions | | \`work_start\` | End-to-end: label transition, level assignment, session create/reuse, dispatch with role instructions |
| \`work_finish\` | End-to-end: label transition, state update, issue close/reopen. Auto-ticks queue after completion. | | \`work_finish\` | End-to-end: label transition, state update, issue close/reopen. Auto-ticks queue after completion. |
### Pipeline Flow ### Pipeline Flow
@@ -118,19 +118,19 @@ Issue labels are the single source of truth for task state.
### Developer Assignment ### Developer Assignment
Evaluate each task and pass the appropriate developer tier to \`work_start\`: Evaluate each task and pass the appropriate developer level to \`work_start\`:
- **junior** — trivial: typos, single-file fix, quick change - **junior** — trivial: typos, single-file fix, quick change
- **medior** — standard: features, bug fixes, multi-file changes - **medior** — standard: features, bug fixes, multi-file changes
- **senior** — complex: architecture, system-wide refactoring, 5+ services - **senior** — complex: architecture, system-wide refactoring, 5+ services
- **qa** — review: code inspection, validation, test runs - **reviewer** — QA: code inspection, validation, test runs
### Picking Up Work ### Picking Up Work
1. Use \`status\` to see what's available 1. Use \`status\` to see what's available
2. Priority: \`To Improve\` (fix failures) > \`To Test\` (QA) > \`To Do\` (new work) 2. Priority: \`To Improve\` (fix failures) > \`To Test\` (QA) > \`To Do\` (new work)
3. Evaluate complexity, choose developer tier 3. Evaluate complexity, choose developer level
4. Call \`work_start\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`model\` (tier name) 4. Call \`work_start\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`level\`
5. Post the \`announcement\` from the tool response to Telegram 5. Post the \`announcement\` from the tool response to Telegram
### When Work Completes ### When Work Completes
@@ -146,7 +146,7 @@ The response includes \`tickPickups\` showing any tasks that were auto-dispatche
### Prompt Instructions ### Prompt Instructions
Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/prompts/<project-name>/<role>.md\` in the workspace. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project. Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/roles/<project-name>/<role>.md\` in the workspace, falling back to \`projects/roles/default/<role>.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
### Heartbeats ### Heartbeats

View File

@@ -1,17 +1,16 @@
/** /**
* Developer tier definitions and model resolution. * Developer level definitions and model resolution.
* *
* Tier names always include the role prefix: "dev.junior", "qa.reviewer", etc. * Level names are plain: "junior", "senior", "reviewer", etc.
* This makes tier names globally unique and self-documenting. * Role context (dev/qa) is always provided by the caller or parent structure.
*/ */
export const DEV_TIERS = ["dev.junior", "dev.medior", "dev.senior"] as const; export const DEV_LEVELS = ["junior", "medior", "senior"] as const;
export const QA_TIERS = ["qa.reviewer", "qa.tester"] as const; export const QA_LEVELS = ["reviewer", "tester"] as const;
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
export type DevTier = (typeof DEV_TIERS)[number]; export type DevLevel = (typeof DEV_LEVELS)[number];
export type QaTier = (typeof QA_TIERS)[number]; export type QaLevel = (typeof QA_LEVELS)[number];
export type Tier = (typeof ALL_TIERS)[number]; export type Level = DevLevel | QaLevel;
/** Default models, nested by role. */ /** Default models, nested by role. */
export const DEFAULT_MODELS = { export const DEFAULT_MODELS = {
@@ -27,7 +26,7 @@ export const DEFAULT_MODELS = {
}; };
/** Emoji used in announcements, nested by role. */ /** Emoji used in announcements, nested by role. */
export const TIER_EMOJI = { export const LEVEL_EMOJI = {
dev: { dev: {
junior: "⚡", junior: "⚡",
medior: "🔧", medior: "🔧",
@@ -39,77 +38,52 @@ export const TIER_EMOJI = {
}, },
}; };
/** Check if a string is a valid tier name. */ /** Check if a level belongs to the dev role. */
export function isTier(value: string): value is Tier { export function isDevLevel(value: string): value is DevLevel {
return (ALL_TIERS as readonly string[]).includes(value); return (DEV_LEVELS as readonly string[]).includes(value);
} }
/** Check if a tier belongs to the dev role. */ /** Check if a level belongs to the qa role. */
export function isDevTier(value: string): value is DevTier { export function isQaLevel(value: string): value is QaLevel {
return (DEV_TIERS as readonly string[]).includes(value); return (QA_LEVELS as readonly string[]).includes(value);
} }
/** Extract the role from a tier name (e.g. "dev.junior" → "dev"). */ /** Determine the role a level belongs to. */
export function tierRole(tier: string): "dev" | "qa" | undefined { export function levelRole(level: string): "dev" | "qa" | undefined {
if (tier.startsWith("dev.")) return "dev"; if (isDevLevel(level)) return "dev";
if (tier.startsWith("qa.")) return "qa"; if (isQaLevel(level)) return "qa";
return undefined; return undefined;
} }
/** Extract the short name from a tier (e.g. "dev.junior" → "junior"). */ /** Get the default model for a role + level. */
export function tierName(tier: string): string { export function defaultModel(role: "dev" | "qa", level: string): string | undefined {
const dot = tier.indexOf("."); return (DEFAULT_MODELS[role] as Record<string, string>)[level];
return dot >= 0 ? tier.slice(dot + 1) : tier;
} }
/** Look up a value from a nested role structure using a full tier name. */ /** Get the emoji for a role + level. */
function lookupNested<T>(map: Record<string, Record<string, T>>, tier: string): T | undefined { export function levelEmoji(role: "dev" | "qa", level: string): string | undefined {
const role = tierRole(tier); return (LEVEL_EMOJI[role] as Record<string, string>)[level];
if (!role) return undefined;
return map[role]?.[tierName(tier)];
}
/** Get the default model for a tier. */
export function defaultModel(tier: string): string | undefined {
return lookupNested(DEFAULT_MODELS, tier);
}
/** Get the emoji for a tier. */
export function tierEmoji(tier: string): string | undefined {
return lookupNested(TIER_EMOJI, tier);
}
/** Build a flat Record<Tier, string> of all default models. */
export function allDefaultModels(): Record<Tier, string> {
const result = {} as Record<Tier, string>;
for (const tier of ALL_TIERS) {
result[tier] = defaultModel(tier)!;
}
return result;
} }
/** /**
* Resolve a tier name to a full model ID. * Resolve a level to a full model ID.
* *
* Resolution order: * Resolution order:
* 1. Parse "role.name" → look up config `models.<role>.<name>` * 1. Plugin config `models.<role>.<level>`
* 2. DEFAULT_MODELS[role][name] * 2. DEFAULT_MODELS[role][level]
* 3. Passthrough (treat as raw model ID) * 3. Passthrough (treat as raw model ID)
*/ */
export function resolveTierToModel( export function resolveModel(
tier: string, role: "dev" | "qa",
level: string,
pluginConfig?: Record<string, unknown>, pluginConfig?: Record<string, unknown>,
): string { ): string {
const models = (pluginConfig as { models?: Record<string, unknown> })?.models; const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
if (models && typeof models === "object") { if (models && typeof models === "object") {
const role = tierRole(tier); const roleModels = models[role] as Record<string, string> | undefined;
const name = tierName(tier); if (roleModels?.[level]) return roleModels[level];
if (role) {
const roleModels = models[role] as Record<string, string> | undefined;
if (roleModels?.[name]) return roleModels[name];
}
} }
return defaultModel(tier) ?? tier; return defaultModel(role, level) ?? level;
} }

View File

@@ -15,7 +15,7 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
import { resolveRepoPath } from "../projects.js"; import { resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { DEV_TIERS, QA_TIERS } from "../tiers.js"; import { DEV_LEVELS, QA_LEVELS } from "../tiers.js";
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js"; import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js";
import { detectContext, generateGuardrails } from "../context-guard.js"; import { detectContext, generateGuardrails } from "../context-guard.js";
@@ -24,7 +24,7 @@ import { detectContext, generateGuardrails } from "../context-guard.js";
* Returns true if files were created, false if they already existed. * Returns true if files were created, false if they already existed.
*/ */
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> { async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
const projectDir = path.join(workspaceDir, "projects", "prompts", projectName); const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
const projectDev = path.join(projectDir, "dev.md"); const projectDev = path.join(projectDir, "dev.md");
@@ -185,8 +185,8 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
deployBranch, deployBranch,
channel: context.channel, channel: context.channel,
roleExecution, roleExecution,
dev: emptyWorkerState([...DEV_TIERS]), dev: emptyWorkerState([...DEV_LEVELS]),
qa: emptyWorkerState([...QA_TIERS]), qa: emptyWorkerState([...QA_LEVELS]),
}; };
await writeProjects(workspaceDir, data); await writeProjects(workspaceDir, data);

View File

@@ -1,20 +1,20 @@
/** /**
* setup — Agent-driven DevClaw setup. * setup — Agent-driven DevClaw setup.
* *
* Creates agent, configures model tiers, writes workspace files. * Creates agent, configures model levels, writes workspace files.
* Thin wrapper around lib/setup/. * Thin wrapper around lib/setup/.
*/ */
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { runSetup } from "../setup/index.js"; import { runSetup, type SetupOpts } from "../setup/index.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js"; import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js";
export function createSetupTool(api: OpenClawPluginApi) { export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
name: "setup", name: "setup",
label: "Setup", label: "Setup",
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model tier config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`, 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.`,
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -35,11 +35,11 @@ export function createSetupTool(api: OpenClawPluginApi) {
}, },
models: { models: {
type: "object", type: "object",
description: "Model overrides per role and tier.", description: "Model overrides per role and level.",
properties: { properties: {
dev: { dev: {
type: "object", type: "object",
description: "Developer tier models", description: "Developer level models",
properties: { properties: {
junior: { junior: {
type: "string", type: "string",
@@ -57,7 +57,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
}, },
qa: { qa: {
type: "object", type: "object",
description: "QA tier models", description: "QA level models",
properties: { properties: {
reviewer: { reviewer: {
type: "string", type: "string",
@@ -87,7 +87,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
migrateFrom: params.migrateFrom as string | undefined, migrateFrom: params.migrateFrom as string | undefined,
agentId: params.newAgentName ? undefined : ctx.agentId, agentId: params.newAgentName ? undefined : ctx.agentId,
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir, workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
models: params.models as Partial<Record<Tier, string>> | undefined, models: params.models as SetupOpts["models"],
projectExecution: params.projectExecution as projectExecution: params.projectExecution as
| "parallel" | "parallel"
| "sequential" | "sequential"
@@ -108,7 +108,8 @@ export function createSetupTool(api: OpenClawPluginApi) {
} }
lines.push( lines.push(
"Models:", "Models:",
...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`), ...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`),
...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`),
"", "",
"Files:", "Files:",
...result.filesWritten.map((f) => ` ${f}`), ...result.filesWritten.map((f) => ` ${f}`),

View File

@@ -62,8 +62,8 @@ export function createStatusTool(api: OpenClawPluginApi) {
name: project.name, name: project.name,
groupId: pid, groupId: pid,
roleExecution: project.roleExecution ?? "parallel", roleExecution: project.roleExecution ?? "parallel",
dev: { active: project.dev.active, issueId: project.dev.issueId, tier: project.dev.tier, startTime: project.dev.startTime }, 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, tier: project.qa.tier, startTime: project.qa.startTime }, qa: { active: project.qa.active, issueId: project.qa.issueId, level: project.qa.level, startTime: project.qa.startTime },
queue: { toImprove: count("To Improve"), toTest: count("To Test"), toDo: count("To Do") }, queue: { toImprove: count("To Improve"), toTest: count("To Test"), toDo: count("To Do") },
}; };
}), }),

View File

@@ -22,17 +22,17 @@ import type { StateLabel } from "../providers/provider.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const INACTIVE_WORKER: WorkerState = { const INACTIVE_WORKER: WorkerState = {
active: false, issueId: null, startTime: null, tier: null, sessions: {}, active: false, issueId: null, startTime: null, level: null, sessions: {},
}; };
const ACTIVE_DEV: WorkerState = { const ACTIVE_DEV: WorkerState = {
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "dev.medior", active: true, issueId: "42", startTime: new Date().toISOString(), level: "medior",
sessions: { "dev.medior": "session-dev-42" }, sessions: { medior: "session-dev-42" },
}; };
const ACTIVE_QA: WorkerState = { const ACTIVE_QA: WorkerState = {
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "qa.reviewer", active: true, issueId: "42", startTime: new Date().toISOString(), level: "reviewer",
sessions: { "qa.reviewer": "session-qa-42" }, sessions: { reviewer: "session-qa-42" },
}; };
function makeProject(overrides: Partial<Project> = {}): Project { function makeProject(overrides: Partial<Project> = {}): Project {
@@ -280,11 +280,11 @@ describe("work_heartbeat: worker slot guards", () => {
}); });
}); });
describe("work_heartbeat: tier assignment", () => { describe("work_heartbeat: level assignment", () => {
afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); });
it("uses label-based tier when present", async () => { it("uses label-based level when present", async () => {
// Given: issue with "dev.senior" label → tier should be "dev.senior" // Given: issue with "dev.senior" label → level should be "senior"
const workspaceDir = await setupWorkspace({ const workspaceDir = await setupWorkspace({
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
}); });
@@ -299,12 +299,12 @@ describe("work_heartbeat: tier assignment", () => {
const pickup = result.pickups.find((p) => p.role === "dev"); const pickup = result.pickups.find((p) => p.role === "dev");
assert.ok(pickup); assert.ok(pickup);
assert.strictEqual(pickup.tier, "dev.senior", "Should use label-based tier"); assert.strictEqual(pickup.level, "senior", "Should use label-based level");
}); });
it("overrides to reviewer tier for qa role regardless of label", async () => { it("overrides to reviewer level for qa role regardless of label", async () => {
// Given: issue with "dev.senior" label but picked up by QA // Given: issue with "dev.senior" label but picked up by QA
// Expected: tier = "qa.reviewer" (QA always uses reviewer tier) // Expected: level = "reviewer" (QA always uses reviewer level)
const workspaceDir = await setupWorkspace({ const workspaceDir = await setupWorkspace({
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
}); });
@@ -319,11 +319,11 @@ describe("work_heartbeat: tier assignment", () => {
const qaPickup = result.pickups.find((p) => p.role === "qa"); const qaPickup = result.pickups.find((p) => p.role === "qa");
assert.ok(qaPickup); assert.ok(qaPickup);
assert.strictEqual(qaPickup.tier, "qa.reviewer", "QA always uses reviewer tier regardless of issue label"); assert.strictEqual(qaPickup.level, "reviewer", "QA always uses reviewer level regardless of issue label");
}); });
it("falls back to heuristic when no tier label", async () => { it("falls back to heuristic when no level label", async () => {
// Given: issue with no tier label → heuristic selects based on title/description // Given: issue with no level label → heuristic selects based on title/description
const workspaceDir = await setupWorkspace({ const workspaceDir = await setupWorkspace({
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
}); });
@@ -339,7 +339,7 @@ describe("work_heartbeat: tier assignment", () => {
const pickup = result.pickups.find((p) => p.role === "dev"); const pickup = result.pickups.find((p) => p.role === "dev");
assert.ok(pickup); assert.ok(pickup);
// Heuristic should select junior for a typo fix // Heuristic should select junior for a typo fix
assert.strictEqual(pickup.tier, "dev.junior", "Heuristic should assign junior for simple typo fix"); assert.strictEqual(pickup.level, "junior", "Heuristic should assign junior for simple typo fix");
}); });
}); });
@@ -394,7 +394,7 @@ describe("work_heartbeat: TickAction output shape", () => {
assert.strictEqual(pickup.issueTitle, "Build feature"); assert.strictEqual(pickup.issueTitle, "Build feature");
assert.strictEqual(pickup.issueUrl, "https://github.com/test/alpha/issues/10"); assert.strictEqual(pickup.issueUrl, "https://github.com/test/alpha/issues/10");
assert.ok(["dev", "qa"].includes(pickup.role)); assert.ok(["dev", "qa"].includes(pickup.role));
assert.ok(typeof pickup.tier === "string"); assert.ok(typeof pickup.level === "string");
assert.ok(["spawn", "send"].includes(pickup.sessionAction)); assert.ok(["spawn", "send"].includes(pickup.sessionAction));
assert.ok(pickup.announcement.includes("[DRY RUN]")); assert.ok(pickup.announcement.includes("[DRY RUN]"));
}); });

View File

@@ -2,33 +2,33 @@
* work_start — Pick up a task from the issue queue. * work_start — Pick up a task from the issue queue.
* *
* Context-aware: ONLY works in project group chats. * Context-aware: ONLY works in project group chats.
* Auto-detects: projectGroupId, role, tier, issueId. * Auto-detects: projectGroupId, role, level, issueId.
* After dispatch, ticks the project queue to fill parallel slots. * After dispatch, ticks the project queue to fill parallel slots.
*/ */
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import type { StateLabel } from "../providers/provider.js"; import type { StateLabel } from "../providers/provider.js";
import { selectTier } from "../model-selector.js"; import { selectLevel } from "../model-selector.js";
import { getWorker } from "../projects.js"; import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { notify, getNotificationConfig } from "../notify.js"; import { notify, getNotificationConfig } from "../notify.js";
import { findNextIssue, detectRoleFromLabel, detectTierFromLabels } from "../services/tick.js"; import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
import { isDevTier } from "../tiers.js"; import { isDevLevel } from "../tiers.js";
import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js"; import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js";
export function createWorkStartTool(api: OpenClawPluginApi) { export function createWorkStartTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
name: "work_start", name: "work_start",
label: "Work Start", label: "Work Start",
description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, tier assignment, session creation, dispatch, audit, and ticks the queue to fill parallel slots.`, description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, level assignment, session creation, dispatch, audit, and ticks the queue to fill parallel slots.`,
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." }, issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." }, role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." },
projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." }, projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." },
tier: { type: "string", description: "Developer tier (junior/medior/senior/qa). Auto-detected if omitted." }, level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." },
}, },
}, },
@@ -36,7 +36,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const issueIdParam = params.issueId as number | undefined; const issueIdParam = params.issueId as number | undefined;
const roleParam = params.role as "dev" | "qa" | undefined; const roleParam = params.role as "dev" | "qa" | undefined;
const groupIdParam = params.projectGroupId as string | undefined; const groupIdParam = params.projectGroupId as string | undefined;
const tierParam = params.tier as string | undefined; const levelParam = (params.level ?? params.tier) as string | undefined;
const workspaceDir = requireWorkspaceDir(ctx); const workspaceDir = requireWorkspaceDir(ctx);
// Context guard: group only // Context guard: group only
@@ -76,20 +76,20 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`); if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
} }
// Select tier // Select level
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
let selectedTier: string, tierReason: string, tierSource: string; let selectedLevel: string, levelReason: string, levelSource: string;
if (tierParam) { if (levelParam) {
selectedTier = tierParam; tierReason = "LLM-selected"; tierSource = "llm"; selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
} else { } else {
const labelTier = detectTierFromLabels(issue.labels); const labelLevel = detectLevelFromLabels(issue.labels);
if (labelTier) { if (labelLevel) {
if (role === "qa" && isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = `QA overrides dev tier "${labelTier}"`; tierSource = "role-override"; } 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" && !isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; } else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; }
else { selectedTier = labelTier; tierReason = `Label: "${labelTier}"`; tierSource = "label"; } else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; }
} else { } else {
const s = selectTier(issue.title, issue.description ?? "", role); const s = selectLevel(issue.title, issue.description ?? "", role);
selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
} }
} }
@@ -98,7 +98,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const dr = await dispatchTask({ const dr = await dispatchTask({
workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid, workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid,
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel, role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
pluginConfig, sessionKey: ctx.sessionKey, pluginConfig, sessionKey: ctx.sessionKey,
}); });
@@ -106,7 +106,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
// Notify // Notify
const notifyConfig = getNotificationConfig(pluginConfig); const notifyConfig = getNotificationConfig(pluginConfig);
await notify( await notify(
{ type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, tier: dr.tier, sessionAction: dr.sessionAction }, { type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, level: dr.level, sessionAction: dr.sessionAction },
{ workspaceDir, config: notifyConfig, groupId, channel: context.channel }, { workspaceDir, config: notifyConfig, groupId, channel: context.channel },
); );
@@ -119,10 +119,10 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const output: Record<string, unknown> = { const output: Record<string, unknown> = {
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
role, tier: dr.tier, model: dr.model, sessionAction: dr.sessionAction, role, level: dr.level, model: dr.model, sessionAction: dr.sessionAction,
announcement: dr.announcement, labelTransition: `${currentLabel}${targetLabel}`, announcement: dr.announcement, labelTransition: `${currentLabel}${targetLabel}`,
tierReason, tierSource, levelReason, levelSource,
autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, tier: !tierParam }, autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, level: !levelParam },
}; };
if (tickPickups.length) output.tickPickups = tickPickups; if (tickPickups.length) output.tickPickups = tickPickups;