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:
208
lib/cli.ts
Normal file
208
lib/cli.ts
Normal 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("");
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
263
lib/setup.ts
Normal 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
179
lib/templates.ts
Normal 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
66
lib/tiers.ts
Normal 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",
|
||||
};
|
||||
84
lib/tools/devclaw-setup.ts
Normal file
84
lib/tools/devclaw-setup.ts
Normal 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),
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user