feat: refactor model selection to use developer tiers

- Replaced raw model aliases with developer tiers (junior, medior, senior, qa) in dispatch and model selection logic.
- Updated `dispatchTask` to resolve models based on tiers and plugin configuration.
- Modified `selectModel` to return tier names instead of model aliases based on task description.
- Implemented migration logic for transitioning from old model aliases to new tier names in worker state.
- Added setup logic for agent creation and model configuration in `setup.ts`.
- Created shared templates for workspace files and instructions for DEV/QA workers.
- Enhanced project registration to scaffold role files based on developer tiers.
- Updated task management tools to reflect changes in model selection and tier assignment.
- Introduced a new `devclaw_setup` tool for agent-driven setup and configuration.
- Updated plugin configuration schema to support model mapping per developer tier.
This commit is contained in:
Lauren ten Hoor
2026-02-09 13:41:22 +08:00
parent 8a79755e4c
commit aa8e8dbd1b
16 changed files with 1162 additions and 257 deletions

208
lib/cli.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* cli.ts — CLI command for `openclaw devclaw setup`.
*
* Interactive and non-interactive modes for onboarding.
*/
import { createInterface } from "node:readline";
import { runSetup, type SetupOpts } from "./setup.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
type CliArgs = {
/** Create a new agent */
newAgent?: string;
/** Use existing agent */
agent?: string;
/** Direct workspace path */
workspace?: string;
/** Model overrides */
junior?: string;
medior?: string;
senior?: string;
qa?: string;
/** Skip prompts */
nonInteractive?: boolean;
};
/**
* Parse CLI arguments from argv-style array.
* Expects: ["setup", "--new-agent", "name", "--junior", "model", ...]
*/
export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
switch (arg) {
case "--new-agent":
args.newAgent = next;
i++;
break;
case "--agent":
args.agent = next;
i++;
break;
case "--workspace":
args.workspace = next;
i++;
break;
case "--junior":
args.junior = next;
i++;
break;
case "--medior":
args.medior = next;
i++;
break;
case "--senior":
args.senior = next;
i++;
break;
case "--qa":
args.qa = next;
i++;
break;
case "--non-interactive":
args.nonInteractive = true;
break;
}
}
return args;
}
/**
* Run the interactive setup wizard.
*/
async function interactiveSetup(): Promise<SetupOpts> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = (question: string): Promise<string> =>
new Promise((resolve) => rl.question(question, resolve));
console.log("");
console.log("DevClaw Setup");
console.log("=============");
console.log("");
// Step 1: Agent
console.log("Step 1: Agent");
console.log("─────────────");
const agentChoice = await ask(
"Create a new agent or use an existing one? [new/existing]: ",
);
let newAgentName: string | undefined;
let agentId: string | undefined;
if (agentChoice.toLowerCase().startsWith("n")) {
newAgentName = await ask("Agent name: ");
if (!newAgentName.trim()) {
rl.close();
throw new Error("Agent name cannot be empty");
}
newAgentName = newAgentName.trim();
} else {
agentId = await ask("Agent ID: ");
if (!agentId.trim()) {
rl.close();
throw new Error("Agent ID cannot be empty");
}
agentId = agentId.trim();
}
// Step 2: Models
console.log("");
console.log("Step 2: Developer Team (models)");
console.log("───────────────────────────────");
console.log("Press Enter to accept defaults.");
console.log("");
const models: Partial<Record<Tier, string>> = {};
for (const tier of ALL_TIERS) {
const label =
tier === "junior"
? "Junior dev (fast, cheap tasks)"
: tier === "medior"
? "Medior dev (standard tasks)"
: tier === "senior"
? "Senior dev (complex tasks)"
: "QA engineer (code review)";
const answer = await ask(` ${label} [${DEFAULT_MODELS[tier]}]: `);
if (answer.trim()) {
models[tier] = answer.trim();
}
}
rl.close();
console.log("");
console.log("Step 3: Workspace");
console.log("─────────────────");
return { newAgentName, agentId, models };
}
/**
* Main CLI entry point.
*/
export async function runCli(argv: string[]): Promise<void> {
const args = parseArgs(argv);
let opts: SetupOpts;
if (args.nonInteractive || args.newAgent || args.agent || args.workspace) {
// Non-interactive mode
const models: Partial<Record<Tier, string>> = {};
if (args.junior) models.junior = args.junior;
if (args.medior) models.medior = args.medior;
if (args.senior) models.senior = args.senior;
if (args.qa) models.qa = args.qa;
opts = {
newAgentName: args.newAgent,
agentId: args.agent,
workspacePath: args.workspace,
models: Object.keys(models).length > 0 ? models : undefined,
};
} else {
// Interactive mode
opts = await interactiveSetup();
}
console.log("");
const result = await runSetup(opts);
// Print results
if (result.agentCreated) {
console.log(` Agent "${result.agentId}" created`);
}
console.log(` Models configured:`);
for (const tier of ALL_TIERS) {
console.log(` ${tier}: ${result.models[tier]}`);
}
console.log(` Files written:`);
for (const file of result.filesWritten) {
console.log(` ${file}`);
}
if (result.warnings.length > 0) {
console.log("");
console.log(" Warnings:");
for (const w of result.warnings) {
console.log(` ${w}`);
}
}
console.log("");
console.log("Done! Next steps:");
console.log(" 1. Add bot to a Telegram group");
console.log(
' 2. Register a project: "Register project <name> at <repo> for group <id>"',
);
console.log(" 3. Create your first issue and pick it up");
console.log("");
}

View File

@@ -18,16 +18,10 @@ import {
} from "./projects.js";
import { selectModel } from "./model-selector.js";
import { log as auditLog } from "./audit.js";
import { resolveModel, TIER_EMOJI, isTier } from "./tiers.js";
const execFileAsync = promisify(execFile);
export const MODEL_MAP: Record<string, string> = {
haiku: "anthropic/claude-haiku-4-5",
sonnet: "anthropic/claude-sonnet-4-5",
opus: "anthropic/claude-opus-4-5",
grok: "github-copilot/grok-code-fast-1",
};
export type DispatchOpts = {
workspaceDir: string;
agentId: string;
@@ -38,6 +32,7 @@ export type DispatchOpts = {
issueDescription: string;
issueUrl: string;
role: "dev" | "qa";
/** Developer tier (junior, medior, senior, qa) or raw model ID */
modelAlias: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
fromLabel: string;
@@ -45,6 +40,8 @@ export type DispatchOpts = {
toLabel: string;
/** Function to transition labels (injected to avoid gitlab.ts dependency) */
transitionLabel: (issueId: number, from: string, to: string) => Promise<void>;
/** Plugin config for model resolution */
pluginConfig?: Record<string, unknown>;
};
export type DispatchResult = {
@@ -118,9 +115,10 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
workspaceDir, agentId, groupId, project, issueId,
issueTitle, issueDescription, issueUrl,
role, modelAlias, fromLabel, toLabel, transitionLabel,
pluginConfig,
} = opts;
const fullModel = MODEL_MAP[modelAlias] ?? modelAlias;
const fullModel = resolveModel(modelAlias, pluginConfig);
const worker = getWorker(project, role);
const existingSessionKey = getSessionForModel(worker, modelAlias);
const sessionAction = existingSessionKey ? "send" : "spawn";
@@ -210,7 +208,7 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
issue: issueId,
issueTitle,
role,
model: modelAlias,
tier: modelAlias,
sessionAction,
sessionKey,
labelTransition: `${fromLabel}${toLabel}`,
@@ -219,14 +217,14 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
await auditLog(workspaceDir, "model_selection", {
issue: issueId,
role,
selected: modelAlias,
tier: modelAlias,
fullModel,
});
// Build announcement
const emoji = role === "dev"
? (modelAlias === "haiku" ? "⚡" : modelAlias === "opus" ? "🧠" : "🔧")
: "🔍";
const emoji = isTier(modelAlias)
? TIER_EMOJI[modelAlias]
: (role === "qa" ? "🔍" : "🔧");
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}`;

View File

@@ -1,11 +1,11 @@
/**
* Model selection for dev/qa tasks.
* MVP: Simple heuristic-based selection. LLM-based analysis can be added later.
* Keyword heuristic fallback — used when the orchestrator doesn't specify a tier.
* Returns tier names (junior, medior, senior, qa) instead of model aliases.
*/
export type ModelRecommendation = {
model: string;
alias: string;
tier: string;
reason: string;
};
@@ -39,13 +39,13 @@ const COMPLEX_KEYWORDS = [
];
/**
* Select appropriate model based on task description.
* Select appropriate developer tier based on task description.
*
* Model tiers:
* - haiku: very simple (typos, single-file fixes, CSS tweaks)
* - grok: default QA (code inspection, validation, test runs)
* - sonnet: default DEV (features, bug fixes, multi-file changes)
* - opus: deep/architectural (system-wide refactoring, novel design)
* Developer tiers:
* - junior: very simple (typos, single-file fixes, CSS tweaks)
* - medior: standard DEV (features, bug fixes, multi-file changes)
* - senior: deep/architectural (system-wide refactoring, novel design)
* - qa: all QA tasks (code inspection, validation, test runs)
*/
export function selectModel(
issueTitle: string,
@@ -54,9 +54,8 @@ export function selectModel(
): ModelRecommendation {
if (role === "qa") {
return {
model: "github-copilot/grok-code-fast-1",
alias: "grok",
reason: "Default QA model for code inspection and validation",
tier: "qa",
reason: "Default QA tier for code inspection and validation",
};
}
@@ -67,8 +66,7 @@ export function selectModel(
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
if (isSimple && wordCount < 100) {
return {
model: "anthropic/claude-haiku-4-5",
alias: "haiku",
tier: "junior",
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
};
}
@@ -77,16 +75,14 @@ export function selectModel(
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
if (isComplex || wordCount > 500) {
return {
model: "anthropic/claude-opus-4-5",
alias: "opus",
tier: "senior",
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
};
}
// Default: sonnet for standard dev work
// Default: medior for standard dev work
return {
model: "anthropic/claude-sonnet-4-5",
alias: "sonnet",
tier: "medior",
reason: "Standard dev task — multi-file changes, features, bug fixes",
};
}

View File

@@ -4,6 +4,7 @@
*/
import fs from "node:fs/promises";
import path from "node:path";
import { TIER_MIGRATION } from "./tiers.js";
export type WorkerState = {
active: boolean;
@@ -30,41 +31,64 @@ export type ProjectsData = {
};
/**
* Migrate old WorkerState schema (sessionId field) to new sessions map.
* Called transparently on read — old data is converted in memory,
* persisted on next write.
* Migrate old WorkerState schema to current format.
*
* Handles two migrations:
* 1. Old sessionId field → sessions map (pre-sessions era)
* 2. Model-alias session keys → tier-name keys (haiku→junior, sonnet→medior, etc.)
*/
function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
// Already migrated — has sessions map
if (worker.sessions && typeof worker.sessions === "object") {
return worker as unknown as WorkerState;
// Migration 1: old sessionId field → sessions map
if (!worker.sessions || typeof worker.sessions !== "object") {
const sessionId = worker.sessionId as string | null;
const model = worker.model as string | null;
const sessions: Record<string, string | null> = {};
if (sessionId && model) {
// Apply tier migration to the model key too
const tierKey = TIER_MIGRATION[model] ?? model;
sessions[tierKey] = sessionId;
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
model: model ? (TIER_MIGRATION[model] ?? model) : null,
sessions,
};
}
// Old schema: { sessionId, model, ... }
const sessionId = worker.sessionId as string | null;
const model = worker.model as string | null;
const sessions: Record<string, string | null> = {};
// Migration 2: model-alias session keys → tier-name keys
const oldSessions = worker.sessions as Record<string, string | null>;
const needsMigration = Object.keys(oldSessions).some((key) => key in TIER_MIGRATION);
if (sessionId && model) {
sessions[model] = sessionId;
if (needsMigration) {
const newSessions: Record<string, string | null> = {};
for (const [key, value] of Object.entries(oldSessions)) {
const newKey = TIER_MIGRATION[key] ?? key;
newSessions[newKey] = value;
}
const model = worker.model as string | null;
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
model: model ? (TIER_MIGRATION[model] ?? model) : null,
sessions: newSessions,
};
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
model,
sessions,
};
return worker as unknown as WorkerState;
}
/**
* Create a blank WorkerState with null sessions for given model aliases.
* Create a blank WorkerState with null sessions for given tier names.
*/
export function emptyWorkerState(aliases: string[]): WorkerState {
export function emptyWorkerState(tiers: string[]): WorkerState {
const sessions: Record<string, string | null> = {};
for (const alias of aliases) {
sessions[alias] = null;
for (const tier of tiers) {
sessions[tier] = null;
}
return {
active: false,
@@ -76,13 +100,13 @@ export function emptyWorkerState(aliases: string[]): WorkerState {
}
/**
* Get session key for a specific model alias from a worker's sessions map.
* Get session key for a specific tier from a worker's sessions map.
*/
export function getSessionForModel(
worker: WorkerState,
modelAlias: string,
tier: string,
): string | null {
return worker.sessions[modelAlias] ?? null;
return worker.sessions[tier] ?? null;
}
function projectsPath(workspaceDir: string): string {
@@ -163,7 +187,7 @@ export async function updateWorker(
/**
* Mark a worker as active with a new task.
* Sets active=true, issueId, model. Stores session key in sessions[model].
* Sets active=true, issueId, model (tier). Stores session key in sessions[tier].
*/
export async function activateWorker(
workspaceDir: string,
@@ -181,7 +205,7 @@ export async function activateWorker(
issueId: params.issueId,
model: params.model,
};
// Store session key in the sessions map for this model
// Store session key in the sessions map for this tier
if (params.sessionKey !== undefined) {
updates.sessions = { [params.model]: params.sessionKey };
}

263
lib/setup.ts Normal file
View File

@@ -0,0 +1,263 @@
/**
* setup.ts — Shared setup logic for DevClaw onboarding.
*
* Used by both the `devclaw_setup` tool and the `openclaw devclaw setup` CLI command.
* Handles: agent creation, model configuration, workspace file writes.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
import {
AGENTS_MD_TEMPLATE,
HEARTBEAT_MD_TEMPLATE,
DEFAULT_DEV_INSTRUCTIONS,
DEFAULT_QA_INSTRUCTIONS,
} from "./templates.js";
const execFileAsync = promisify(execFile);
export type SetupOpts = {
/** Create a new agent with this name. Mutually exclusive with agentId. */
newAgentName?: string;
/** Use an existing agent by ID. Mutually exclusive with newAgentName. */
agentId?: string;
/** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string;
/** Model overrides per tier. Missing tiers use defaults. */
models?: Partial<Record<Tier, string>>;
};
export type SetupResult = {
agentId: string;
agentCreated: boolean;
workspacePath: string;
models: Record<Tier, string>;
filesWritten: string[];
warnings: string[];
};
/**
* Run the full DevClaw setup.
*
* 1. Create agent (optional) or resolve existing workspace
* 2. Merge model config and write to openclaw.json
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
*/
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
const warnings: string[] = [];
const filesWritten: string[] = [];
let agentId: string;
let agentCreated = false;
let workspacePath: string;
// --- Step 1: Agent ---
if (opts.newAgentName) {
const result = await createAgent(opts.newAgentName);
agentId = result.agentId;
workspacePath = result.workspacePath;
agentCreated = true;
} else if (opts.agentId) {
agentId = opts.agentId;
workspacePath = opts.workspacePath ?? await resolveWorkspacePath(agentId);
} else if (opts.workspacePath) {
agentId = "unknown";
workspacePath = opts.workspacePath;
} else {
throw new Error(
"Setup requires either newAgentName, agentId, or workspacePath",
);
}
// --- Step 2: Models ---
const models = { ...DEFAULT_MODELS };
if (opts.models) {
for (const [tier, model] of Object.entries(opts.models)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) {
models[tier as Tier] = model;
}
}
}
// Write plugin config to openclaw.json
await writePluginConfig(models);
// --- Step 3: Workspace files ---
// AGENTS.md (backup existing)
const agentsMdPath = path.join(workspacePath, "AGENTS.md");
await backupAndWrite(agentsMdPath, AGENTS_MD_TEMPLATE);
filesWritten.push("AGENTS.md");
// HEARTBEAT.md
const heartbeatPath = path.join(workspacePath, "HEARTBEAT.md");
await backupAndWrite(heartbeatPath, HEARTBEAT_MD_TEMPLATE);
filesWritten.push("HEARTBEAT.md");
// roles/default/dev.md and qa.md
const rolesDefaultDir = path.join(workspacePath, "roles", "default");
await fs.mkdir(rolesDefaultDir, { recursive: true });
const devRolePath = path.join(rolesDefaultDir, "dev.md");
const qaRolePath = path.join(rolesDefaultDir, "qa.md");
if (!await fileExists(devRolePath)) {
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/dev.md");
}
if (!await fileExists(qaRolePath)) {
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/qa.md");
}
// memory/projects.json
const memoryDir = path.join(workspacePath, "memory");
await fs.mkdir(memoryDir, { recursive: true });
const projectsJsonPath = path.join(memoryDir, "projects.json");
if (!await fileExists(projectsJsonPath)) {
await fs.writeFile(
projectsJsonPath,
JSON.stringify({ projects: {} }, null, 2) + "\n",
"utf-8",
);
filesWritten.push("memory/projects.json");
}
return {
agentId,
agentCreated,
workspacePath,
models,
filesWritten,
warnings,
};
}
/**
* Create a new agent via `openclaw agents add`.
*/
async function createAgent(
name: string,
): Promise<{ agentId: string; workspacePath: string }> {
// Generate ID from name (lowercase, hyphenated)
const agentId = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const workspacePath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
`workspace-${agentId}`,
);
try {
await execFileAsync("openclaw", [
"agents",
"add",
agentId,
"--name",
name,
"--workspace",
workspacePath,
"--non-interactive",
], { timeout: 30_000 });
} catch (err) {
throw new Error(
`Failed to create agent "${name}": ${(err as Error).message}`,
);
}
// openclaw agents add creates a .git dir in the workspace — remove it
const gitDir = path.join(workspacePath, ".git");
try {
await fs.rm(gitDir, { recursive: true });
} catch {
// May not exist — that's fine
}
return { agentId, workspacePath };
}
/**
* Resolve workspace path from an agent ID by reading openclaw.json.
*/
async function resolveWorkspacePath(agentId: string): Promise<string> {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
"openclaw.json",
);
const raw = await fs.readFile(configPath, "utf-8");
const config = JSON.parse(raw);
const agent = config.agents?.list?.find(
(a: { id: string }) => a.id === agentId,
);
if (!agent?.workspace) {
throw new Error(
`Agent "${agentId}" not found in openclaw.json or has no workspace configured.`,
);
}
return agent.workspace;
}
/**
* Write DevClaw model tier config to openclaw.json plugins section.
* Read-modify-write to preserve existing config.
*/
async function writePluginConfig(
models: Record<Tier, string>,
): Promise<void> {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
"openclaw.json",
);
const raw = await fs.readFile(configPath, "utf-8");
const config = JSON.parse(raw);
// Ensure plugins.entries.devclaw.config.models exists
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
if (!config.plugins.entries.devclaw) config.plugins.entries.devclaw = {};
if (!config.plugins.entries.devclaw.config)
config.plugins.entries.devclaw.config = {};
config.plugins.entries.devclaw.config.models = { ...models };
// Atomic write
const tmpPath = configPath + ".tmp";
await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
await fs.rename(tmpPath, configPath);
}
/**
* Backup existing file (if any) and write new content.
*/
async function backupAndWrite(
filePath: string,
content: string,
): Promise<void> {
try {
await fs.access(filePath);
// File exists — backup
const bakPath = filePath + ".bak";
await fs.copyFile(filePath, bakPath);
} catch {
// File doesn't exist — ensure directory
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
await fs.writeFile(filePath, content, "utf-8");
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

179
lib/templates.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* Shared templates for workspace files.
* Used by setup and project_register.
*/
export const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
- Work in a git worktree (never switch branches in the main repo)
- Run tests before completing
- Create an MR/PR to the base branch and merge it
- Clean up the worktree after merging
- When done, call task_complete with role "dev", result "done", and a brief summary
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
- Pull latest from the base branch
- Run tests and linting
- Verify the changes address the issue requirements
- Check for regressions in related functionality
- When done, call task_complete with role "qa" and one of:
- result "pass" if everything looks good
- result "fail" with specific issues if problems found
- result "refine" if you need human input to decide
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
## If You Are a Sub-Agent (DEV/QA Worker)
Skip the orchestrator section. Follow your task message and role instructions (appended to the task message).
### Conventions
- Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\`
- Include issue number: \`feat: add user authentication (#12)\`
- Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\`
- **DEV always works in a git worktree** (never switch branches in the main repo)
- **DEV must merge to base branch** before announcing completion
- **QA tests on the deployed version** and inspects code on the base branch
- Always run tests before completing
### Completing Your Task
When you are done, **call \`task_complete\` yourself** — do not just announce in text.
- **DEV done:** \`task_complete({ role: "dev", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA pass:** \`task_complete({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA fail:** \`task_complete({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **QA refine:** \`task_complete({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
The \`projectGroupId\` is included in your task message.
### Filing Follow-Up Issues
If you discover unrelated bugs or needed improvements during your work, call \`task_create\` to file them:
\`task_create({ projectGroupId: "<from task message>", title: "Bug: ...", description: "..." })\`
### Tools You Should NOT Use
These are orchestrator-only tools. Do not call them:
- \`task_pickup\`, \`queue_status\`, \`session_health\`, \`project_register\`
---
## Orchestrator
You are a **development orchestrator**. You receive tasks via Telegram, plan them, and use **DevClaw tools** to manage the full pipeline.
### DevClaw Tools
All orchestration goes through these tools. You do NOT manually manage sessions, labels, or projects.json.
| Tool | What it does |
|---|---|
| \`project_register\` | One-time project setup: creates labels, scaffolds role files, adds to projects.json |
| \`task_create\` | Create issues from chat (bugs, features, tasks) |
| \`queue_status\` | Scans issue queue (To Do, To Test, To Improve) + shows worker state |
| \`task_pickup\` | End-to-end: label transition, tier assignment, session create/reuse, dispatch with role instructions, state update |
| \`task_complete\` | End-to-end: label transition, state update, issue close/reopen. Auto-chains if enabled. |
| \`session_health\` | Detects zombie workers, stale sessions. Can auto-fix. |
### Pipeline Flow
\`\`\`
Planning → To Do → Doing → To Test → Testing → Done
To Improve → Doing (fix cycle)
Refining (human decision)
\`\`\`
Issue labels are the single source of truth for task state.
### Developer Assignment
Evaluate each task and pass the appropriate developer tier to \`task_pickup\`:
- **junior** — trivial: typos, single-file fix, quick change
- **medior** — standard: features, bug fixes, multi-file changes
- **senior** — complex: architecture, system-wide refactoring, 5+ services
- **qa** — review: code inspection, validation, test runs
### Picking Up Work
1. Use \`queue_status\` to see what's available
2. Priority: \`To Improve\` (fix failures) > \`To Test\` (QA) > \`To Do\` (new work)
3. Evaluate complexity, choose developer tier
4. Call \`task_pickup\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`model\` (tier name)
5. Post the \`announcement\` from the tool response to Telegram
### When Work Completes
Workers call \`task_complete\` themselves — the label transition, state update, and audit log happen atomically.
**If \`autoChain\` is enabled on the project:**
- DEV "done" → QA is dispatched automatically (qa tier)
- QA "fail" → DEV fix is dispatched automatically (reuses previous DEV tier)
- QA "pass" / "refine" → pipeline done or needs human input, no chaining
**If \`autoChain\` is disabled:**
- The \`task_complete\` response includes a \`nextAction\` hint
- \`"qa_pickup"\` → pick up QA for this issue
- \`"dev_fix"\` → pick up DEV to fix
- absent → pipeline done or needs human input
Post the \`announcement\` from the tool response to Telegram.
### Role Instructions
Workers receive role-specific instructions appended to their task message. These are loaded from \`roles/<project-name>/<role>.md\` in the workspace (with fallback to \`roles/default/<role>.md\`). \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
### Heartbeats
On heartbeat, follow \`HEARTBEAT.md\`.
### Safety
- Don't push to main directly
- Don't force-push
- Don't close issues without QA pass
- Ask before architectural decisions affecting multiple projects
`;
export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md
On each heartbeat, run these checks using DevClaw tools:
## 1. Health Check
Call \`session_health\` with \`projectGroupId\` and \`autoFix: true\`.
- Detects zombie workers (active but session dead)
- Auto-fixes stale state in projects.json
## 2. Queue Scan
Call \`queue_status\` with \`projectGroupId\`.
- Shows issues in To Do, To Test, To Improve
- Shows current worker state (active/idle)
## 3. Pick Up Work (if slots free)
If a worker slot is free (DEV or QA not active), pick up work by priority:
1. \`To Improve\` issues → \`task_pickup\` with role \`dev\`
2. \`To Test\` issues → \`task_pickup\` with role \`qa\`
3. \`To Do\` issues → \`task_pickup\` with role \`dev\`
Choose the developer tier based on task complexity (see AGENTS.md developer assignment guide).
## 4. Nothing to do?
If no issues in queue and no active workers → reply \`HEARTBEAT_OK\`.
`;

66
lib/tiers.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Developer tier definitions and model resolution.
*
* Tasks are assigned to developer tiers (junior, medior, senior, qa)
* instead of raw model names. Each tier maps to a configurable LLM model.
*/
export const DEV_TIERS = ["junior", "medior", "senior"] as const;
export const QA_TIERS = ["qa"] as const;
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
export type DevTier = (typeof DEV_TIERS)[number];
export type QaTier = (typeof QA_TIERS)[number];
export type Tier = (typeof ALL_TIERS)[number];
export const DEFAULT_MODELS: Record<Tier, string> = {
junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
qa: "anthropic/claude-sonnet-4-5",
};
/** Emoji used in announcements per tier. */
export const TIER_EMOJI: Record<Tier, string> = {
junior: "⚡",
medior: "🔧",
senior: "🧠",
qa: "🔍",
};
/** Check if a string is a valid tier name. */
export function isTier(value: string): value is Tier {
return (ALL_TIERS as readonly string[]).includes(value);
}
/** Check if a string is a valid dev tier name. */
export function isDevTier(value: string): value is DevTier {
return (DEV_TIERS as readonly string[]).includes(value);
}
/**
* Resolve a tier name to a full model ID.
*
* Resolution order:
* 1. Plugin config `models` map (user overrides)
* 2. DEFAULT_MODELS (hardcoded defaults)
* 3. Treat input as raw model ID (passthrough for non-tier values)
*/
export function resolveModel(
tier: string,
pluginConfig?: Record<string, unknown>,
): string {
const models = (pluginConfig as { models?: Record<string, string> })?.models;
return models?.[tier] ?? DEFAULT_MODELS[tier as Tier] ?? tier;
}
/**
* Migration map from old model-alias session keys to tier names.
* Used by migrateWorkerState() in projects.ts.
*/
export const TIER_MIGRATION: Record<string, string> = {
haiku: "junior",
sonnet: "medior",
opus: "senior",
grok: "qa",
};

View File

@@ -0,0 +1,84 @@
/**
* devclaw_setup — Agent-driven setup tool.
*
* Creates a new agent (optional), configures model tiers,
* and writes workspace files (AGENTS.md, HEARTBEAT.md, roles, memory).
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { runSetup } from "../setup.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "devclaw_setup",
description: `Set up DevClaw in an agent's workspace. Creates AGENTS.md, HEARTBEAT.md, role templates, memory/projects.json, and writes model tier config to openclaw.json. Optionally creates a new agent. Backs up existing files before overwriting.`,
parameters: {
type: "object",
properties: {
newAgentName: {
type: "string",
description: "Create a new agent with this name. If omitted, configures the current agent's workspace.",
},
models: {
type: "object",
description: `Model overrides per tier. Missing tiers use defaults. Example: { "junior": "anthropic/claude-haiku-4-5", "senior": "anthropic/claude-opus-4-5" }`,
properties: {
junior: { type: "string", description: `Junior dev model (default: ${DEFAULT_MODELS.junior})` },
medior: { type: "string", description: `Medior dev model (default: ${DEFAULT_MODELS.medior})` },
senior: { type: "string", description: `Senior dev model (default: ${DEFAULT_MODELS.senior})` },
qa: { type: "string", description: `QA engineer model (default: ${DEFAULT_MODELS.qa})` },
},
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const newAgentName = params.newAgentName as string | undefined;
const modelsParam = params.models as Partial<Record<Tier, string>> | undefined;
const workspaceDir = ctx.workspaceDir;
const result = await runSetup({
newAgentName,
// If no new agent name, use the current agent's workspace
agentId: newAgentName ? undefined : ctx.agentId,
workspacePath: newAgentName ? undefined : workspaceDir,
models: modelsParam,
});
const lines = [
result.agentCreated
? `Agent "${result.agentId}" created`
: `Configured workspace for agent "${result.agentId}"`,
``,
`Models:`,
...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`),
``,
`Files written:`,
...result.filesWritten.map((f) => ` ${f}`),
];
if (result.warnings.length > 0) {
lines.push(``, `Warnings:`, ...result.warnings.map((w) => ` ${w}`));
}
lines.push(
``,
`Next steps:`,
` 1. Add bot to a Telegram group`,
` 2. Register a project: "Register project <name> at <repo> for group <id>"`,
` 3. Create your first issue and pick it up`,
);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
...result,
summary: lines.join("\n"),
}, null, 2),
}],
};
},
});
}

View File

@@ -13,6 +13,8 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
import { resolveRepoPath } from "../gitlab.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js";
import { DEV_TIERS, QA_TIERS } from "../tiers.js";
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js";
/**
* Ensure default role files exist, then copy them into the project's role directory.
@@ -64,31 +66,6 @@ async function scaffoldRoleFiles(workspaceDir: string, projectName: string): Pro
return created;
}
const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
- Work in a git worktree (never switch branches in the main repo)
- Run tests before completing
- Create an MR/PR to the base branch and merge it
- Clean up the worktree after merging
- When done, call task_complete with role "dev", result "done", and a brief summary
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
- Pull latest from the base branch
- Run tests and linting
- Verify the changes address the issue requirements
- Check for regressions in related functionality
- When done, call task_complete with role "qa" and one of:
- result "pass" if everything looks good
- result "fail" with specific issues if problems found
- result "refine" if you need human input to decide
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
export function createProjectRegisterTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "project_register",
@@ -186,8 +163,8 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
baseBranch,
deployBranch,
autoChain: false,
dev: emptyWorkerState(["haiku", "sonnet", "opus"]),
qa: emptyWorkerState(["grok"]),
dev: emptyWorkerState([...DEV_TIERS]),
qa: emptyWorkerState([...QA_TIERS]),
};
await writeProjects(workspaceDir, data);

View File

@@ -5,8 +5,8 @@
* issue close/reopen, audit logging, and optional auto-chaining.
*
* When project.autoChain is true:
* - DEV "done" → automatically dispatches QA (default model: grok)
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV model)
* - DEV "done" → automatically dispatches QA (qa tier)
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV tier)
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import {
@@ -120,6 +120,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
if (project.autoChain) {
try {
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
@@ -131,11 +132,12 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
issueDescription: issue.description ?? "",
issueUrl: issue.web_url,
role: "qa",
modelAlias: "grok",
modelAlias: "qa",
fromLabel: "To Test",
toLabel: "Testing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
pluginConfig,
});
output.autoChain = {
dispatched: true,
@@ -181,6 +183,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
if (project.autoChain && devModel) {
try {
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
@@ -197,6 +200,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
toLabel: "Doing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
pluginConfig,
});
output.autoChain = {
dispatched: true,

View File

@@ -23,7 +23,7 @@ import { dispatchTask } from "../dispatch.js";
export function createTaskPickupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_pickup",
description: `Pick up a task from the issue queue for a DEV or QA worker. Handles everything end-to-end: label transition, model selection, session creation/reuse, task dispatch, state update, and audit logging. The orchestrator should analyze the issue and pass the appropriate model. Returns an announcement for the agent to post — no further session actions needed.`,
description: `Pick up a task from the issue queue for a DEV or QA worker. Handles everything end-to-end: label transition, tier assignment, session creation/reuse, task dispatch, state update, and audit logging. The orchestrator should analyze the issue and pass the appropriate developer tier. Returns an announcement for the agent to post — no further session actions needed.`,
parameters: {
type: "object",
required: ["issueId", "role", "projectGroupId"],
@@ -36,7 +36,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
},
model: {
type: "string",
description: "Model alias to use (e.g. haiku, sonnet, opus, grok). The orchestrator should analyze the issue complexity and choose. Falls back to keyword heuristic if omitted.",
description: "Developer tier (junior, medior, senior, qa). The orchestrator should evaluate the task complexity and choose the right tier. Falls back to keyword heuristic if omitted.",
},
},
},
@@ -101,12 +101,13 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
modelSource = "llm";
} else {
const selected = selectModel(issue.title, issue.description ?? "", role);
modelAlias = selected.alias;
modelAlias = selected.tier;
modelReason = selected.reason;
modelSource = "heuristic";
}
// 5. Dispatch via shared logic
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const dispatchResult = await dispatchTask({
workspaceDir,
agentId: ctx.agentId,
@@ -122,6 +123,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
toLabel: targetLabel,
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
pluginConfig,
});
// 6. Build result