Merge upstream/main with Gitea support preserved
- design_task → research_task (breaking change) - QA role → Tester role (renamed) - Auto-merge for approved PRs - Enhanced workspace scaffolding - Preserved Gitea provider support (github|gitlab|gitea) - Preserved business hours scheduling (stashed)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { DATA_DIR } from "./setup/migrate-layout.js";
|
||||
|
||||
const MAX_LOG_LINES = 50;
|
||||
|
||||
@@ -13,7 +14,7 @@ export async function log(
|
||||
event: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = join(workspaceDir, "log", "audit.log");
|
||||
const filePath = join(workspaceDir, DATA_DIR, "log", "audit.log");
|
||||
const entry = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
event,
|
||||
|
||||
@@ -10,24 +10,24 @@ import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
describe("parseDevClawSessionKey", () => {
|
||||
it("should parse a standard dev session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-medior");
|
||||
assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" });
|
||||
it("should parse a standard developer session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-developer-medior");
|
||||
assert.deepStrictEqual(result, { projectName: "my-project", role: "developer" });
|
||||
});
|
||||
|
||||
it("should parse a qa session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-reviewer");
|
||||
assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" });
|
||||
it("should parse a tester session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-tester-medior");
|
||||
assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" });
|
||||
});
|
||||
|
||||
it("should handle project names with hyphens", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior");
|
||||
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" });
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior");
|
||||
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" });
|
||||
});
|
||||
|
||||
it("should handle project names with multiple hyphens and qa role", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-tester");
|
||||
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" });
|
||||
it("should handle project names with multiple hyphens and tester role", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-tester-junior");
|
||||
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "tester" });
|
||||
});
|
||||
|
||||
it("should return null for non-subagent session keys", () => {
|
||||
@@ -45,38 +45,38 @@ describe("parseDevClawSessionKey", () => {
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it("should parse senior dev level", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior");
|
||||
assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" });
|
||||
it("should parse senior developer level", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-developer-senior");
|
||||
assert.deepStrictEqual(result, { projectName: "devclaw", role: "developer" });
|
||||
});
|
||||
|
||||
it("should parse simple project name", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior");
|
||||
assert.deepStrictEqual(result, { projectName: "api", role: "dev" });
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-developer-junior");
|
||||
assert.deepStrictEqual(result, { projectName: "api", role: "developer" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadRoleInstructions", () => {
|
||||
it("should load project-specific instructions", async () => {
|
||||
it("should load project-specific instructions from devclaw/projects/<project>/prompts/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||
const projectDir = path.join(tmpDir, "projects", "roles", "test-project");
|
||||
const projectDir = path.join(tmpDir, "devclaw", "projects", "test-project", "prompts");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projectDir, "dev.md"), "# Dev Instructions\nDo the thing.");
|
||||
await fs.writeFile(path.join(projectDir, "developer.md"), "# Developer Instructions\nDo the thing.");
|
||||
|
||||
const result = await loadRoleInstructions(tmpDir, "test-project", "dev");
|
||||
assert.strictEqual(result, "# Dev Instructions\nDo the thing.");
|
||||
const result = await loadRoleInstructions(tmpDir, "test-project", "developer");
|
||||
assert.strictEqual(result, "# Developer Instructions\nDo the thing.");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should fall back to default instructions", async () => {
|
||||
it("should fall back to default instructions from devclaw/prompts/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
||||
await fs.mkdir(defaultDir, { recursive: true });
|
||||
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA Default\nReview carefully.");
|
||||
const promptsDir = path.join(tmpDir, "devclaw", "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(promptsDir, "tester.md"), "# Tester Default\nReview carefully.");
|
||||
|
||||
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "qa");
|
||||
assert.strictEqual(result, "# QA Default\nReview carefully.");
|
||||
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester");
|
||||
assert.strictEqual(result, "# Tester Default\nReview carefully.");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe("loadRoleInstructions", () => {
|
||||
it("should return empty string when no instructions exist", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||
|
||||
const result = await loadRoleInstructions(tmpDir, "missing", "dev");
|
||||
const result = await loadRoleInstructions(tmpDir, "missing", "developer");
|
||||
assert.strictEqual(result, "");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
@@ -92,16 +92,28 @@ describe("loadRoleInstructions", () => {
|
||||
|
||||
it("should prefer project-specific over default", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||
const projectDir = path.join(tmpDir, "projects", "roles", "my-project");
|
||||
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(defaultDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions");
|
||||
await fs.writeFile(path.join(defaultDir, "dev.md"), "Default instructions");
|
||||
const projectPromptsDir = path.join(tmpDir, "devclaw", "projects", "my-project", "prompts");
|
||||
const defaultPromptsDir = path.join(tmpDir, "devclaw", "prompts");
|
||||
await fs.mkdir(projectPromptsDir, { recursive: true });
|
||||
await fs.mkdir(defaultPromptsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projectPromptsDir, "developer.md"), "Project-specific instructions");
|
||||
await fs.writeFile(path.join(defaultPromptsDir, "developer.md"), "Default instructions");
|
||||
|
||||
const result = await loadRoleInstructions(tmpDir, "my-project", "dev");
|
||||
const result = await loadRoleInstructions(tmpDir, "my-project", "developer");
|
||||
assert.strictEqual(result, "Project-specific instructions");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should fall back to old path for unmigrated workspaces", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||
const oldDir = path.join(tmpDir, "projects", "roles", "old-project");
|
||||
await fs.mkdir(oldDir, { recursive: true });
|
||||
await fs.writeFile(path.join(oldDir, "developer.md"), "Old layout instructions");
|
||||
|
||||
const result = await loadRoleInstructions(tmpDir, "old-project", "developer");
|
||||
assert.strictEqual(result, "Old layout instructions");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,14 +11,15 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { getSessionKeyRolePattern } from "./roles/index.js";
|
||||
import { DATA_DIR } from "./setup/migrate-layout.js";
|
||||
|
||||
/**
|
||||
* Parse a DevClaw subagent session key to extract project name and role.
|
||||
*
|
||||
* Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
|
||||
* Examples:
|
||||
* - `agent:devclaw:subagent:my-project-dev-medior` → { projectName: "my-project", role: "dev" }
|
||||
* - `agent:devclaw:subagent:webapp-qa-reviewer` → { projectName: "webapp", role: "qa" }
|
||||
* - `agent:devclaw:subagent:my-project-developer-medior` → { projectName: "my-project", role: "developer" }
|
||||
* - `agent:devclaw:subagent:webapp-tester-medior` → { projectName: "webapp", role: "tester" }
|
||||
*
|
||||
* Note: projectName may contain hyphens, so we match role from the end.
|
||||
*/
|
||||
@@ -32,30 +33,61 @@ export function parseDevClawSessionKey(
|
||||
return { projectName: match[1], role: match[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading role instructions — includes the source for traceability.
|
||||
*/
|
||||
export type RoleInstructionsResult = {
|
||||
content: string;
|
||||
/** Which file the instructions were loaded from, or null if none found. */
|
||||
source: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load role-specific instructions from workspace.
|
||||
* Tries project-specific file first, then falls back to default.
|
||||
* Returns both the content and the source path for logging/traceability.
|
||||
*
|
||||
* This is the same logic previously in dispatch.ts loadRoleInstructions(),
|
||||
* now called from the bootstrap hook instead of during dispatch.
|
||||
* Resolution order:
|
||||
* 1. devclaw/projects/<project>/prompts/<role>.md (project-specific)
|
||||
* 2. projects/roles/<project>/<role>.md (old project-specific)
|
||||
* 3. devclaw/prompts/<role>.md (workspace default)
|
||||
* 4. projects/roles/default/<role>.md (old default)
|
||||
*/
|
||||
export async function loadRoleInstructions(
|
||||
workspaceDir: string,
|
||||
projectName: string,
|
||||
role: string,
|
||||
): Promise<string> {
|
||||
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
||||
try {
|
||||
return await fs.readFile(projectFile, "utf-8");
|
||||
} catch {
|
||||
/* not found — try default */
|
||||
}
|
||||
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
||||
try {
|
||||
return await fs.readFile(defaultFile, "utf-8");
|
||||
} catch {
|
||||
/* not found */
|
||||
): Promise<string>;
|
||||
export async function loadRoleInstructions(
|
||||
workspaceDir: string,
|
||||
projectName: string,
|
||||
role: string,
|
||||
opts: { withSource: true },
|
||||
): Promise<RoleInstructionsResult>;
|
||||
export async function loadRoleInstructions(
|
||||
workspaceDir: string,
|
||||
projectName: string,
|
||||
role: string,
|
||||
opts?: { withSource: true },
|
||||
): Promise<string | RoleInstructionsResult> {
|
||||
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||
|
||||
const candidates = [
|
||||
path.join(dataDir, "projects", projectName, "prompts", `${role}.md`),
|
||||
path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`),
|
||||
path.join(dataDir, "prompts", `${role}.md`),
|
||||
path.join(workspaceDir, "projects", "roles", "default", `${role}.md`),
|
||||
];
|
||||
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
if (opts?.withSource) return { content, source: filePath };
|
||||
return content;
|
||||
} catch { /* not found, try next */ }
|
||||
}
|
||||
|
||||
if (opts?.withSource) return { content: "", source: null };
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -74,10 +106,14 @@ export async function loadRoleInstructions(
|
||||
export function registerBootstrapHook(api: OpenClawPluginApi): void {
|
||||
api.registerHook("agent:bootstrap", async (event) => {
|
||||
const sessionKey = event.sessionKey;
|
||||
api.logger.debug(`Bootstrap hook fired: sessionKey=${sessionKey ?? "undefined"}, event keys=${Object.keys(event).join(",")}`);
|
||||
if (!sessionKey) return;
|
||||
|
||||
const parsed = parseDevClawSessionKey(sessionKey);
|
||||
if (!parsed) return;
|
||||
if (!parsed) {
|
||||
api.logger.debug(`Bootstrap hook: not a DevClaw session key: ${sessionKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = event.context as {
|
||||
workspaceDir?: string;
|
||||
@@ -90,30 +126,40 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void {
|
||||
};
|
||||
|
||||
const workspaceDir = context.workspaceDir;
|
||||
if (!workspaceDir || typeof workspaceDir !== "string") return;
|
||||
if (!workspaceDir || typeof workspaceDir !== "string") {
|
||||
api.logger.warn(`Bootstrap hook: no workspaceDir in context for ${sessionKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrapFiles = context.bootstrapFiles;
|
||||
if (!Array.isArray(bootstrapFiles)) return;
|
||||
if (!Array.isArray(bootstrapFiles)) {
|
||||
api.logger.warn(`Bootstrap hook: no bootstrapFiles array in context for ${sessionKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const instructions = await loadRoleInstructions(
|
||||
const { content, source } = await loadRoleInstructions(
|
||||
workspaceDir,
|
||||
parsed.projectName,
|
||||
parsed.role,
|
||||
{ withSource: true },
|
||||
);
|
||||
|
||||
if (!instructions) return;
|
||||
if (!content) {
|
||||
api.logger.warn(`Bootstrap hook: no content found for ${parsed.role} in project "${parsed.projectName}" (workspace: ${workspaceDir})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject as a virtual bootstrap file. OpenClaw includes these in the
|
||||
// agent's system prompt automatically (via buildBootstrapContextFiles).
|
||||
bootstrapFiles.push({
|
||||
name: "WORKER_INSTRUCTIONS.md" as any,
|
||||
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
|
||||
content: instructions.trim(),
|
||||
content: content.trim(),
|
||||
missing: false,
|
||||
});
|
||||
|
||||
api.logger.info(
|
||||
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`,
|
||||
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}" from ${source}`,
|
||||
);
|
||||
});
|
||||
}, { name: "devclaw-worker-instructions", description: "Injects role-specific instructions into DevClaw worker sessions" } as any);
|
||||
}
|
||||
|
||||
53
lib/cli.ts
53
lib/cli.ts
@@ -6,7 +6,7 @@
|
||||
import type { Command } from "commander";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { runSetup } from "./setup/index.js";
|
||||
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "./tiers.js";
|
||||
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "./roles/index.js";
|
||||
|
||||
/**
|
||||
* Register the `devclaw` CLI command group on a Commander program.
|
||||
@@ -16,37 +16,41 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
||||
.command("devclaw")
|
||||
.description("DevClaw development pipeline tools");
|
||||
|
||||
devclaw
|
||||
const setupCmd = devclaw
|
||||
.command("setup")
|
||||
.description("Set up DevClaw: create agent, configure models, write workspace files")
|
||||
.option("--new-agent <name>", "Create a new agent with this name")
|
||||
.option("--agent <id>", "Use an existing agent by ID")
|
||||
.option("--workspace <path>", "Direct workspace path")
|
||||
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`)
|
||||
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`)
|
||||
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`)
|
||||
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`)
|
||||
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`)
|
||||
.action(async (opts) => {
|
||||
const dev: Record<string, string> = {};
|
||||
const qa: Record<string, string> = {};
|
||||
if (opts.junior) dev.junior = opts.junior;
|
||||
if (opts.medior) dev.medior = opts.medior;
|
||||
if (opts.senior) dev.senior = opts.senior;
|
||||
if (opts.reviewer) qa.reviewer = opts.reviewer;
|
||||
if (opts.tester) qa.tester = opts.tester;
|
||||
.option("--workspace <path>", "Direct workspace path");
|
||||
|
||||
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;
|
||||
// Register dynamic --<role>-<level> options from registry
|
||||
const defaults = getAllDefaultModels();
|
||||
for (const role of getAllRoleIds()) {
|
||||
for (const level of getLevelsForRole(role)) {
|
||||
const flag = `--${role}-${level}`;
|
||||
setupCmd.option(`${flag} <model>`, `${role.toUpperCase()} ${level} model (default: ${defaults[role]?.[level] ?? "auto"})`);
|
||||
}
|
||||
}
|
||||
|
||||
setupCmd.action(async (opts) => {
|
||||
// Build model overrides from CLI flags dynamically
|
||||
const models: Record<string, Record<string, string>> = {};
|
||||
for (const role of getAllRoleIds()) {
|
||||
const roleModels: Record<string, string> = {};
|
||||
for (const level of getLevelsForRole(role)) {
|
||||
// camelCase key: "testerJunior" for --tester-junior, "developerMedior" for --developer-medior
|
||||
const key = `${role}${level.charAt(0).toUpperCase()}${level.slice(1)}`;
|
||||
if (opts[key]) roleModels[level] = opts[key];
|
||||
}
|
||||
if (Object.keys(roleModels).length > 0) models[role] = roleModels;
|
||||
}
|
||||
|
||||
const result = await runSetup({
|
||||
api,
|
||||
newAgentName: opts.newAgent,
|
||||
agentId: opts.agent,
|
||||
workspacePath: opts.workspace,
|
||||
models,
|
||||
models: Object.keys(models).length > 0 ? models : undefined,
|
||||
});
|
||||
|
||||
if (result.agentCreated) {
|
||||
@@ -54,8 +58,11 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
||||
}
|
||||
|
||||
console.log("Models configured:");
|
||||
for (const t of DEV_LEVELS) console.log(` dev.${t}: ${result.models.dev[t]}`);
|
||||
for (const t of QA_LEVELS) console.log(` qa.${t}: ${result.models.qa[t]}`);
|
||||
for (const [role, levels] of Object.entries(result.models)) {
|
||||
for (const [level, model] of Object.entries(levels)) {
|
||||
console.log(` ${role}.${level}: ${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Files written:");
|
||||
for (const file of result.filesWritten) {
|
||||
|
||||
17
lib/config/index.ts
Normal file
17
lib/config/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* config/ — Unified DevClaw configuration.
|
||||
*
|
||||
* Single workflow.yaml per workspace/project combining roles, models, and workflow.
|
||||
*/
|
||||
export type {
|
||||
DevClawConfig,
|
||||
RoleOverride,
|
||||
ResolvedConfig,
|
||||
ResolvedRoleConfig,
|
||||
ResolvedTimeouts,
|
||||
TimeoutConfig,
|
||||
} from "./types.js";
|
||||
|
||||
export { loadConfig } from "./loader.js";
|
||||
export { mergeConfig } from "./merge.js";
|
||||
export { validateConfig, validateWorkflowIntegrity } from "./schema.js";
|
||||
202
lib/config/loader.ts
Normal file
202
lib/config/loader.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* config/loader.ts — Three-layer config loading.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
|
||||
* 2. Workspace: <workspace>/devclaw/workflow.yaml
|
||||
* 3. Project: <workspace>/devclaw/projects/<project>/workflow.yaml
|
||||
*
|
||||
* Also supports legacy config.yaml and workflow.json for backward compat.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import { ROLE_REGISTRY } from "../roles/registry.js";
|
||||
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||
import { mergeConfig } from "./merge.js";
|
||||
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, ResolvedTimeouts, RoleOverride } from "./types.js";
|
||||
import { validateConfig, validateWorkflowIntegrity } from "./schema.js";
|
||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||
|
||||
/**
|
||||
* Load and resolve the full DevClaw config for a project.
|
||||
*
|
||||
* Merges: built-in → workspace workflow.yaml → project workflow.yaml.
|
||||
*/
|
||||
export async function loadConfig(
|
||||
workspaceDir: string,
|
||||
projectName?: string,
|
||||
): Promise<ResolvedConfig> {
|
||||
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||
const projectsDir = path.join(dataDir, "projects");
|
||||
|
||||
// Layer 1: built-in defaults
|
||||
const builtIn = buildDefaultConfig();
|
||||
|
||||
// Layer 2: workspace workflow.yaml (in devclaw/ data dir)
|
||||
let merged = builtIn;
|
||||
const workspaceConfig =
|
||||
await readWorkflowFile(dataDir) ??
|
||||
await readLegacyConfigFile(path.join(workspaceDir, "projects"));
|
||||
if (workspaceConfig) {
|
||||
merged = mergeConfig(merged, workspaceConfig);
|
||||
}
|
||||
|
||||
// Legacy: standalone workflow.json (only if no workflow section found)
|
||||
if (!workspaceConfig?.workflow) {
|
||||
const legacyWorkflow = await readLegacyWorkflowJson(projectsDir);
|
||||
if (legacyWorkflow) {
|
||||
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: project workflow.yaml
|
||||
if (projectName) {
|
||||
const projectDir = path.join(projectsDir, projectName);
|
||||
const projectConfig =
|
||||
await readWorkflowFile(projectDir) ??
|
||||
await readLegacyConfigFile(projectDir);
|
||||
if (projectConfig) {
|
||||
merged = mergeConfig(merged, projectConfig);
|
||||
}
|
||||
|
||||
if (!projectConfig?.workflow) {
|
||||
const legacyWorkflow = await readLegacyWorkflowJson(projectDir);
|
||||
if (legacyWorkflow) {
|
||||
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default config from the built-in ROLE_REGISTRY and DEFAULT_WORKFLOW.
|
||||
*/
|
||||
function buildDefaultConfig(): DevClawConfig {
|
||||
const roles: Record<string, RoleOverride> = {};
|
||||
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
|
||||
roles[id] = {
|
||||
levels: [...reg.levels],
|
||||
defaultLevel: reg.defaultLevel,
|
||||
models: { ...reg.models },
|
||||
emoji: { ...reg.emoji },
|
||||
completionResults: [...reg.completionResults],
|
||||
};
|
||||
}
|
||||
return { roles, workflow: DEFAULT_WORKFLOW };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a merged DevClawConfig into a fully-typed ResolvedConfig.
|
||||
*/
|
||||
function resolve(config: DevClawConfig): ResolvedConfig {
|
||||
const roles: Record<string, ResolvedRoleConfig> = {};
|
||||
|
||||
if (config.roles) {
|
||||
for (const [id, override] of Object.entries(config.roles)) {
|
||||
if (override === false) {
|
||||
// Disabled role — include with enabled: false for visibility
|
||||
const reg = ROLE_REGISTRY[id];
|
||||
roles[id] = {
|
||||
levels: reg ? [...reg.levels] : [],
|
||||
defaultLevel: reg?.defaultLevel ?? "",
|
||||
models: reg ? { ...reg.models } : {},
|
||||
emoji: reg ? { ...reg.emoji } : {},
|
||||
completionResults: reg ? [...reg.completionResults] : [],
|
||||
enabled: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
const reg = ROLE_REGISTRY[id];
|
||||
roles[id] = {
|
||||
levels: override.levels ?? (reg ? [...reg.levels] : []),
|
||||
defaultLevel: override.defaultLevel ?? reg?.defaultLevel ?? "",
|
||||
models: { ...(reg?.models ?? {}), ...(override.models ?? {}) },
|
||||
emoji: { ...(reg?.emoji ?? {}), ...(override.emoji ?? {}) },
|
||||
completionResults: override.completionResults ?? (reg ? [...reg.completionResults] : []),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all built-in roles exist even if not in config
|
||||
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
|
||||
if (!roles[id]) {
|
||||
roles[id] = {
|
||||
levels: [...reg.levels],
|
||||
defaultLevel: reg.defaultLevel,
|
||||
models: { ...reg.models },
|
||||
emoji: { ...reg.emoji },
|
||||
completionResults: [...reg.completionResults],
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const workflow: WorkflowConfig = {
|
||||
initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial,
|
||||
reviewPolicy: config.workflow?.reviewPolicy ?? DEFAULT_WORKFLOW.reviewPolicy,
|
||||
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
|
||||
};
|
||||
|
||||
// Validate structural integrity (cross-references between states)
|
||||
const integrityErrors = validateWorkflowIntegrity(workflow);
|
||||
if (integrityErrors.length > 0) {
|
||||
throw new Error(`Workflow config integrity errors:\n - ${integrityErrors.join("\n - ")}`);
|
||||
}
|
||||
|
||||
const timeouts: ResolvedTimeouts = {
|
||||
gitPullMs: config.timeouts?.gitPullMs ?? 30_000,
|
||||
gatewayMs: config.timeouts?.gatewayMs ?? 15_000,
|
||||
sessionPatchMs: config.timeouts?.sessionPatchMs ?? 30_000,
|
||||
dispatchMs: config.timeouts?.dispatchMs ?? 600_000,
|
||||
staleWorkerHours: config.timeouts?.staleWorkerHours ?? 2,
|
||||
};
|
||||
|
||||
return { roles, workflow, timeouts };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File reading helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Read workflow.yaml (new primary config file). Validates structure via Zod. */
|
||||
async function readWorkflowFile(dir: string): Promise<DevClawConfig | null> {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
|
||||
const parsed = YAML.parse(content);
|
||||
if (parsed) validateConfig(parsed);
|
||||
return parsed as DevClawConfig;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") return null;
|
||||
// Re-throw validation errors with file context
|
||||
if (err?.name === "ZodError") {
|
||||
throw new Error(`Invalid workflow.yaml in ${dir}: ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read config.yaml (old name, fallback for unmigrated workspaces). */
|
||||
async function readLegacyConfigFile(dir: string): Promise<DevClawConfig | null> {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8");
|
||||
return YAML.parse(content) as DevClawConfig;
|
||||
} catch { /* not found */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read legacy workflow.json (standalone workflow section only). */
|
||||
async function readLegacyWorkflowJson(dir: string): Promise<Partial<WorkflowConfig> | null> {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8");
|
||||
const parsed = JSON.parse(content) as
|
||||
| Partial<WorkflowConfig>
|
||||
| { workflow?: Partial<WorkflowConfig> };
|
||||
return (parsed as any).workflow ?? parsed;
|
||||
} catch { /* not found */ }
|
||||
return null;
|
||||
}
|
||||
88
lib/config/merge.ts
Normal file
88
lib/config/merge.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* config/merge.ts — Deep merge for DevClaw config layers.
|
||||
*
|
||||
* Merge semantics:
|
||||
* - Objects: recursively merge (sparse override)
|
||||
* - Arrays: replace entirely (no merging array elements)
|
||||
* - `false` for a role: marks it as disabled
|
||||
* - Primitives: override
|
||||
*/
|
||||
import type { DevClawConfig, RoleOverride } from "./types.js";
|
||||
|
||||
/**
|
||||
* Merge a config overlay on top of a base config.
|
||||
* Returns a new config — does not mutate inputs.
|
||||
*/
|
||||
export function mergeConfig(
|
||||
base: DevClawConfig,
|
||||
overlay: DevClawConfig,
|
||||
): DevClawConfig {
|
||||
const merged: DevClawConfig = {};
|
||||
|
||||
// Merge roles
|
||||
if (base.roles || overlay.roles) {
|
||||
merged.roles = { ...base.roles };
|
||||
if (overlay.roles) {
|
||||
for (const [roleId, overrideValue] of Object.entries(overlay.roles)) {
|
||||
if (overrideValue === false) {
|
||||
// Disable role
|
||||
merged.roles[roleId] = false;
|
||||
} else if (merged.roles[roleId] === false) {
|
||||
// Re-enable with override
|
||||
merged.roles[roleId] = overrideValue;
|
||||
} else {
|
||||
// Merge role override on top of base role
|
||||
const baseRole = merged.roles[roleId];
|
||||
merged.roles[roleId] = mergeRoleOverride(
|
||||
typeof baseRole === "object" ? baseRole : {},
|
||||
overrideValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge workflow
|
||||
if (base.workflow || overlay.workflow) {
|
||||
merged.workflow = {
|
||||
initial: overlay.workflow?.initial ?? base.workflow?.initial,
|
||||
reviewPolicy: overlay.workflow?.reviewPolicy ?? base.workflow?.reviewPolicy,
|
||||
states: {
|
||||
...base.workflow?.states,
|
||||
...overlay.workflow?.states,
|
||||
},
|
||||
};
|
||||
// Clean up undefined initial
|
||||
if (merged.workflow.initial === undefined) {
|
||||
delete merged.workflow.initial;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge timeouts
|
||||
if (base.timeouts || overlay.timeouts) {
|
||||
merged.timeouts = { ...base.timeouts, ...overlay.timeouts };
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeRoleOverride(
|
||||
base: RoleOverride,
|
||||
overlay: RoleOverride,
|
||||
): RoleOverride {
|
||||
return {
|
||||
...base,
|
||||
...overlay,
|
||||
// Models: merge (don't replace)
|
||||
models: base.models || overlay.models
|
||||
? { ...base.models, ...overlay.models }
|
||||
: undefined,
|
||||
// Emoji: merge (don't replace)
|
||||
emoji: base.emoji || overlay.emoji
|
||||
? { ...base.emoji, ...overlay.emoji }
|
||||
: undefined,
|
||||
// Arrays replace entirely
|
||||
...(overlay.levels ? { levels: overlay.levels } : {}),
|
||||
...(overlay.completionResults ? { completionResults: overlay.completionResults } : {}),
|
||||
};
|
||||
}
|
||||
115
lib/config/schema.ts
Normal file
115
lib/config/schema.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* config/schema.ts — Zod validation for DevClaw workflow config.
|
||||
*
|
||||
* Validates workflow YAML at load time with clear error messages.
|
||||
* Enforces: transition targets exist, queue states have roles,
|
||||
* terminal states have no outgoing transitions.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { StateType } from "../workflow.js";
|
||||
|
||||
const STATE_TYPES = Object.values(StateType) as [string, ...string[]];
|
||||
|
||||
const TransitionTargetSchema = z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
target: z.string(),
|
||||
actions: z.array(z.string()).optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const StateConfigSchema = z.object({
|
||||
type: z.enum(STATE_TYPES),
|
||||
role: z.string().optional(),
|
||||
label: z.string(),
|
||||
color: z.string(),
|
||||
priority: z.number().optional(),
|
||||
description: z.string().optional(),
|
||||
check: z.string().optional(),
|
||||
on: z.record(z.string(), TransitionTargetSchema).optional(),
|
||||
});
|
||||
|
||||
const WorkflowConfigSchema = z.object({
|
||||
initial: z.string(),
|
||||
reviewPolicy: z.enum(["human", "agent", "auto"]).optional(),
|
||||
states: z.record(z.string(), StateConfigSchema),
|
||||
});
|
||||
|
||||
const RoleOverrideSchema = z.union([
|
||||
z.literal(false),
|
||||
z.object({
|
||||
levels: z.array(z.string()).optional(),
|
||||
defaultLevel: z.string().optional(),
|
||||
models: z.record(z.string(), z.string()).optional(),
|
||||
emoji: z.record(z.string(), z.string()).optional(),
|
||||
completionResults: z.array(z.string()).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const TimeoutConfigSchema = z.object({
|
||||
gitPullMs: z.number().positive().optional(),
|
||||
gatewayMs: z.number().positive().optional(),
|
||||
sessionPatchMs: z.number().positive().optional(),
|
||||
dispatchMs: z.number().positive().optional(),
|
||||
staleWorkerHours: z.number().positive().optional(),
|
||||
}).optional();
|
||||
|
||||
export const DevClawConfigSchema = z.object({
|
||||
roles: z.record(z.string(), RoleOverrideSchema).optional(),
|
||||
workflow: WorkflowConfigSchema.partial().optional(),
|
||||
timeouts: TimeoutConfigSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate a raw parsed config object.
|
||||
* Returns the validated config or throws with a descriptive error.
|
||||
*/
|
||||
export function validateConfig(raw: unknown): void {
|
||||
DevClawConfigSchema.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate structural integrity of a fully-resolved workflow config.
|
||||
* Checks cross-references that Zod schema alone can't enforce:
|
||||
* - All transition targets point to existing states
|
||||
* - Queue states have a role assigned
|
||||
* - Terminal states have no outgoing transitions
|
||||
*/
|
||||
export function validateWorkflowIntegrity(
|
||||
workflow: { initial: string; states: Record<string, { type: string; role?: string; on?: Record<string, unknown> }> },
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
const stateKeys = new Set(Object.keys(workflow.states));
|
||||
|
||||
if (!stateKeys.has(workflow.initial)) {
|
||||
errors.push(`Initial state "${workflow.initial}" does not exist in states`);
|
||||
}
|
||||
|
||||
for (const [key, state] of Object.entries(workflow.states)) {
|
||||
if (state.type === StateType.QUEUE && !state.role) {
|
||||
errors.push(`Queue state "${key}" must have a role assigned`);
|
||||
}
|
||||
|
||||
if (state.type === StateType.ACTIVE && !state.role) {
|
||||
errors.push(`Active state "${key}" must have a role assigned`);
|
||||
}
|
||||
|
||||
if (state.type === StateType.TERMINAL && state.on && Object.keys(state.on).length > 0) {
|
||||
errors.push(`Terminal state "${key}" should not have outgoing transitions`);
|
||||
}
|
||||
|
||||
if (state.on) {
|
||||
for (const [event, transition] of Object.entries(state.on)) {
|
||||
const target = typeof transition === "string"
|
||||
? transition
|
||||
: (transition as { target: string }).target;
|
||||
if (!stateKeys.has(target)) {
|
||||
errors.push(`State "${key}" transition "${event}" targets non-existent state "${target}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
74
lib/config/types.ts
Normal file
74
lib/config/types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* config/types.ts — Types for the unified DevClaw configuration.
|
||||
*
|
||||
* A single workflow.yaml combines roles, models, and workflow.
|
||||
* Three-layer resolution: built-in → workspace → per-project.
|
||||
*/
|
||||
import type { WorkflowConfig } from "../workflow.js";
|
||||
|
||||
/**
|
||||
* Role override in workflow.yaml. All fields optional — only override what you need.
|
||||
* Set to `false` to disable a role entirely for a project.
|
||||
*/
|
||||
export type RoleOverride = {
|
||||
levels?: string[];
|
||||
defaultLevel?: string;
|
||||
models?: Record<string, string>;
|
||||
emoji?: Record<string, string>;
|
||||
completionResults?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Configurable timeout values (in milliseconds).
|
||||
* All fields optional — defaults applied at resolution time.
|
||||
*/
|
||||
export type TimeoutConfig = {
|
||||
gitPullMs?: number;
|
||||
gatewayMs?: number;
|
||||
sessionPatchMs?: number;
|
||||
dispatchMs?: number;
|
||||
staleWorkerHours?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The full workflow.yaml shape.
|
||||
* All fields optional — missing fields inherit from the layer below.
|
||||
*/
|
||||
export type DevClawConfig = {
|
||||
roles?: Record<string, RoleOverride | false>;
|
||||
workflow?: Partial<WorkflowConfig>;
|
||||
timeouts?: TimeoutConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully resolved timeout config — all fields present with defaults.
|
||||
*/
|
||||
export type ResolvedTimeouts = {
|
||||
gitPullMs: number;
|
||||
gatewayMs: number;
|
||||
sessionPatchMs: number;
|
||||
dispatchMs: number;
|
||||
staleWorkerHours: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully resolved config — all fields guaranteed present.
|
||||
* Built by merging three layers over the built-in defaults.
|
||||
*/
|
||||
export type ResolvedConfig = {
|
||||
roles: Record<string, ResolvedRoleConfig>;
|
||||
workflow: WorkflowConfig;
|
||||
timeouts: ResolvedTimeouts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully resolved role config — all fields present.
|
||||
*/
|
||||
export type ResolvedRoleConfig = {
|
||||
levels: string[];
|
||||
defaultLevel: string;
|
||||
models: Record<string, string>;
|
||||
emoji: Record<string, string>;
|
||||
completionResults: string[];
|
||||
enabled: boolean;
|
||||
};
|
||||
121
lib/dispatch.ts
121
lib/dispatch.ts
@@ -13,8 +13,10 @@ import {
|
||||
getSessionForLevel,
|
||||
getWorker,
|
||||
} from "./projects.js";
|
||||
import { resolveModel, getEmoji, getFallbackEmoji } from "./roles/index.js";
|
||||
import { resolveModel, getFallbackEmoji } from "./roles/index.js";
|
||||
import { notify, getNotificationConfig } from "./notify.js";
|
||||
import { loadConfig, type ResolvedRoleConfig } from "./config/index.js";
|
||||
import { ReviewPolicy, resolveReviewRouting } from "./workflow.js";
|
||||
|
||||
export type DispatchOpts = {
|
||||
workspaceDir: string;
|
||||
@@ -25,8 +27,8 @@ export type DispatchOpts = {
|
||||
issueTitle: string;
|
||||
issueDescription: string;
|
||||
issueUrl: string;
|
||||
role: "dev" | "qa" | "architect";
|
||||
/** Developer level (junior, medior, senior, reviewer, opus, sonnet) or raw model ID */
|
||||
role: string;
|
||||
/** Developer level (junior, mid, senior) or raw model ID */
|
||||
level: string;
|
||||
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
|
||||
fromLabel: string;
|
||||
@@ -63,7 +65,7 @@ export type DispatchResult = {
|
||||
*/
|
||||
export function buildTaskMessage(opts: {
|
||||
projectName: string;
|
||||
role: "dev" | "qa" | "architect";
|
||||
role: string;
|
||||
issueId: number;
|
||||
issueTitle: string;
|
||||
issueDescription: string;
|
||||
@@ -72,16 +74,17 @@ export function buildTaskMessage(opts: {
|
||||
baseBranch: string;
|
||||
groupId: string;
|
||||
comments?: Array<{ author: string; body: string; created_at: string }>;
|
||||
resolvedRole?: ResolvedRoleConfig;
|
||||
/** PR context for reviewer role (URL + diff) */
|
||||
prContext?: { url: string; diff?: string };
|
||||
}): string {
|
||||
const {
|
||||
projectName, role, issueId, issueTitle,
|
||||
issueDescription, issueUrl, repo, baseBranch, groupId,
|
||||
} = opts;
|
||||
|
||||
const availableResults =
|
||||
role === "dev" || role === "architect"
|
||||
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
|
||||
: '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)';
|
||||
const results = opts.resolvedRole?.completionResults ?? [];
|
||||
const availableResults = results.map((r: string) => `"${r}"`).join(", ");
|
||||
|
||||
const parts = [
|
||||
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
|
||||
@@ -101,6 +104,19 @@ export function buildTaskMessage(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// Include PR context for reviewer role
|
||||
if (opts.prContext) {
|
||||
parts.push(``, `## Pull Request`, `🔗 ${opts.prContext.url}`);
|
||||
if (opts.prContext.diff) {
|
||||
// Truncate large diffs to avoid bloating context
|
||||
const maxDiffLen = 50_000;
|
||||
const diff = opts.prContext.diff.length > maxDiffLen
|
||||
? opts.prContext.diff.slice(0, maxDiffLen) + "\n... (diff truncated, see PR for full changes)"
|
||||
: opts.prContext.diff;
|
||||
parts.push(``, `### Diff`, "```diff", diff, "```");
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(
|
||||
``,
|
||||
`Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`,
|
||||
@@ -149,7 +165,10 @@ export async function dispatchTask(
|
||||
transitionLabel, provider, pluginConfig, runtime,
|
||||
} = opts;
|
||||
|
||||
const model = resolveModel(role, level, pluginConfig);
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
const resolvedRole = resolvedConfig.roles[role];
|
||||
const { timeouts } = resolvedConfig;
|
||||
const model = resolveModel(role, level, resolvedRole);
|
||||
const worker = getWorker(project, role);
|
||||
const existingSessionKey = getSessionForLevel(worker, level);
|
||||
const sessionAction = existingSessionKey ? "send" : "spawn";
|
||||
@@ -160,16 +179,52 @@ export async function dispatchTask(
|
||||
// Fetch comments to include in task context
|
||||
const comments = await provider.listComments(issueId);
|
||||
|
||||
// Fetch PR context for reviewer role
|
||||
let prContext: { url: string; diff?: string } | undefined;
|
||||
if (role === "reviewer") {
|
||||
try {
|
||||
const prStatus = await provider.getPrStatus(issueId);
|
||||
if (prStatus.url) {
|
||||
const diff = await provider.getPrDiff(issueId) ?? undefined;
|
||||
prContext = { url: prStatus.url, diff };
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — reviewer can still work from issue context
|
||||
}
|
||||
}
|
||||
|
||||
const taskMessage = buildTaskMessage({
|
||||
projectName: project.name, role, issueId,
|
||||
issueTitle, issueDescription, issueUrl,
|
||||
repo: project.repo, baseBranch: project.baseBranch, groupId,
|
||||
comments,
|
||||
comments, resolvedRole, prContext,
|
||||
});
|
||||
|
||||
// Step 1: Transition label (this is the commitment point)
|
||||
await transitionLabel(issueId, fromLabel, toLabel);
|
||||
|
||||
// Step 1b: Apply role:level label (best-effort — failure must not abort dispatch)
|
||||
try {
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
|
||||
if (oldRoleLabels.length > 0) {
|
||||
await provider.removeLabels(issueId, oldRoleLabels);
|
||||
}
|
||||
await provider.addLabel(issueId, `${role}:${level}`);
|
||||
|
||||
// Step 1c: Apply review routing label when developer dispatched (best-effort)
|
||||
if (role === "developer") {
|
||||
const reviewLabel = resolveReviewRouting(
|
||||
resolvedConfig.workflow.reviewPolicy ?? ReviewPolicy.AUTO, level,
|
||||
);
|
||||
const oldRouting = issue.labels.filter((l) => l.startsWith("review:"));
|
||||
if (oldRouting.length > 0) await provider.removeLabels(issueId, oldRouting);
|
||||
await provider.addLabel(issueId, reviewLabel);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — label failure must not abort dispatch
|
||||
}
|
||||
|
||||
// Step 2: Send notification early (before session dispatch which can timeout)
|
||||
// This ensures users see the notification even if gateway is slow
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
@@ -192,16 +247,22 @@ export async function dispatchTask(
|
||||
channel: opts.channel ?? "telegram",
|
||||
runtime,
|
||||
},
|
||||
).catch(() => { /* non-fatal */ });
|
||||
).catch((err) => {
|
||||
auditLog(workspaceDir, "dispatch_warning", {
|
||||
step: "notify", issue: issueId, role,
|
||||
error: (err as Error).message ?? String(err),
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Step 3: Ensure session exists (fire-and-forget — don't wait for gateway)
|
||||
// Session key is deterministic, so we can proceed immediately
|
||||
ensureSessionFireAndForget(sessionKey, model);
|
||||
ensureSessionFireAndForget(sessionKey, model, workspaceDir, timeouts.sessionPatchMs);
|
||||
|
||||
// Step 4: Send task to agent (fire-and-forget)
|
||||
sendToAgent(sessionKey, taskMessage, {
|
||||
agentId, projectName: project.name, issueId, role,
|
||||
orchestratorSessionKey: opts.sessionKey,
|
||||
agentId, projectName: project.name, issueId, role, level,
|
||||
orchestratorSessionKey: opts.sessionKey, workspaceDir,
|
||||
dispatchTimeoutMs: timeouts.dispatchMs,
|
||||
});
|
||||
|
||||
// Step 5: Update worker state
|
||||
@@ -225,7 +286,7 @@ export async function dispatchTask(
|
||||
fromLabel, toLabel,
|
||||
});
|
||||
|
||||
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl);
|
||||
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl, resolvedRole);
|
||||
|
||||
return { sessionAction, sessionKey, level, model, announcement };
|
||||
}
|
||||
@@ -239,19 +300,24 @@ export async function dispatchTask(
|
||||
* Session key is deterministic, so we don't need to wait for confirmation.
|
||||
* If this fails, health check will catch orphaned state later.
|
||||
*/
|
||||
function ensureSessionFireAndForget(sessionKey: string, model: string): void {
|
||||
function ensureSessionFireAndForget(sessionKey: string, model: string, workspaceDir: string, timeoutMs = 30_000): void {
|
||||
runCommand(
|
||||
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })],
|
||||
{ timeoutMs: 30_000 },
|
||||
).catch(() => { /* fire-and-forget */ });
|
||||
{ timeoutMs },
|
||||
).catch((err) => {
|
||||
auditLog(workspaceDir, "dispatch_warning", {
|
||||
step: "ensureSession", sessionKey,
|
||||
error: (err as Error).message ?? String(err),
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function sendToAgent(
|
||||
sessionKey: string, taskMessage: string,
|
||||
opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string },
|
||||
opts: { agentId?: string; projectName: string; issueId: number; role: string; level?: string; orchestratorSessionKey?: string; workspaceDir: string; dispatchTimeoutMs?: number },
|
||||
): void {
|
||||
const gatewayParams = JSON.stringify({
|
||||
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`,
|
||||
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${sessionKey}`,
|
||||
agentId: opts.agentId ?? "devclaw",
|
||||
sessionKey,
|
||||
message: taskMessage,
|
||||
@@ -262,12 +328,18 @@ function sendToAgent(
|
||||
// Fire-and-forget: long-running agent turn, don't await
|
||||
runCommand(
|
||||
["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
|
||||
{ timeoutMs: 600_000 },
|
||||
).catch(() => { /* fire-and-forget */ });
|
||||
{ timeoutMs: opts.dispatchTimeoutMs ?? 600_000 },
|
||||
).catch((err) => {
|
||||
auditLog(opts.workspaceDir, "dispatch_warning", {
|
||||
step: "sendToAgent", sessionKey,
|
||||
issue: opts.issueId, role: opts.role,
|
||||
error: (err as Error).message ?? String(err),
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function recordWorkerState(
|
||||
workspaceDir: string, groupId: string, role: "dev" | "qa" | "architect",
|
||||
workspaceDir: string, groupId: string, role: string,
|
||||
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
|
||||
): Promise<void> {
|
||||
await activateWorker(workspaceDir, groupId, role, {
|
||||
@@ -301,8 +373,9 @@ async function auditDispatch(
|
||||
function buildAnnouncement(
|
||||
level: string, role: string, sessionAction: "spawn" | "send",
|
||||
issueId: number, issueTitle: string, issueUrl: string,
|
||||
resolvedRole?: ResolvedRoleConfig,
|
||||
): string {
|
||||
const emoji = getEmoji(role, level) ?? getFallbackEmoji(role);
|
||||
const emoji = resolvedRole?.emoji[level] ?? getFallbackEmoji(role);
|
||||
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
||||
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
||||
}
|
||||
|
||||
141
lib/migrations.ts
Normal file
141
lib/migrations.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* migrations.ts — Backward-compatibility aliases and migration logic.
|
||||
*
|
||||
* Contains all role/level renaming aliases and projects.json format migration.
|
||||
* This file can be removed once all users have migrated to the new format.
|
||||
*
|
||||
* Migrations handled:
|
||||
* - Role renames: dev → developer, qa → tester
|
||||
* - Level renames: mid → medior, reviewer → medior, tester → junior, opus → senior, sonnet → junior
|
||||
* - projects.json format: old hardcoded dev/qa/architect fields → workers map
|
||||
* - projects.json format: old role keys in workers map → canonical role keys
|
||||
*/
|
||||
|
||||
import type { WorkerState, Project } from "./projects.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role aliases — old role IDs → canonical IDs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maps old role IDs to canonical IDs. */
|
||||
export const ROLE_ALIASES: Record<string, string> = {
|
||||
dev: "developer",
|
||||
qa: "tester",
|
||||
};
|
||||
|
||||
/** Resolve a role ID, applying aliases for backward compatibility. */
|
||||
export function canonicalRole(role: string): string {
|
||||
return ROLE_ALIASES[role] ?? role;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level aliases — old level names → canonical names, per role
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maps old level names to canonical names, per role. */
|
||||
export const LEVEL_ALIASES: Record<string, Record<string, string>> = {
|
||||
developer: { mid: "medior", medior: "medior" },
|
||||
dev: { mid: "medior", medior: "medior" },
|
||||
tester: { mid: "medior", reviewer: "medior", tester: "junior" },
|
||||
qa: { mid: "medior", reviewer: "medior", tester: "junior" },
|
||||
architect: { opus: "senior", sonnet: "junior" },
|
||||
};
|
||||
|
||||
/** Resolve a level name, applying aliases for backward compatibility. */
|
||||
export function canonicalLevel(role: string, level: string): string {
|
||||
return LEVEL_ALIASES[role]?.[level] ?? level;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// projects.json migration helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function migrateLevel(level: string | null, role: string): string | null {
|
||||
if (!level) return null;
|
||||
return LEVEL_ALIASES[role]?.[level] ?? level;
|
||||
}
|
||||
|
||||
function migrateSessions(
|
||||
sessions: Record<string, string | null>,
|
||||
role: string,
|
||||
): Record<string, string | null> {
|
||||
const aliases = LEVEL_ALIASES[role];
|
||||
if (!aliases) return sessions;
|
||||
|
||||
const migrated: Record<string, string | null> = {};
|
||||
for (const [key, value] of Object.entries(sessions)) {
|
||||
const newKey = aliases[key] ?? key;
|
||||
migrated[newKey] = value;
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function parseWorkerState(worker: Record<string, unknown>, role: string): WorkerState {
|
||||
const level = (worker.level ?? worker.tier ?? null) as string | null;
|
||||
const sessions = (worker.sessions as Record<string, string | null>) ?? {};
|
||||
return {
|
||||
active: worker.active as boolean,
|
||||
issueId: worker.issueId as string | null,
|
||||
startTime: worker.startTime as string | null,
|
||||
level: migrateLevel(level, role),
|
||||
sessions: migrateSessions(sessions, role),
|
||||
};
|
||||
}
|
||||
|
||||
/** Empty worker state with null sessions for given levels. */
|
||||
function emptyWorkerState(levels: string[]): WorkerState {
|
||||
const sessions: Record<string, string | null> = {};
|
||||
for (const l of levels) {
|
||||
sessions[l] = null;
|
||||
}
|
||||
return {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
level: null,
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a raw project object from old format to current format.
|
||||
*
|
||||
* Handles:
|
||||
* 1. Old format: hardcoded dev/qa/architect fields → workers map
|
||||
* 2. Old role keys in workers map (dev → developer, qa → tester)
|
||||
* 3. Old level names in worker state
|
||||
* 4. Missing channel field defaults to "telegram"
|
||||
*/
|
||||
export function migrateProject(project: Project): void {
|
||||
const raw = project as unknown as Record<string, unknown>;
|
||||
|
||||
if (!raw.workers && (raw.dev || raw.qa || raw.architect)) {
|
||||
// Old format: hardcoded dev/qa/architect fields → workers map
|
||||
project.workers = {};
|
||||
for (const role of ["dev", "qa", "architect"]) {
|
||||
const canonical = ROLE_ALIASES[role] ?? role;
|
||||
project.workers[canonical] = raw[role]
|
||||
? parseWorkerState(raw[role] as Record<string, unknown>, role)
|
||||
: emptyWorkerState([]);
|
||||
}
|
||||
// Clean up old fields from the in-memory object
|
||||
delete raw.dev;
|
||||
delete raw.qa;
|
||||
delete raw.architect;
|
||||
} else if (raw.workers) {
|
||||
// New format: parse each worker with role-aware migration
|
||||
const workers = raw.workers as Record<string, Record<string, unknown>>;
|
||||
project.workers = {};
|
||||
for (const [role, worker] of Object.entries(workers)) {
|
||||
// Migrate old role keys (dev→developer, qa→tester)
|
||||
const canonical = ROLE_ALIASES[role] ?? role;
|
||||
project.workers[canonical] = parseWorkerState(worker, role);
|
||||
}
|
||||
} else {
|
||||
project.workers = {};
|
||||
}
|
||||
|
||||
if (!project.channel) {
|
||||
project.channel = "telegram";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Model selection for dev/qa tasks.
|
||||
* Keyword heuristic fallback — used when the orchestrator doesn't specify a level.
|
||||
* Returns plain level names (junior, medior, senior, reviewer, tester).
|
||||
* Model selection heuristic fallback — used when the orchestrator doesn't specify a level.
|
||||
* Returns plain level names (junior, medior, senior).
|
||||
*
|
||||
* Adapts to any role's level count:
|
||||
* - 1 level: always returns that level
|
||||
* - 2 levels: simple binary (complex → last, else first)
|
||||
* - 3+ levels: full heuristic (simple → first, complex → last, default → middle)
|
||||
*/
|
||||
import { getLevelsForRole, getDefaultLevel } from "./roles/index.js";
|
||||
|
||||
export type LevelSelection = {
|
||||
level: string;
|
||||
@@ -39,62 +44,59 @@ const COMPLEX_KEYWORDS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Select appropriate developer level based on task description.
|
||||
* Select appropriate level based on task description and role.
|
||||
*
|
||||
* Developer levels:
|
||||
* - 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)
|
||||
* - reviewer: QA code inspection and validation
|
||||
* - tester: QA manual testing
|
||||
* Adapts to the role's available levels:
|
||||
* - Roles with 1 level → always that level
|
||||
* - Roles with 2 levels → binary: complex keywords → highest, else lowest
|
||||
* - Roles with 3+ levels → full heuristic: simple → lowest, complex → highest, else default
|
||||
*/
|
||||
export function selectLevel(
|
||||
issueTitle: string,
|
||||
issueDescription: string,
|
||||
role: "dev" | "qa" | "architect",
|
||||
role: string,
|
||||
): LevelSelection {
|
||||
if (role === "qa") {
|
||||
return {
|
||||
level: "reviewer",
|
||||
reason: "Default QA level for code inspection and validation",
|
||||
};
|
||||
}
|
||||
const levels = getLevelsForRole(role);
|
||||
const defaultLvl = getDefaultLevel(role);
|
||||
|
||||
if (role === "architect") {
|
||||
const text = `${issueTitle} ${issueDescription}`.toLowerCase();
|
||||
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
|
||||
return {
|
||||
level: isComplex ? "opus" : "sonnet",
|
||||
reason: isComplex
|
||||
? "Complex design task — using opus for depth"
|
||||
: "Standard design task — using sonnet",
|
||||
};
|
||||
// Roles with only 1 level — always return it
|
||||
if (levels.length <= 1) {
|
||||
const level = levels[0] ?? defaultLvl ?? "medior";
|
||||
return { level, reason: `Only level for ${role}` };
|
||||
}
|
||||
|
||||
const text = `${issueTitle} ${issueDescription}`.toLowerCase();
|
||||
const wordCount = text.split(/\s+/).length;
|
||||
|
||||
// Check for simple task indicators
|
||||
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
|
||||
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
|
||||
|
||||
const lowest = levels[0];
|
||||
const highest = levels[levels.length - 1];
|
||||
|
||||
// Roles with 2 levels — binary decision
|
||||
if (levels.length === 2) {
|
||||
if (isComplex) {
|
||||
return { level: highest, reason: `Complex task — using ${highest}` };
|
||||
}
|
||||
return { level: lowest, reason: `Standard task — using ${lowest}` };
|
||||
}
|
||||
|
||||
// Roles with 3+ levels — full heuristic
|
||||
if (isSimple && wordCount < 100) {
|
||||
return {
|
||||
level: "junior",
|
||||
level: lowest,
|
||||
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for complex task indicators
|
||||
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
|
||||
if (isComplex || wordCount > 500) {
|
||||
return {
|
||||
level: "senior",
|
||||
level: highest,
|
||||
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: medior for standard dev work
|
||||
return {
|
||||
level: "medior",
|
||||
reason: "Standard dev task — multi-file changes, features, bug fixes",
|
||||
};
|
||||
// Default level for the role
|
||||
const level = defaultLvl ?? levels[Math.floor(levels.length / 2)];
|
||||
return { level, reason: `Standard ${role} task` };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* Event types:
|
||||
* - workerStart: Worker spawned/resumed for a task (→ project group)
|
||||
* - workerComplete: Worker completed task (→ project group)
|
||||
* - reviewNeeded: Issue needs review — human or agent (→ project group)
|
||||
*/
|
||||
import { log as auditLog } from "./audit.js";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
@@ -21,7 +22,7 @@ export type NotifyEvent =
|
||||
issueId: number;
|
||||
issueTitle: string;
|
||||
issueUrl: string;
|
||||
role: "dev" | "qa" | "architect";
|
||||
role: string;
|
||||
level: string;
|
||||
sessionAction: "spawn" | "send";
|
||||
}
|
||||
@@ -31,10 +32,20 @@ export type NotifyEvent =
|
||||
groupId: string;
|
||||
issueId: number;
|
||||
issueUrl: string;
|
||||
role: "dev" | "qa" | "architect";
|
||||
role: string;
|
||||
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
||||
summary?: string;
|
||||
nextState?: string;
|
||||
}
|
||||
| {
|
||||
type: "reviewNeeded";
|
||||
project: string;
|
||||
groupId: string;
|
||||
issueId: number;
|
||||
issueUrl: string;
|
||||
issueTitle: string;
|
||||
routing: "human" | "agent";
|
||||
prUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,6 +85,15 @@ function buildMessage(event: NotifyEvent): string {
|
||||
msg += `\n🔗 ${event.issueUrl}`;
|
||||
return msg;
|
||||
}
|
||||
|
||||
case "reviewNeeded": {
|
||||
const icon = event.routing === "human" ? "👀" : "🤖";
|
||||
const who = event.routing === "human" ? "Human review needed" : "Agent review queued";
|
||||
let msg = `${icon} ${who} for #${event.issueId}: ${event.issueTitle}`;
|
||||
if (event.prUrl) msg += `\n🔗 PR: ${event.prUrl}`;
|
||||
msg += `\n📋 Issue: ${event.issueUrl}`;
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_MODELS } from "./tiers.js";
|
||||
import { getAllDefaultModels } from "./roles/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection
|
||||
@@ -14,8 +14,8 @@ import { DEFAULT_MODELS } from "./tiers.js";
|
||||
export function isPluginConfigured(
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): boolean {
|
||||
const models = (pluginConfig as { models?: Record<string, string> })?.models;
|
||||
return !!models && Object.keys(models).length > 0;
|
||||
// Models moved to workflow.yaml — check for any devclaw plugin config (heartbeat, notifications, etc.)
|
||||
return !!pluginConfig && Object.keys(pluginConfig).length > 0;
|
||||
}
|
||||
|
||||
export async function hasWorkspaceFiles(
|
||||
@@ -37,34 +37,26 @@ export async function hasWorkspaceFiles(
|
||||
// Context templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildModelTable(pluginConfig?: Record<string, unknown>): string {
|
||||
const cfg = (
|
||||
pluginConfig as {
|
||||
models?: { dev?: Record<string, string>; qa?: Record<string, string> };
|
||||
}
|
||||
)?.models;
|
||||
function buildModelTable(): string {
|
||||
const lines: string[] = [];
|
||||
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
|
||||
for (const [level, defaultModel] of Object.entries(levels)) {
|
||||
const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel;
|
||||
lines.push(
|
||||
` - **${role} ${level}**: ${model} (default: ${defaultModel})`,
|
||||
);
|
||||
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
|
||||
for (const [level, model] of Object.entries(levels)) {
|
||||
lines.push(` - **${role} ${level}**: ${model}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildReconfigContext(
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): string {
|
||||
const modelTable = buildModelTable(pluginConfig);
|
||||
export function buildReconfigContext(): string {
|
||||
const modelTable = buildModelTable();
|
||||
return `# DevClaw Reconfiguration
|
||||
|
||||
The user wants to reconfigure DevClaw. Current model configuration:
|
||||
The user wants to reconfigure DevClaw. Default model configuration:
|
||||
|
||||
${modelTable}
|
||||
|
||||
Models are configured in \`devclaw/workflow.yaml\`. Edit that file directly or call \`setup\` with a \`models\` object to update.
|
||||
|
||||
## What can be changed
|
||||
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)
|
||||
@@ -76,16 +68,14 @@ Ask what they want to change, then call the appropriate tool.
|
||||
}
|
||||
|
||||
export function buildOnboardToolContext(): string {
|
||||
// Build the model table dynamically from DEFAULT_MODELS
|
||||
// Build the model table dynamically from getAllDefaultModels()
|
||||
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",
|
||||
junior: "Simple tasks, single-file fixes",
|
||||
medior: "Features, bug fixes, code review",
|
||||
senior: "Architecture, refactoring, complex tasks",
|
||||
};
|
||||
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
|
||||
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
|
||||
for (const [level, model] of Object.entries(levels)) {
|
||||
rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`);
|
||||
}
|
||||
@@ -97,8 +87,8 @@ export function buildOnboardToolContext(): string {
|
||||
## What is DevClaw?
|
||||
DevClaw turns each Telegram group into an autonomous development team:
|
||||
- An **orchestrator** that manages backlogs and delegates work
|
||||
- **DEV workers** (junior/medior/senior levels) that write code in isolated sessions
|
||||
- **QA workers** that review code and run tests
|
||||
- **Developer workers** (junior/medior/senior levels) that write code in isolated sessions
|
||||
- **Tester workers** that review code and run tests
|
||||
- Atomic tools for label transitions, session dispatch, state management, and audit logging
|
||||
|
||||
## Setup Steps
|
||||
@@ -143,7 +133,7 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de
|
||||
|
||||
**Step 3: Run Setup**
|
||||
Call \`setup\` with the collected answers:
|
||||
- Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\`
|
||||
- Current agent: \`setup({})\` or \`setup({ models: { developer: { ... }, tester: { ... } } })\`
|
||||
- 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
|
||||
|
||||
|
||||
248
lib/projects.test.ts
Normal file
248
lib/projects.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Tests for projects.ts — worker state, migration, and accessors.
|
||||
* Run with: npx tsx --test lib/projects.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { readProjects, getWorker, emptyWorkerState, writeProjects, type ProjectsData } from "./projects.js";
|
||||
|
||||
describe("readProjects migration", () => {
|
||||
it("should migrate old format (dev/qa/architect fields) to workers map", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
await fs.mkdir(projDir, { recursive: true });
|
||||
|
||||
// Old format: hardcoded dev/qa/architect fields
|
||||
const oldFormat = {
|
||||
projects: {
|
||||
"group-1": {
|
||||
name: "test-project",
|
||||
repo: "~/git/test",
|
||||
groupName: "Test",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
dev: { active: true, issueId: "42", startTime: null, level: "mid", sessions: { mid: "key-1" } },
|
||||
qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||
architect: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8");
|
||||
|
||||
const data = await readProjects(tmpDir);
|
||||
const project = data.projects["group-1"];
|
||||
|
||||
// Should have workers map with migrated role keys
|
||||
assert.ok(project.workers, "should have workers map");
|
||||
assert.ok(project.workers.developer, "should have developer worker (migrated from dev)");
|
||||
assert.ok(project.workers.tester, "should have tester worker (migrated from qa)");
|
||||
assert.ok(project.workers.architect, "should have architect worker");
|
||||
|
||||
// Developer worker should be active with migrated level
|
||||
assert.strictEqual(project.workers.developer.active, true);
|
||||
assert.strictEqual(project.workers.developer.issueId, "42");
|
||||
assert.strictEqual(project.workers.developer.level, "medior");
|
||||
|
||||
// Old fields should not exist on the object
|
||||
assert.strictEqual((project as any).dev, undefined);
|
||||
assert.strictEqual((project as any).qa, undefined);
|
||||
assert.strictEqual((project as any).architect, undefined);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should migrate old level names in old format", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
await fs.mkdir(projDir, { recursive: true });
|
||||
|
||||
const oldFormat = {
|
||||
projects: {
|
||||
"group-1": {
|
||||
name: "legacy",
|
||||
repo: "~/git/legacy",
|
||||
groupName: "Legacy",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
dev: { active: false, issueId: null, startTime: null, level: "medior", sessions: { medior: "key-1" } },
|
||||
qa: { active: false, issueId: null, startTime: null, level: "reviewer", sessions: { reviewer: "key-2" } },
|
||||
architect: { active: false, issueId: null, startTime: null, level: "opus", sessions: { opus: "key-3" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8");
|
||||
|
||||
const data = await readProjects(tmpDir);
|
||||
const project = data.projects["group-1"];
|
||||
|
||||
// Level names should be migrated (dev→developer, qa→tester, medior→medior, reviewer→medior)
|
||||
assert.strictEqual(project.workers.developer.level, "medior");
|
||||
assert.strictEqual(project.workers.tester.level, "medior");
|
||||
assert.strictEqual(project.workers.architect.level, "senior");
|
||||
|
||||
// Session keys should be migrated
|
||||
assert.strictEqual(project.workers.developer.sessions.medior, "key-1");
|
||||
assert.strictEqual(project.workers.tester.sessions.medior, "key-2");
|
||||
assert.strictEqual(project.workers.architect.sessions.senior, "key-3");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should read new format (workers map) correctly", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||
const dataDir = path.join(tmpDir, "devclaw");
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
const newFormat = {
|
||||
projects: {
|
||||
"group-1": {
|
||||
name: "modern",
|
||||
repo: "~/git/modern",
|
||||
groupName: "Modern",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
workers: {
|
||||
developer: { active: true, issueId: "10", startTime: null, level: "senior", sessions: { senior: "key-s" } },
|
||||
tester: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(newFormat), "utf-8");
|
||||
|
||||
const data = await readProjects(tmpDir);
|
||||
const project = data.projects["group-1"];
|
||||
|
||||
assert.ok(project.workers.developer);
|
||||
assert.strictEqual(project.workers.developer.active, true);
|
||||
assert.strictEqual(project.workers.developer.level, "senior");
|
||||
assert.ok(project.workers.tester);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should migrate old worker keys in new format", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||
const dataDir = path.join(tmpDir, "devclaw");
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
// Workers map but with old role keys
|
||||
const mixedFormat = {
|
||||
projects: {
|
||||
"group-1": {
|
||||
name: "mixed",
|
||||
repo: "~/git/mixed",
|
||||
groupName: "Mixed",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
workers: {
|
||||
dev: { active: true, issueId: "10", startTime: null, level: "mid", sessions: { mid: "key-m" } },
|
||||
qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8");
|
||||
|
||||
const data = await readProjects(tmpDir);
|
||||
const project = data.projects["group-1"];
|
||||
|
||||
// Old keys should be migrated
|
||||
assert.ok(project.workers.developer, "dev should be migrated to developer");
|
||||
assert.ok(project.workers.tester, "qa should be migrated to tester");
|
||||
assert.strictEqual(project.workers.developer.level, "medior");
|
||||
assert.strictEqual(project.workers.developer.sessions.medior, "key-m");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorker", () => {
|
||||
it("should return worker from workers map", () => {
|
||||
const data: ProjectsData = {
|
||||
projects: {
|
||||
"g1": {
|
||||
name: "test",
|
||||
repo: "~/git/test",
|
||||
groupName: "Test",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
workers: {
|
||||
developer: { active: true, issueId: "5", startTime: null, level: "medior", sessions: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const worker = getWorker(data.projects["g1"], "developer");
|
||||
assert.strictEqual(worker.active, true);
|
||||
assert.strictEqual(worker.issueId, "5");
|
||||
});
|
||||
|
||||
it("should return empty worker for unknown role", () => {
|
||||
const data: ProjectsData = {
|
||||
projects: {
|
||||
"g1": {
|
||||
name: "test",
|
||||
repo: "~/git/test",
|
||||
groupName: "Test",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
workers: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const worker = getWorker(data.projects["g1"], "nonexistent");
|
||||
assert.strictEqual(worker.active, false);
|
||||
assert.strictEqual(worker.issueId, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeProjects round-trip", () => {
|
||||
it("should preserve workers map through write/read cycle", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||
const dataDir = path.join(tmpDir, "devclaw");
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
const data: ProjectsData = {
|
||||
projects: {
|
||||
"g1": {
|
||||
name: "roundtrip",
|
||||
repo: "~/git/rt",
|
||||
groupName: "RT",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
workers: {
|
||||
developer: emptyWorkerState(["junior", "medior", "senior"]),
|
||||
tester: emptyWorkerState(["junior", "medior", "senior"]),
|
||||
architect: emptyWorkerState(["junior", "senior"]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeProjects(tmpDir, data);
|
||||
const loaded = await readProjects(tmpDir);
|
||||
const project = loaded.projects["g1"];
|
||||
|
||||
assert.ok(project.workers.developer);
|
||||
assert.ok(project.workers.tester);
|
||||
assert.ok(project.workers.architect);
|
||||
assert.strictEqual(project.workers.developer.sessions.junior, null);
|
||||
assert.strictEqual(project.workers.developer.sessions.medior, null);
|
||||
assert.strictEqual(project.workers.developer.sessions.senior, null);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
type IssueComment,
|
||||
type PrStatus,
|
||||
PrState,
|
||||
} from "./provider.js";
|
||||
import { runCommand } from "../run-command.js";
|
||||
import { withResilience } from "./resilience.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW,
|
||||
getStateLabels,
|
||||
@@ -41,8 +44,38 @@ export class GitHubProvider implements IssueProvider {
|
||||
}
|
||||
|
||||
private async gh(args: string[]): Promise<string> {
|
||||
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
return withResilience(async () => {
|
||||
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find PRs associated with an issue.
|
||||
* Primary: match by head branch pattern (fix/123-, feature/123-, etc.)
|
||||
* Fallback: word-boundary match on #123 in title/body.
|
||||
*/
|
||||
private async findPrsForIssue<T extends { title: string; body: string; headRefName?: string }>(
|
||||
issueId: number,
|
||||
state: "open" | "merged" | "all",
|
||||
fields: string,
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const args = ["pr", "list", "--json", fields, "--limit", "50"];
|
||||
if (state !== "all") args.push("--state", state);
|
||||
const raw = await this.gh(args);
|
||||
if (!raw) return [];
|
||||
const prs = JSON.parse(raw) as T[];
|
||||
const branchPat = new RegExp(`^(?:fix|feature|chore|bugfix|hotfix)/${issueId}-`);
|
||||
const titlePat = new RegExp(`\\b#${issueId}\\b`);
|
||||
|
||||
// Primary: match by branch name
|
||||
const byBranch = prs.filter((pr) => pr.headRefName && branchPat.test(pr.headRefName));
|
||||
if (byBranch.length > 0) return byBranch;
|
||||
|
||||
// Fallback: word-boundary match in title/body
|
||||
return prs.filter((pr) => titlePat.test(pr.title) || titlePat.test(pr.body ?? ""));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
@@ -97,6 +130,17 @@ export class GitHubProvider implements IssueProvider {
|
||||
await this.gh(args);
|
||||
}
|
||||
|
||||
async addLabel(issueId: number, label: string): Promise<void> {
|
||||
await this.gh(["issue", "edit", String(issueId), "--add-label", label]);
|
||||
}
|
||||
|
||||
async removeLabels(issueId: number, labels: string[]): Promise<void> {
|
||||
if (labels.length === 0) return;
|
||||
const args = ["issue", "edit", String(issueId)];
|
||||
for (const l of labels) args.push("--remove-label", l);
|
||||
await this.gh(args);
|
||||
}
|
||||
|
||||
async closeIssue(issueId: number): Promise<void> { await this.gh(["issue", "close", String(issueId)]); }
|
||||
async reopenIssue(issueId: number): Promise<void> { await this.gh(["issue", "reopen", String(issueId)]); }
|
||||
|
||||
@@ -108,20 +152,47 @@ export class GitHubProvider implements IssueProvider {
|
||||
}
|
||||
|
||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body"]);
|
||||
const prs = JSON.parse(raw) as Array<{ title: string; body: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
return prs.some((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat));
|
||||
} catch { return false; }
|
||||
const prs = await this.findPrsForIssue(issueId, "merged", "title,body,headRefName");
|
||||
return prs.length > 0;
|
||||
}
|
||||
|
||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||
type MergedPr = { title: string; body: string; headRefName: string; url: string; mergedAt: string };
|
||||
const prs = await this.findPrsForIssue<MergedPr>(issueId, "merged", "title,body,headRefName,url,mergedAt");
|
||||
if (prs.length === 0) return null;
|
||||
prs.sort((a, b) => new Date(b.mergedAt).getTime() - new Date(a.mergedAt).getTime());
|
||||
return prs[0].url;
|
||||
}
|
||||
|
||||
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||
// Check open PRs first
|
||||
type OpenPr = { title: string; body: string; headRefName: string; url: string; reviewDecision: string };
|
||||
const open = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,url,reviewDecision");
|
||||
if (open.length > 0) {
|
||||
const pr = open[0];
|
||||
const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN;
|
||||
return { state, url: pr.url };
|
||||
}
|
||||
// Check merged PRs
|
||||
type MergedPr = { title: string; body: string; headRefName: string; url: string };
|
||||
const merged = await this.findPrsForIssue<MergedPr>(issueId, "merged", "title,body,headRefName,url");
|
||||
if (merged.length > 0) return { state: PrState.MERGED, url: merged[0].url };
|
||||
return { state: PrState.CLOSED, url: null };
|
||||
}
|
||||
|
||||
async mergePr(issueId: number): Promise<void> {
|
||||
type OpenPr = { title: string; body: string; headRefName: string; url: string };
|
||||
const prs = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,url");
|
||||
if (prs.length === 0) throw new Error(`No open PR found for issue #${issueId}`);
|
||||
await this.gh(["pr", "merge", prs[0].url, "--merge"]);
|
||||
}
|
||||
|
||||
async getPrDiff(issueId: number): Promise<string | null> {
|
||||
type OpenPr = { title: string; body: string; headRefName: string; number: number };
|
||||
const prs = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,number");
|
||||
if (prs.length === 0) return null;
|
||||
try {
|
||||
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "number,title,body,url,mergedAt", "--limit", "20"]);
|
||||
const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string; mergedAt: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
return prs.find((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat))?.url ?? null;
|
||||
return await this.gh(["pr", "diff", String(prs[0].number)]);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
type IssueComment,
|
||||
type PrStatus,
|
||||
PrState,
|
||||
} from "./provider.js";
|
||||
import { runCommand } from "../run-command.js";
|
||||
import { withResilience } from "./resilience.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW,
|
||||
getStateLabels,
|
||||
@@ -15,6 +18,16 @@ import {
|
||||
type WorkflowConfig,
|
||||
} from "../workflow.js";
|
||||
|
||||
type GitLabMR = {
|
||||
iid: number;
|
||||
title: string;
|
||||
description: string;
|
||||
web_url: string;
|
||||
state: string;
|
||||
merged_at: string | null;
|
||||
approved_by?: Array<unknown>;
|
||||
};
|
||||
|
||||
export class GitLabProvider implements IssueProvider {
|
||||
private repoPath: string;
|
||||
private workflow: WorkflowConfig;
|
||||
@@ -25,8 +38,19 @@ export class GitLabProvider implements IssueProvider {
|
||||
}
|
||||
|
||||
private async glab(args: string[]): Promise<string> {
|
||||
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
return withResilience(async () => {
|
||||
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
});
|
||||
}
|
||||
|
||||
/** Get MRs linked to an issue via GitLab's native related_merge_requests API. */
|
||||
private async getRelatedMRs(issueId: number): Promise<GitLabMR[]> {
|
||||
try {
|
||||
const raw = await this.glab(["api", `projects/:id/issues/${issueId}/related_merge_requests`, "--paginate"]);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as GitLabMR[];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
@@ -91,6 +115,17 @@ export class GitLabProvider implements IssueProvider {
|
||||
await this.glab(args);
|
||||
}
|
||||
|
||||
async addLabel(issueId: number, label: string): Promise<void> {
|
||||
await this.glab(["issue", "update", String(issueId), "--label", label]);
|
||||
}
|
||||
|
||||
async removeLabels(issueId: number, labels: string[]): Promise<void> {
|
||||
if (labels.length === 0) return;
|
||||
const args = ["issue", "update", String(issueId)];
|
||||
for (const l of labels) args.push("--unlabel", l);
|
||||
await this.glab(args);
|
||||
}
|
||||
|
||||
async closeIssue(issueId: number): Promise<void> { await this.glab(["issue", "close", String(issueId)]); }
|
||||
async reopenIssue(issueId: number): Promise<void> { await this.glab(["issue", "reopen", String(issueId)]); }
|
||||
|
||||
@@ -102,23 +137,55 @@ export class GitLabProvider implements IssueProvider {
|
||||
}
|
||||
|
||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
||||
const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
return mrs.some((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat));
|
||||
} catch { return false; }
|
||||
const mrs = await this.getRelatedMRs(issueId);
|
||||
return mrs.some((mr) => mr.state === "merged");
|
||||
}
|
||||
|
||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||
const mrs = await this.getRelatedMRs(issueId);
|
||||
const merged = mrs
|
||||
.filter((mr) => mr.state === "merged" && mr.merged_at)
|
||||
.sort((a, b) => new Date(b.merged_at!).getTime() - new Date(a.merged_at!).getTime());
|
||||
return merged[0]?.web_url ?? null;
|
||||
}
|
||||
|
||||
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||
const mrs = await this.getRelatedMRs(issueId);
|
||||
// Check open MRs first
|
||||
const open = mrs.find((mr) => mr.state === "opened");
|
||||
if (open) {
|
||||
// related_merge_requests doesn't populate approved_by — use dedicated approvals endpoint
|
||||
const approved = await this.isMrApproved(open.iid);
|
||||
return { state: approved ? PrState.APPROVED : PrState.OPEN, url: open.web_url };
|
||||
}
|
||||
// Check merged MRs
|
||||
const merged = mrs.find((mr) => mr.state === "merged");
|
||||
if (merged) return { state: PrState.MERGED, url: merged.web_url };
|
||||
return { state: PrState.CLOSED, url: null };
|
||||
}
|
||||
|
||||
/** Check if an MR is approved via the dedicated approvals endpoint. */
|
||||
private async isMrApproved(mrIid: number): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
||||
const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string; web_url: string; merged_at: string }>;
|
||||
const pat = `#${issueId}`;
|
||||
const mr = mrs
|
||||
.filter((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat))
|
||||
.sort((a, b) => new Date(b.merged_at).getTime() - new Date(a.merged_at).getTime())[0];
|
||||
return mr?.web_url ?? null;
|
||||
const raw = await this.glab(["api", `projects/:id/merge_requests/${mrIid}/approvals`]);
|
||||
const data = JSON.parse(raw) as { approved?: boolean; approvals_left?: number };
|
||||
return data.approved === true || (data.approvals_left ?? 1) === 0;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async mergePr(issueId: number): Promise<void> {
|
||||
const mrs = await this.getRelatedMRs(issueId);
|
||||
const open = mrs.find((mr) => mr.state === "opened");
|
||||
if (!open) throw new Error(`No open MR found for issue #${issueId}`);
|
||||
await this.glab(["mr", "merge", String(open.iid)]);
|
||||
}
|
||||
|
||||
async getPrDiff(issueId: number): Promise<string | null> {
|
||||
const mrs = await this.getRelatedMRs(issueId);
|
||||
const open = mrs.find((mr) => mr.state === "opened");
|
||||
if (!open) return null;
|
||||
try {
|
||||
return await this.glab(["mr", "diff", String(open.iid)]);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,34 +2,13 @@
|
||||
* IssueProvider — Abstract interface for issue tracker operations.
|
||||
*
|
||||
* Implementations: GitHub (gh CLI), GitLab (glab CLI).
|
||||
*
|
||||
* Note: STATE_LABELS and LABEL_COLORS are kept for backward compatibility
|
||||
* but new code should use the workflow config via lib/workflow.ts.
|
||||
*/
|
||||
import { DEFAULT_WORKFLOW, getStateLabels, getLabelColors } from "../workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State labels — derived from default workflow for backward compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use workflow.getStateLabels() instead.
|
||||
* Kept for backward compatibility with existing code.
|
||||
*/
|
||||
export const STATE_LABELS = getStateLabels(DEFAULT_WORKFLOW) as readonly string[];
|
||||
|
||||
/**
|
||||
* StateLabel type — union of all valid state labels.
|
||||
* This remains a string type for flexibility with custom workflows.
|
||||
* StateLabel type — string for flexibility with custom workflows.
|
||||
*/
|
||||
export type StateLabel = string;
|
||||
|
||||
/**
|
||||
* @deprecated Use workflow.getLabelColors() instead.
|
||||
* Kept for backward compatibility with existing code.
|
||||
*/
|
||||
export const LABEL_COLORS: Record<string, string> = getLabelColors(DEFAULT_WORKFLOW);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -49,6 +28,20 @@ export type IssueComment = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/** Built-in PR states. */
|
||||
export const PrState = {
|
||||
OPEN: "open",
|
||||
APPROVED: "approved",
|
||||
MERGED: "merged",
|
||||
CLOSED: "closed",
|
||||
} as const;
|
||||
export type PrState = (typeof PrState)[keyof typeof PrState];
|
||||
|
||||
export type PrStatus = {
|
||||
state: PrState;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider interface
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,15 +54,17 @@ export interface IssueProvider {
|
||||
getIssue(issueId: number): Promise<Issue>;
|
||||
listComments(issueId: number): Promise<IssueComment[]>;
|
||||
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
|
||||
addLabel(issueId: number, label: string): Promise<void>;
|
||||
removeLabels(issueId: number, labels: string[]): Promise<void>;
|
||||
closeIssue(issueId: number): Promise<void>;
|
||||
reopenIssue(issueId: number): Promise<void>;
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean;
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
||||
hasMergedMR(issueId: number): Promise<boolean>;
|
||||
getMergedMRUrl(issueId: number): Promise<string | null>;
|
||||
getPrStatus(issueId: number): Promise<PrStatus>;
|
||||
mergePr(issueId: number): Promise<void>;
|
||||
getPrDiff(issueId: number): Promise<string | null>;
|
||||
addComment(issueId: number, body: string): Promise<void>;
|
||||
healthCheck(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** @deprecated Use IssueProvider */
|
||||
export type TaskManager = IssueProvider;
|
||||
|
||||
49
lib/providers/resilience.ts
Normal file
49
lib/providers/resilience.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* providers/resilience.ts — Retry and circuit breaker policies for provider calls.
|
||||
*
|
||||
* Uses cockatiel for lightweight resilience without heavyweight orchestration.
|
||||
* Applied to GitHub/GitLab CLI calls that can fail due to network, rate limits, or timeouts.
|
||||
*/
|
||||
import {
|
||||
ExponentialBackoff,
|
||||
retry,
|
||||
circuitBreaker,
|
||||
ConsecutiveBreaker,
|
||||
handleAll,
|
||||
wrap,
|
||||
type IPolicy,
|
||||
} from "cockatiel";
|
||||
|
||||
/**
|
||||
* Default retry policy: 3 attempts with exponential backoff.
|
||||
* Handles all errors (network, timeout, CLI failure).
|
||||
*/
|
||||
const retryPolicy = retry(handleAll, {
|
||||
maxAttempts: 3,
|
||||
backoff: new ExponentialBackoff({
|
||||
initialDelay: 500,
|
||||
maxDelay: 5_000,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Circuit breaker: opens after 5 consecutive failures, half-opens after 30s.
|
||||
* Prevents hammering a provider that's down.
|
||||
*/
|
||||
const breakerPolicy = circuitBreaker(handleAll, {
|
||||
halfOpenAfter: 30_000,
|
||||
breaker: new ConsecutiveBreaker(5),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combined policy: circuit breaker wrapping retry.
|
||||
* If circuit is open, calls fail fast without retrying.
|
||||
*/
|
||||
export const providerPolicy: IPolicy = wrap(breakerPolicy, retryPolicy);
|
||||
|
||||
/**
|
||||
* Execute a provider call with retry + circuit breaker.
|
||||
*/
|
||||
export function withResilience<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return providerPolicy.execute(() => fn());
|
||||
}
|
||||
@@ -13,6 +13,12 @@ export {
|
||||
isValidRole,
|
||||
getRole,
|
||||
requireRole,
|
||||
// Role aliases
|
||||
ROLE_ALIASES,
|
||||
canonicalRole,
|
||||
// Level aliases
|
||||
LEVEL_ALIASES,
|
||||
canonicalLevel,
|
||||
// Levels
|
||||
getLevelsForRole,
|
||||
getAllLevels,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getDefaultModel,
|
||||
getAllDefaultModels,
|
||||
resolveModel,
|
||||
canonicalLevel,
|
||||
getEmoji,
|
||||
getFallbackEmoji,
|
||||
getCompletionResults,
|
||||
@@ -28,23 +29,25 @@ import {
|
||||
describe("role registry", () => {
|
||||
it("should have all expected roles", () => {
|
||||
const ids = getAllRoleIds();
|
||||
assert.ok(ids.includes("dev"));
|
||||
assert.ok(ids.includes("qa"));
|
||||
assert.ok(ids.includes("developer"));
|
||||
assert.ok(ids.includes("tester"));
|
||||
assert.ok(ids.includes("architect"));
|
||||
assert.ok(ids.includes("reviewer"));
|
||||
});
|
||||
|
||||
it("should validate role IDs", () => {
|
||||
assert.strictEqual(isValidRole("dev"), true);
|
||||
assert.strictEqual(isValidRole("qa"), true);
|
||||
assert.strictEqual(isValidRole("developer"), true);
|
||||
assert.strictEqual(isValidRole("tester"), true);
|
||||
assert.strictEqual(isValidRole("architect"), true);
|
||||
assert.strictEqual(isValidRole("reviewer"), true);
|
||||
assert.strictEqual(isValidRole("nonexistent"), false);
|
||||
});
|
||||
|
||||
it("should get role config", () => {
|
||||
const dev = getRole("dev");
|
||||
const dev = getRole("developer");
|
||||
assert.ok(dev);
|
||||
assert.strictEqual(dev.id, "dev");
|
||||
assert.strictEqual(dev.displayName, "DEV");
|
||||
assert.strictEqual(dev.id, "developer");
|
||||
assert.strictEqual(dev.displayName, "DEVELOPER");
|
||||
});
|
||||
|
||||
it("should throw for unknown role in requireRole", () => {
|
||||
@@ -54,9 +57,10 @@ describe("role registry", () => {
|
||||
|
||||
describe("levels", () => {
|
||||
it("should return levels for each role", () => {
|
||||
assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "medior", "senior"]);
|
||||
assert.deepStrictEqual([...getLevelsForRole("qa")], ["reviewer", "tester"]);
|
||||
assert.deepStrictEqual([...getLevelsForRole("architect")], ["opus", "sonnet"]);
|
||||
assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]);
|
||||
assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]);
|
||||
assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]);
|
||||
assert.deepStrictEqual([...getLevelsForRole("reviewer")], ["junior", "senior"]);
|
||||
});
|
||||
|
||||
it("should return empty for unknown role", () => {
|
||||
@@ -66,68 +70,117 @@ describe("levels", () => {
|
||||
it("should return all levels", () => {
|
||||
const all = getAllLevels();
|
||||
assert.ok(all.includes("junior"));
|
||||
assert.ok(all.includes("reviewer"));
|
||||
assert.ok(all.includes("opus"));
|
||||
assert.ok(all.includes("medior"));
|
||||
assert.ok(all.includes("senior"));
|
||||
});
|
||||
|
||||
it("should check level membership", () => {
|
||||
assert.strictEqual(isLevelForRole("junior", "dev"), true);
|
||||
assert.strictEqual(isLevelForRole("junior", "qa"), false);
|
||||
assert.strictEqual(isLevelForRole("opus", "architect"), true);
|
||||
assert.strictEqual(isLevelForRole("junior", "developer"), true);
|
||||
assert.strictEqual(isLevelForRole("junior", "tester"), true);
|
||||
assert.strictEqual(isLevelForRole("junior", "architect"), true);
|
||||
assert.strictEqual(isLevelForRole("medior", "developer"), true);
|
||||
assert.strictEqual(isLevelForRole("medior", "architect"), false);
|
||||
});
|
||||
|
||||
it("should find role for level", () => {
|
||||
assert.strictEqual(roleForLevel("junior"), "dev");
|
||||
assert.strictEqual(roleForLevel("reviewer"), "qa");
|
||||
assert.strictEqual(roleForLevel("opus"), "architect");
|
||||
// "junior" appears in developer first (registry order)
|
||||
assert.strictEqual(roleForLevel("junior"), "developer");
|
||||
assert.strictEqual(roleForLevel("medior"), "developer");
|
||||
assert.strictEqual(roleForLevel("senior"), "developer");
|
||||
assert.strictEqual(roleForLevel("nonexistent"), undefined);
|
||||
});
|
||||
|
||||
it("should return default level", () => {
|
||||
assert.strictEqual(getDefaultLevel("dev"), "medior");
|
||||
assert.strictEqual(getDefaultLevel("qa"), "reviewer");
|
||||
assert.strictEqual(getDefaultLevel("architect"), "sonnet");
|
||||
assert.strictEqual(getDefaultLevel("developer"), "medior");
|
||||
assert.strictEqual(getDefaultLevel("tester"), "medior");
|
||||
assert.strictEqual(getDefaultLevel("architect"), "junior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("level aliases", () => {
|
||||
it("should map old developer level names", () => {
|
||||
assert.strictEqual(canonicalLevel("developer", "mid"), "medior");
|
||||
assert.strictEqual(canonicalLevel("developer", "junior"), "junior");
|
||||
assert.strictEqual(canonicalLevel("developer", "senior"), "senior");
|
||||
});
|
||||
|
||||
it("should map old dev role level names", () => {
|
||||
assert.strictEqual(canonicalLevel("dev", "mid"), "medior");
|
||||
assert.strictEqual(canonicalLevel("dev", "medior"), "medior");
|
||||
});
|
||||
|
||||
it("should map old qa/tester level names", () => {
|
||||
assert.strictEqual(canonicalLevel("tester", "mid"), "medior");
|
||||
assert.strictEqual(canonicalLevel("tester", "reviewer"), "medior");
|
||||
assert.strictEqual(canonicalLevel("qa", "reviewer"), "medior");
|
||||
assert.strictEqual(canonicalLevel("qa", "tester"), "junior");
|
||||
});
|
||||
|
||||
it("should map old architect level names", () => {
|
||||
assert.strictEqual(canonicalLevel("architect", "opus"), "senior");
|
||||
assert.strictEqual(canonicalLevel("architect", "sonnet"), "junior");
|
||||
});
|
||||
|
||||
it("should pass through unknown levels", () => {
|
||||
assert.strictEqual(canonicalLevel("developer", "custom"), "custom");
|
||||
assert.strictEqual(canonicalLevel("unknown", "whatever"), "whatever");
|
||||
});
|
||||
});
|
||||
|
||||
describe("models", () => {
|
||||
it("should return default models", () => {
|
||||
assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5");
|
||||
assert.strictEqual(getDefaultModel("qa", "reviewer"), "anthropic/claude-sonnet-4-5");
|
||||
assert.strictEqual(getDefaultModel("architect", "opus"), "anthropic/claude-opus-4-5");
|
||||
assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5");
|
||||
assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5");
|
||||
assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5");
|
||||
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("should return all default models", () => {
|
||||
const models = getAllDefaultModels();
|
||||
assert.ok(models.dev);
|
||||
assert.ok(models.qa);
|
||||
assert.ok(models.developer);
|
||||
assert.ok(models.tester);
|
||||
assert.ok(models.architect);
|
||||
assert.strictEqual(models.dev.junior, "anthropic/claude-haiku-4-5");
|
||||
assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("should resolve from config override", () => {
|
||||
const config = { models: { dev: { junior: "custom/model" } } };
|
||||
assert.strictEqual(resolveModel("dev", "junior", config), "custom/model");
|
||||
it("should resolve from resolved role config override", () => {
|
||||
const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||
assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model");
|
||||
});
|
||||
|
||||
it("should fall back to default", () => {
|
||||
assert.strictEqual(resolveModel("dev", "junior"), "anthropic/claude-haiku-4-5");
|
||||
assert.strictEqual(resolveModel("developer", "junior"), "anthropic/claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("should pass through unknown level as model ID", () => {
|
||||
assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5");
|
||||
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("should resolve via level aliases", () => {
|
||||
// "mid" alias maps to "medior" — should resolve to default medior model
|
||||
assert.strictEqual(resolveModel("developer", "mid"), "anthropic/claude-sonnet-4-5");
|
||||
// With explicit override in resolved config
|
||||
const resolvedRole = { models: { medior: "custom/old-config-model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||
assert.strictEqual(resolveModel("developer", "mid", resolvedRole), "custom/old-config-model");
|
||||
});
|
||||
|
||||
it("should resolve with resolved role overriding defaults selectively", () => {
|
||||
const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||
assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model");
|
||||
// Levels not overridden fall through to registry defaults
|
||||
assert.strictEqual(resolveModel("developer", "medior", resolvedRole), "anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("emoji", () => {
|
||||
it("should return level emoji", () => {
|
||||
assert.strictEqual(getEmoji("dev", "junior"), "⚡");
|
||||
assert.strictEqual(getEmoji("architect", "opus"), "🏗️");
|
||||
assert.strictEqual(getEmoji("developer", "junior"), "⚡");
|
||||
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
|
||||
});
|
||||
|
||||
it("should return fallback emoji", () => {
|
||||
assert.strictEqual(getFallbackEmoji("dev"), "🔧");
|
||||
assert.strictEqual(getFallbackEmoji("qa"), "🔍");
|
||||
assert.strictEqual(getFallbackEmoji("developer"), "🔧");
|
||||
assert.strictEqual(getFallbackEmoji("tester"), "🔍");
|
||||
assert.strictEqual(getFallbackEmoji("architect"), "🏗️");
|
||||
assert.strictEqual(getFallbackEmoji("nonexistent"), "📋");
|
||||
});
|
||||
@@ -135,33 +188,40 @@ describe("emoji", () => {
|
||||
|
||||
describe("completion results", () => {
|
||||
it("should return valid results per role", () => {
|
||||
assert.deepStrictEqual([...getCompletionResults("dev")], ["done", "blocked"]);
|
||||
assert.deepStrictEqual([...getCompletionResults("qa")], ["pass", "fail", "refine", "blocked"]);
|
||||
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]);
|
||||
assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]);
|
||||
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
|
||||
assert.deepStrictEqual([...getCompletionResults("reviewer")], ["approve", "reject", "blocked"]);
|
||||
});
|
||||
|
||||
it("should validate results", () => {
|
||||
assert.strictEqual(isValidResult("dev", "done"), true);
|
||||
assert.strictEqual(isValidResult("dev", "pass"), false);
|
||||
assert.strictEqual(isValidResult("qa", "pass"), true);
|
||||
assert.strictEqual(isValidResult("qa", "done"), false);
|
||||
assert.strictEqual(isValidResult("developer", "done"), true);
|
||||
assert.strictEqual(isValidResult("developer", "pass"), false);
|
||||
assert.strictEqual(isValidResult("tester", "pass"), true);
|
||||
assert.strictEqual(isValidResult("tester", "done"), false);
|
||||
assert.strictEqual(isValidResult("reviewer", "approve"), true);
|
||||
assert.strictEqual(isValidResult("reviewer", "reject"), true);
|
||||
assert.strictEqual(isValidResult("reviewer", "escalate"), false);
|
||||
assert.strictEqual(isValidResult("reviewer", "done"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session key pattern", () => {
|
||||
it("should generate pattern matching all roles", () => {
|
||||
const pattern = getSessionKeyRolePattern();
|
||||
assert.ok(pattern.includes("dev"));
|
||||
assert.ok(pattern.includes("qa"));
|
||||
assert.ok(pattern.includes("developer"));
|
||||
assert.ok(pattern.includes("tester"));
|
||||
assert.ok(pattern.includes("architect"));
|
||||
assert.ok(pattern.includes("reviewer"));
|
||||
});
|
||||
|
||||
it("should work as regex", () => {
|
||||
const pattern = getSessionKeyRolePattern();
|
||||
const regex = new RegExp(`(${pattern})`);
|
||||
assert.ok(regex.test("dev"));
|
||||
assert.ok(regex.test("qa"));
|
||||
assert.ok(regex.test("developer"));
|
||||
assert.ok(regex.test("tester"));
|
||||
assert.ok(regex.test("architect"));
|
||||
assert.ok(regex.test("reviewer"));
|
||||
assert.ok(!regex.test("nonexistent"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
import type { RoleConfig } from "./types.js";
|
||||
|
||||
export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
||||
dev: {
|
||||
id: "dev",
|
||||
displayName: "DEV",
|
||||
developer: {
|
||||
id: "developer",
|
||||
displayName: "DEVELOPER",
|
||||
levels: ["junior", "medior", "senior"],
|
||||
defaultLevel: "medior",
|
||||
models: {
|
||||
junior: "anthropic/claude-haiku-4-5",
|
||||
medior: "anthropic/claude-sonnet-4-5",
|
||||
senior: "anthropic/claude-opus-4-5",
|
||||
senior: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
emoji: {
|
||||
junior: "⚡",
|
||||
@@ -31,45 +31,66 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
||||
},
|
||||
fallbackEmoji: "🔧",
|
||||
completionResults: ["done", "blocked"],
|
||||
sessionKeyPattern: "dev",
|
||||
sessionKeyPattern: "developer",
|
||||
notifications: { onStart: true, onComplete: true },
|
||||
},
|
||||
|
||||
qa: {
|
||||
id: "qa",
|
||||
displayName: "QA",
|
||||
levels: ["reviewer", "tester"],
|
||||
defaultLevel: "reviewer",
|
||||
tester: {
|
||||
id: "tester",
|
||||
displayName: "TESTER",
|
||||
levels: ["junior", "medior", "senior"],
|
||||
defaultLevel: "medior",
|
||||
models: {
|
||||
reviewer: "anthropic/claude-sonnet-4-5",
|
||||
tester: "anthropic/claude-haiku-4-5",
|
||||
junior: "anthropic/claude-haiku-4-5",
|
||||
medior: "anthropic/claude-sonnet-4-5",
|
||||
senior: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
emoji: {
|
||||
reviewer: "🔍",
|
||||
tester: "👀",
|
||||
junior: "⚡",
|
||||
medior: "🔍",
|
||||
senior: "🧠",
|
||||
},
|
||||
fallbackEmoji: "🔍",
|
||||
completionResults: ["pass", "fail", "refine", "blocked"],
|
||||
sessionKeyPattern: "qa",
|
||||
sessionKeyPattern: "tester",
|
||||
notifications: { onStart: true, onComplete: true },
|
||||
},
|
||||
|
||||
architect: {
|
||||
id: "architect",
|
||||
displayName: "ARCHITECT",
|
||||
levels: ["opus", "sonnet"],
|
||||
defaultLevel: "sonnet",
|
||||
levels: ["junior", "senior"],
|
||||
defaultLevel: "junior",
|
||||
models: {
|
||||
opus: "anthropic/claude-opus-4-5",
|
||||
sonnet: "anthropic/claude-sonnet-4-5",
|
||||
junior: "anthropic/claude-sonnet-4-5",
|
||||
senior: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
emoji: {
|
||||
opus: "🏗️",
|
||||
sonnet: "📐",
|
||||
junior: "📐",
|
||||
senior: "🏗️",
|
||||
},
|
||||
fallbackEmoji: "🏗️",
|
||||
completionResults: ["done", "blocked"],
|
||||
sessionKeyPattern: "architect",
|
||||
notifications: { onStart: true, onComplete: true },
|
||||
},
|
||||
|
||||
reviewer: {
|
||||
id: "reviewer",
|
||||
displayName: "REVIEWER",
|
||||
levels: ["junior", "senior"],
|
||||
defaultLevel: "junior",
|
||||
models: {
|
||||
junior: "anthropic/claude-haiku-4-5",
|
||||
senior: "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
emoji: {
|
||||
junior: "👁️",
|
||||
senior: "🔬",
|
||||
},
|
||||
fallbackEmoji: "👁️",
|
||||
completionResults: ["approve", "reject", "blocked"],
|
||||
sessionKeyPattern: "reviewer",
|
||||
notifications: { onStart: true, onComplete: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
import { ROLE_REGISTRY } from "./registry.js";
|
||||
import type { RoleConfig } from "./types.js";
|
||||
import type { ResolvedRoleConfig } from "../config/types.js";
|
||||
import { ROLE_ALIASES as _ROLE_ALIASES, canonicalLevel as _canonicalLevel } from "../migrations.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role IDs
|
||||
@@ -36,6 +38,12 @@ export function requireRole(role: string): RoleConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration aliases — re-exported from lib/migrations.ts for backward compat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { ROLE_ALIASES, canonicalRole, LEVEL_ALIASES, canonicalLevel } from "../migrations.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Levels
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -90,21 +98,22 @@ export function getAllDefaultModels(): Record<string, Record<string, string>> {
|
||||
* Resolve a level to a full model ID.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Plugin config `models.<role>.<level>`
|
||||
* 1. Resolved config from workflow.yaml (three-layer merge)
|
||||
* 2. Registry default model
|
||||
* 3. Passthrough (treat level as raw model ID)
|
||||
*/
|
||||
export function resolveModel(
|
||||
role: string,
|
||||
level: string,
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
resolvedRole?: ResolvedRoleConfig,
|
||||
): string {
|
||||
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
|
||||
if (models && typeof models === "object") {
|
||||
const roleModels = models[role] as Record<string, string> | undefined;
|
||||
if (roleModels?.[level]) return roleModels[level];
|
||||
}
|
||||
return getDefaultModel(role, level) ?? level;
|
||||
const canonical = _canonicalLevel(role, level);
|
||||
|
||||
// 1. Resolved config (workflow.yaml — includes workspace + project overrides)
|
||||
if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical];
|
||||
|
||||
// 2. Built-in registry default
|
||||
return getDefaultModel(role, canonical) ?? canonical;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
/** Configuration for a single worker role. */
|
||||
export type RoleConfig = {
|
||||
/** Unique role identifier (e.g., "dev", "qa", "architect"). */
|
||||
/** Unique role identifier (e.g., "developer", "tester", "architect"). */
|
||||
id: string;
|
||||
/** Human-readable display name. */
|
||||
displayName: string;
|
||||
@@ -23,7 +23,7 @@ export type RoleConfig = {
|
||||
fallbackEmoji: string;
|
||||
/** Valid completion results for this role. */
|
||||
completionResults: readonly string[];
|
||||
/** Regex pattern fragment for session key matching (e.g., "dev|qa|architect"). */
|
||||
/** Regex pattern fragment for session key matching (e.g., "developer|tester|architect"). */
|
||||
sessionKeyPattern: string;
|
||||
/** Notification config per event type. */
|
||||
notifications: {
|
||||
|
||||
253
lib/services/bootstrap.e2e.test.ts
Normal file
253
lib/services/bootstrap.e2e.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* E2E bootstrap tests — verifies the full bootstrap hook chain:
|
||||
* dispatchTask() → session key → registerBootstrapHook fires → bootstrapFiles injected
|
||||
*
|
||||
* Uses simulateBootstrap() which registers the real hook with a mock API,
|
||||
* fires it with the session key from dispatch, and returns the resulting
|
||||
* bootstrapFiles array — proving instructions actually reach the worker.
|
||||
*
|
||||
* Run: npx tsx --test lib/services/bootstrap.e2e.test.ts
|
||||
*/
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { createTestHarness, type TestHarness } from "../testing/index.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
|
||||
describe("E2E bootstrap — hook injection", () => {
|
||||
let h: TestHarness;
|
||||
|
||||
afterEach(async () => {
|
||||
if (h) await h.cleanup();
|
||||
});
|
||||
|
||||
it("should inject project-specific instructions into bootstrapFiles", async () => {
|
||||
h = await createTestHarness({ projectName: "my-app" });
|
||||
h.provider.seedIssue({ iid: 1, title: "Add feature", labels: ["To Do"] });
|
||||
|
||||
// Write both default and project-specific prompts
|
||||
await h.writePrompt("developer", "# Default Developer\nGeneric instructions.");
|
||||
await h.writePrompt("developer", "# My App Developer\nUse React. Follow our design system.", "my-app");
|
||||
|
||||
// Dispatch to get the session key
|
||||
const result = await dispatchTask({
|
||||
workspaceDir: h.workspaceDir,
|
||||
agentId: "main",
|
||||
groupId: h.groupId,
|
||||
project: h.project,
|
||||
issueId: 1,
|
||||
issueTitle: "Add feature",
|
||||
issueDescription: "",
|
||||
issueUrl: "https://example.com/issues/1",
|
||||
role: "developer",
|
||||
level: "medior",
|
||||
fromLabel: "To Do",
|
||||
toLabel: "Doing",
|
||||
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||
provider: h.provider,
|
||||
});
|
||||
|
||||
// Fire the actual bootstrap hook with the dispatch session key
|
||||
const files = await h.simulateBootstrap(result.sessionKey);
|
||||
|
||||
// Should have exactly one injected file
|
||||
assert.strictEqual(files.length, 1, `Expected 1 bootstrap file, got ${files.length}`);
|
||||
assert.strictEqual(files[0].name, "WORKER_INSTRUCTIONS.md");
|
||||
assert.strictEqual(files[0].missing, false);
|
||||
assert.ok(files[0].path.includes("my-app"), `Path should reference project: ${files[0].path}`);
|
||||
assert.ok(files[0].path.includes("developer"), `Path should reference role: ${files[0].path}`);
|
||||
|
||||
// Content should be project-specific, NOT default
|
||||
const content = files[0].content!;
|
||||
assert.ok(content.includes("My App Developer"), `Got: ${content}`);
|
||||
assert.ok(content.includes("Use React"));
|
||||
assert.ok(!content.includes("Generic instructions"));
|
||||
});
|
||||
|
||||
it("should fall back to default instructions when no project override exists", async () => {
|
||||
h = await createTestHarness({ projectName: "other-app" });
|
||||
h.provider.seedIssue({ iid: 2, title: "Fix bug", labels: ["To Do"] });
|
||||
|
||||
// Only write default prompt — no project-specific
|
||||
await h.writePrompt("developer", "# Default Developer\nFollow coding standards.");
|
||||
|
||||
const result = await dispatchTask({
|
||||
workspaceDir: h.workspaceDir,
|
||||
agentId: "main",
|
||||
groupId: h.groupId,
|
||||
project: h.project,
|
||||
issueId: 2,
|
||||
issueTitle: "Fix bug",
|
||||
issueDescription: "",
|
||||
issueUrl: "https://example.com/issues/2",
|
||||
role: "developer",
|
||||
level: "junior",
|
||||
fromLabel: "To Do",
|
||||
toLabel: "Doing",
|
||||
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||
provider: h.provider,
|
||||
});
|
||||
|
||||
const files = await h.simulateBootstrap(result.sessionKey);
|
||||
|
||||
assert.strictEqual(files.length, 1);
|
||||
assert.ok(files[0].content!.includes("Default Developer"));
|
||||
assert.ok(files[0].content!.includes("Follow coding standards"));
|
||||
});
|
||||
|
||||
it("should inject scaffolded default instructions when no overrides exist", async () => {
|
||||
h = await createTestHarness({ projectName: "bare-app" });
|
||||
h.provider.seedIssue({ iid: 3, title: "Chore", labels: ["To Do"] });
|
||||
|
||||
// Don't write any custom prompts — ensureWorkspaceMigrated scaffolds defaults
|
||||
|
||||
const result = await dispatchTask({
|
||||
workspaceDir: h.workspaceDir,
|
||||
agentId: "main",
|
||||
groupId: h.groupId,
|
||||
project: h.project,
|
||||
issueId: 3,
|
||||
issueTitle: "Chore",
|
||||
issueDescription: "",
|
||||
issueUrl: "https://example.com/issues/3",
|
||||
role: "developer",
|
||||
level: "medior",
|
||||
fromLabel: "To Do",
|
||||
toLabel: "Doing",
|
||||
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||
provider: h.provider,
|
||||
});
|
||||
|
||||
const files = await h.simulateBootstrap(result.sessionKey);
|
||||
|
||||
// Default developer instructions are scaffolded by ensureDefaultFiles
|
||||
assert.strictEqual(files.length, 1);
|
||||
assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading");
|
||||
assert.ok(files[0].content!.includes("worktree"), "Should reference git worktree workflow");
|
||||
});
|
||||
|
||||
it("should NOT inject anything for unknown custom roles", async () => {
|
||||
h = await createTestHarness({ projectName: "custom-app" });
|
||||
|
||||
// Simulate a session key for a custom role that has no prompt file
|
||||
// This key won't parse because "investigator" isn't in the role registry
|
||||
const files = await h.simulateBootstrap(
|
||||
"agent:main:subagent:custom-app-investigator-medior",
|
||||
);
|
||||
|
||||
assert.strictEqual(files.length, 0, "Should not inject files for unknown roles");
|
||||
});
|
||||
|
||||
it("should resolve tester instructions independently from developer", async () => {
|
||||
h = await createTestHarness({ projectName: "multi-role" });
|
||||
h.provider.seedIssue({ iid: 4, title: "Test thing", labels: ["To Test"] });
|
||||
|
||||
// Write project-specific for developer, default for tester
|
||||
await h.writePrompt("developer", "# Dev for multi-role\nSpecific dev rules.", "multi-role");
|
||||
await h.writePrompt("tester", "# Default Tester\nRun integration tests.");
|
||||
|
||||
// Dispatch as tester
|
||||
const result = await dispatchTask({
|
||||
workspaceDir: h.workspaceDir,
|
||||
agentId: "main",
|
||||
groupId: h.groupId,
|
||||
project: h.project,
|
||||
issueId: 4,
|
||||
issueTitle: "Test thing",
|
||||
issueDescription: "",
|
||||
issueUrl: "https://example.com/issues/4",
|
||||
role: "tester",
|
||||
level: "medior",
|
||||
fromLabel: "To Test",
|
||||
toLabel: "Testing",
|
||||
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||
provider: h.provider,
|
||||
});
|
||||
|
||||
// Simulate bootstrap for the tester session
|
||||
const testerFiles = await h.simulateBootstrap(result.sessionKey);
|
||||
assert.strictEqual(testerFiles.length, 1);
|
||||
assert.ok(testerFiles[0].content!.includes("Default Tester"));
|
||||
assert.ok(!testerFiles[0].content!.includes("Dev for multi-role"));
|
||||
|
||||
// Simulate bootstrap for a developer session on the same project
|
||||
const devKey = result.sessionKey.replace("-tester-", "-developer-");
|
||||
const devFiles = await h.simulateBootstrap(devKey);
|
||||
assert.strictEqual(devFiles.length, 1);
|
||||
assert.ok(devFiles[0].content!.includes("Dev for multi-role"));
|
||||
assert.ok(devFiles[0].content!.includes("Specific dev rules"));
|
||||
});
|
||||
|
||||
it("should handle project names with hyphens correctly", async () => {
|
||||
h = await createTestHarness({ projectName: "my-cool-project" });
|
||||
h.provider.seedIssue({ iid: 5, title: "Hyphen test", labels: ["To Do"] });
|
||||
|
||||
await h.writePrompt(
|
||||
"developer",
|
||||
"# Hyphenated Project\nThis project has hyphens in the name.",
|
||||
"my-cool-project",
|
||||
);
|
||||
|
||||
const result = await dispatchTask({
|
||||
workspaceDir: h.workspaceDir,
|
||||
agentId: "main",
|
||||
groupId: h.groupId,
|
||||
project: h.project,
|
||||
issueId: 5,
|
||||
issueTitle: "Hyphen test",
|
||||
issueDescription: "",
|
||||
issueUrl: "https://example.com/issues/5",
|
||||
role: "developer",
|
||||
level: "senior",
|
||||
fromLabel: "To Do",
|
||||
toLabel: "Doing",
|
||||
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||
provider: h.provider,
|
||||
});
|
||||
|
||||
const files = await h.simulateBootstrap(result.sessionKey);
|
||||
|
||||
assert.strictEqual(files.length, 1);
|
||||
assert.ok(files[0].content!.includes("Hyphenated Project"));
|
||||
assert.ok(files[0].path.includes("my-cool-project"));
|
||||
});
|
||||
|
||||
it("should resolve architect instructions with project override", async () => {
|
||||
h = await createTestHarness({ projectName: "arch-proj" });
|
||||
h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["Planning"] });
|
||||
|
||||
await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines.");
|
||||
await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj");
|
||||
|
||||
const result = await dispatchTask({
|
||||
workspaceDir: h.workspaceDir,
|
||||
agentId: "main",
|
||||
groupId: h.groupId,
|
||||
project: h.project,
|
||||
issueId: 6,
|
||||
issueTitle: "Design API",
|
||||
issueDescription: "",
|
||||
issueUrl: "https://example.com/issues/6",
|
||||
role: "architect",
|
||||
level: "senior",
|
||||
fromLabel: "Planning",
|
||||
toLabel: "Planning",
|
||||
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||
provider: h.provider,
|
||||
});
|
||||
|
||||
const files = await h.simulateBootstrap(result.sessionKey);
|
||||
|
||||
assert.strictEqual(files.length, 1);
|
||||
assert.ok(files[0].content!.includes("Arch Proj Architect"));
|
||||
assert.ok(files[0].content!.includes("event-driven"));
|
||||
assert.ok(!files[0].content!.includes("General design guidelines"));
|
||||
});
|
||||
|
||||
it("should not inject when session key is not a DevClaw subagent", async () => {
|
||||
h = await createTestHarness();
|
||||
|
||||
// Non-DevClaw session key — hook should no-op
|
||||
const files = await h.simulateBootstrap("agent:main:orchestrator");
|
||||
assert.strictEqual(files.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
DEFAULT_WORKFLOW,
|
||||
getActiveLabel,
|
||||
getRevertLabel,
|
||||
hasWorkflowStates,
|
||||
type WorkflowConfig,
|
||||
type Role,
|
||||
} from "../workflow.js";
|
||||
@@ -83,13 +84,13 @@ export type SessionLookup = Map<string, GatewaySession>;
|
||||
* Returns null if gateway is unavailable (timeout, error, etc).
|
||||
* Callers should skip session liveness checks if null — unknown ≠ dead.
|
||||
*/
|
||||
export async function fetchGatewaySessions(): Promise<SessionLookup | null> {
|
||||
export async function fetchGatewaySessions(gatewayTimeoutMs = 15_000): Promise<SessionLookup | null> {
|
||||
const lookup: SessionLookup = new Map();
|
||||
|
||||
try {
|
||||
const result = await runCommand(
|
||||
["openclaw", "gateway", "call", "status", "--json"],
|
||||
{ timeoutMs: 15_000 },
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
);
|
||||
|
||||
const jsonStart = result.stdout.indexOf("{");
|
||||
@@ -151,13 +152,20 @@ export async function checkWorkerHealth(opts: {
|
||||
sessions: SessionLookup | null;
|
||||
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
||||
workflow?: WorkflowConfig;
|
||||
/** Hours after which an active worker is considered stale (default: 2) */
|
||||
staleWorkerHours?: number;
|
||||
}): Promise<HealthFix[]> {
|
||||
const {
|
||||
workspaceDir, groupId, project, role, autoFix, provider, sessions,
|
||||
workflow = DEFAULT_WORKFLOW,
|
||||
staleWorkerHours = 2,
|
||||
} = opts;
|
||||
|
||||
const fixes: HealthFix[] = [];
|
||||
|
||||
// Skip roles without workflow states (e.g. architect — tool-triggered only)
|
||||
if (!hasWorkflowStates(workflow, role)) return fixes;
|
||||
|
||||
const worker = getWorker(project, role);
|
||||
const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null;
|
||||
|
||||
@@ -316,7 +324,7 @@ export async function checkWorkerHealth(opts: {
|
||||
// ---------------------------------------------------------------------------
|
||||
if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) {
|
||||
const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000;
|
||||
if (hours > 2) {
|
||||
if (hours > staleWorkerHours) {
|
||||
const fix: HealthFix = {
|
||||
issue: {
|
||||
type: "stale_worker",
|
||||
@@ -427,6 +435,10 @@ export async function scanOrphanedLabels(opts: {
|
||||
} = opts;
|
||||
|
||||
const fixes: HealthFix[] = [];
|
||||
|
||||
// Skip roles without workflow states (e.g. architect — tool-triggered only)
|
||||
if (!hasWorkflowStates(workflow, role)) return fixes;
|
||||
|
||||
const worker = getWorker(project, role);
|
||||
|
||||
// Get labels from workflow config
|
||||
|
||||
@@ -15,10 +15,13 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readProjects } from "../projects.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
||||
import { projectTick } from "./tick.js";
|
||||
import { reviewPass } from "./review.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { getAllRoleIds } from "../roles/index.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { ExecutionMode } from "../workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -39,6 +42,7 @@ type TickResult = {
|
||||
totalPickups: number;
|
||||
totalHealthFixes: number;
|
||||
totalSkipped: number;
|
||||
totalReviewTransitions: number;
|
||||
};
|
||||
|
||||
type ServiceContext = {
|
||||
@@ -116,7 +120,7 @@ export function registerHeartbeatService(api: OpenClawPluginApi) {
|
||||
|
||||
/**
|
||||
* Discover DevClaw agents by scanning which agent workspaces have projects.
|
||||
* Self-discovering: any agent whose workspace contains projects/projects.json is processed.
|
||||
* Self-discovering: any agent whose workspace contains projects.json is processed.
|
||||
* Also checks the default workspace (agents.defaults.workspace) for projects.
|
||||
*/
|
||||
function discoverAgents(config: {
|
||||
@@ -132,7 +136,7 @@ function discoverAgents(config: {
|
||||
for (const a of config.agents?.list || []) {
|
||||
if (!a.workspace) continue;
|
||||
try {
|
||||
if (fs.existsSync(path.join(a.workspace, "projects", "projects.json"))) {
|
||||
if (hasProjects(a.workspace)) {
|
||||
agents.push({ agentId: a.id, workspace: a.workspace });
|
||||
seen.add(a.workspace);
|
||||
}
|
||||
@@ -143,7 +147,7 @@ function discoverAgents(config: {
|
||||
const defaultWorkspace = config.agents?.defaults?.workspace;
|
||||
if (defaultWorkspace && !seen.has(defaultWorkspace)) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(defaultWorkspace, "projects", "projects.json"))) {
|
||||
if (hasProjects(defaultWorkspace)) {
|
||||
agents.push({ agentId: "main", workspace: defaultWorkspace });
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
@@ -152,6 +156,15 @@ function discoverAgents(config: {
|
||||
return agents;
|
||||
}
|
||||
|
||||
/** Check if a workspace has a projects.json (new or old locations). */
|
||||
function hasProjects(workspace: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(workspace, DATA_DIR, "projects.json")) ||
|
||||
fs.existsSync(path.join(workspace, "projects.json")) ||
|
||||
fs.existsSync(path.join(workspace, "projects", "projects.json"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one heartbeat tick for all agents.
|
||||
*/
|
||||
@@ -182,6 +195,7 @@ async function processAllAgents(
|
||||
totalPickups: 0,
|
||||
totalHealthFixes: 0,
|
||||
totalSkipped: 0,
|
||||
totalReviewTransitions: 0,
|
||||
};
|
||||
|
||||
// Fetch gateway sessions once for all agents/projects
|
||||
@@ -200,6 +214,7 @@ async function processAllAgents(
|
||||
result.totalPickups += agentResult.totalPickups;
|
||||
result.totalHealthFixes += agentResult.totalHealthFixes;
|
||||
result.totalSkipped += agentResult.totalSkipped;
|
||||
result.totalReviewTransitions += agentResult.totalReviewTransitions;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -209,9 +224,9 @@ async function processAllAgents(
|
||||
* Log tick results if anything happened.
|
||||
*/
|
||||
function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void {
|
||||
if (result.totalPickups > 0 || result.totalHealthFixes > 0) {
|
||||
if (result.totalPickups > 0 || result.totalHealthFixes > 0 || result.totalReviewTransitions > 0) {
|
||||
logger.info(
|
||||
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalSkipped} skipped`,
|
||||
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalSkipped} skipped`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,60 +249,83 @@ export async function tick(opts: {
|
||||
const projectIds = Object.keys(data.projects);
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0 };
|
||||
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, totalReviewTransitions: 0 };
|
||||
}
|
||||
|
||||
const result: TickResult = {
|
||||
totalPickups: 0,
|
||||
totalHealthFixes: 0,
|
||||
totalSkipped: 0,
|
||||
totalReviewTransitions: 0,
|
||||
};
|
||||
|
||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
|
||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
|
||||
let activeProjects = 0;
|
||||
|
||||
for (const groupId of projectIds) {
|
||||
const project = data.projects[groupId];
|
||||
if (!project) continue;
|
||||
try {
|
||||
const project = data.projects[groupId];
|
||||
if (!project) continue;
|
||||
|
||||
// Health pass: auto-fix zombies and stale workers
|
||||
result.totalHealthFixes += await performHealthPass(
|
||||
workspaceDir,
|
||||
groupId,
|
||||
project,
|
||||
sessions,
|
||||
);
|
||||
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
|
||||
// Budget check: stop if we've hit the limit
|
||||
const remaining = config.maxPickupsPerTick - result.totalPickups;
|
||||
if (remaining <= 0) break;
|
||||
// Health pass: auto-fix zombies and stale workers
|
||||
result.totalHealthFixes += await performHealthPass(
|
||||
workspaceDir,
|
||||
groupId,
|
||||
project,
|
||||
sessions,
|
||||
provider,
|
||||
resolvedConfig.timeouts.staleWorkerHours,
|
||||
);
|
||||
|
||||
// Sequential project guard: don't start new projects if one is active
|
||||
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
|
||||
if (projectExecution === "sequential" && !isProjectActive && activeProjects >= 1) {
|
||||
// Review pass: transition issues whose PR check condition is met
|
||||
result.totalReviewTransitions += await reviewPass({
|
||||
workspaceDir,
|
||||
groupId,
|
||||
workflow: resolvedConfig.workflow,
|
||||
provider,
|
||||
repoPath: project.repo,
|
||||
gitPullTimeoutMs: resolvedConfig.timeouts.gitPullMs,
|
||||
});
|
||||
|
||||
// Budget check: stop if we've hit the limit
|
||||
const remaining = config.maxPickupsPerTick - result.totalPickups;
|
||||
if (remaining <= 0) break;
|
||||
|
||||
// Sequential project guard: don't start new projects if one is active
|
||||
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
|
||||
if (projectExecution === ExecutionMode.SEQUENTIAL && !isProjectActive && activeProjects >= 1) {
|
||||
result.totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tick pass: fill free worker slots
|
||||
const tickResult = await projectTick({
|
||||
workspaceDir,
|
||||
groupId,
|
||||
agentId,
|
||||
pluginConfig,
|
||||
maxPickups: remaining,
|
||||
});
|
||||
|
||||
result.totalPickups += tickResult.pickups.length;
|
||||
result.totalSkipped += tickResult.skipped.length;
|
||||
|
||||
// Notifications now handled by dispatchTask
|
||||
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
|
||||
} catch (err) {
|
||||
// Per-project isolation: one failing project doesn't crash the entire tick
|
||||
opts.logger.warn(`Heartbeat tick failed for project ${groupId}: ${(err as Error).message}`);
|
||||
result.totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tick pass: fill free worker slots
|
||||
const tickResult = await projectTick({
|
||||
workspaceDir,
|
||||
groupId,
|
||||
agentId,
|
||||
pluginConfig,
|
||||
maxPickups: remaining,
|
||||
});
|
||||
|
||||
result.totalPickups += tickResult.pickups.length;
|
||||
result.totalSkipped += tickResult.skipped.length;
|
||||
|
||||
// Notifications now handled by dispatchTask
|
||||
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
|
||||
}
|
||||
|
||||
await auditLog(workspaceDir, "heartbeat_tick", {
|
||||
projectsScanned: projectIds.length,
|
||||
healthFixes: result.totalHealthFixes,
|
||||
reviewTransitions: result.totalReviewTransitions,
|
||||
pickups: result.totalPickups,
|
||||
skipped: result.totalSkipped,
|
||||
});
|
||||
@@ -303,20 +341,22 @@ async function performHealthPass(
|
||||
groupId: string,
|
||||
project: any,
|
||||
sessions: SessionLookup | null,
|
||||
provider: import("../providers/provider.js").IssueProvider,
|
||||
staleWorkerHours?: number,
|
||||
): Promise<number> {
|
||||
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
|
||||
let fixedCount = 0;
|
||||
|
||||
for (const role of getAllRoleIds()) {
|
||||
for (const role of Object.keys(project.workers)) {
|
||||
// Check worker health (session liveness, label consistency, etc)
|
||||
const healthFixes = await checkWorkerHealth({
|
||||
workspaceDir,
|
||||
groupId,
|
||||
project,
|
||||
role: role as any,
|
||||
role,
|
||||
sessions,
|
||||
autoFix: true,
|
||||
provider,
|
||||
staleWorkerHours,
|
||||
});
|
||||
fixedCount += healthFixes.filter((f) => f.fixed).length;
|
||||
|
||||
@@ -325,7 +365,7 @@ async function performHealthPass(
|
||||
workspaceDir,
|
||||
groupId,
|
||||
project,
|
||||
role: role as any,
|
||||
role,
|
||||
autoFix: true,
|
||||
provider,
|
||||
});
|
||||
@@ -336,10 +376,10 @@ async function performHealthPass(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project has active work (dev or qa).
|
||||
* Check if a project has any active worker.
|
||||
*/
|
||||
async function checkProjectActive(workspaceDir: string, groupId: string): Promise<boolean> {
|
||||
const fresh = (await readProjects(workspaceDir)).projects[groupId];
|
||||
if (!fresh) return false;
|
||||
return fresh.dev.active || fresh.qa.active;
|
||||
return Object.values(fresh.workers).some(w => w.active);
|
||||
}
|
||||
|
||||
1053
lib/services/pipeline.e2e.test.ts
Normal file
1053
lib/services/pipeline.e2e.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,12 @@ import type { StateLabel, IssueProvider } from "../providers/provider.js";
|
||||
import { deactivateWorker } from "../projects.js";
|
||||
import { runCommand } from "../run-command.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { detectStepRouting } from "./queue-scan.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW,
|
||||
Action,
|
||||
getCompletionRule,
|
||||
getNextStateDescription,
|
||||
getCompletionEmoji,
|
||||
@@ -17,40 +21,6 @@ import {
|
||||
type WorkflowConfig,
|
||||
} from "../workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward compatibility exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use getCompletionRule() from workflow.ts instead.
|
||||
* Kept for backward compatibility.
|
||||
*/
|
||||
export const COMPLETION_RULES: Record<string, CompletionRule> = {
|
||||
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
|
||||
"qa:pass": { from: "Testing", to: "Done", closeIssue: true },
|
||||
"qa:fail": { from: "Testing", to: "To Improve", reopenIssue: true },
|
||||
"qa:refine": { from: "Testing", to: "Refining" },
|
||||
"dev:blocked": { from: "Doing", to: "Refining" },
|
||||
"qa:blocked": { from: "Testing", to: "Refining" },
|
||||
"architect:done": { from: "Designing", to: "Planning" },
|
||||
"architect:blocked": { from: "Designing", to: "Refining" },
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use getNextStateDescription() from workflow.ts instead.
|
||||
*/
|
||||
export const NEXT_STATE: Record<string, string> = {
|
||||
"dev:done": "QA queue",
|
||||
"dev:blocked": "moved to Refining - needs human input",
|
||||
"qa:pass": "Done!",
|
||||
"qa:fail": "back to DEV",
|
||||
"qa:refine": "awaiting human decision",
|
||||
"qa:blocked": "moved to Refining - needs human input",
|
||||
"architect:done": "Planning — ready for review",
|
||||
"architect:blocked": "moved to Refining - needs clarification",
|
||||
};
|
||||
|
||||
// Re-export CompletionRule type for backward compatibility
|
||||
export type { CompletionRule };
|
||||
|
||||
export type CompletionOutput = {
|
||||
@@ -72,7 +42,7 @@ export function getRule(
|
||||
result: string,
|
||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||
): CompletionRule | undefined {
|
||||
return getCompletionRule(workflow, role as "dev" | "qa", result) ?? undefined;
|
||||
return getCompletionRule(workflow, role, result) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +51,7 @@ export function getRule(
|
||||
export async function executeCompletion(opts: {
|
||||
workspaceDir: string;
|
||||
groupId: string;
|
||||
role: "dev" | "qa" | "architect";
|
||||
role: string;
|
||||
result: string;
|
||||
issueId: number;
|
||||
summary?: string;
|
||||
@@ -106,18 +76,28 @@ export async function executeCompletion(opts: {
|
||||
const rule = getCompletionRule(workflow, role, result);
|
||||
if (!rule) throw new Error(`No completion rule for ${key}`);
|
||||
|
||||
const { timeouts } = await loadConfig(workspaceDir, projectName);
|
||||
let prUrl = opts.prUrl;
|
||||
|
||||
// Git pull (dev:done)
|
||||
if (rule.gitPull) {
|
||||
try {
|
||||
await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Auto-detect PR URL (dev:done)
|
||||
if (rule.detectPr && !prUrl) {
|
||||
try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch { /* ignore */ }
|
||||
// Execute pre-notification actions
|
||||
for (const action of rule.actions) {
|
||||
switch (action) {
|
||||
case Action.GIT_PULL:
|
||||
try { await runCommand(["git", "pull"], { timeoutMs: timeouts.gitPullMs, cwd: repoPath }); } catch (err) {
|
||||
auditLog(workspaceDir, "pipeline_warning", { step: "gitPull", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||
}
|
||||
break;
|
||||
case Action.DETECT_PR:
|
||||
if (!prUrl) { try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch (err) {
|
||||
auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||
} }
|
||||
break;
|
||||
case Action.MERGE_PR:
|
||||
try { await provider.mergePr(issueId); } catch (err) {
|
||||
auditLog(workspaceDir, "pipeline_warning", { step: "mergePr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get issue early (for URL in notification)
|
||||
@@ -147,15 +127,55 @@ export async function executeCompletion(opts: {
|
||||
channel: channel ?? "telegram",
|
||||
runtime,
|
||||
},
|
||||
).catch(() => { /* non-fatal */ });
|
||||
).catch((err) => {
|
||||
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||
});
|
||||
|
||||
// Deactivate worker + transition label
|
||||
await deactivateWorker(workspaceDir, groupId, role);
|
||||
await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel);
|
||||
|
||||
// Close/reopen
|
||||
if (rule.closeIssue) await provider.closeIssue(issueId);
|
||||
if (rule.reopenIssue) await provider.reopenIssue(issueId);
|
||||
// Execute post-transition actions
|
||||
for (const action of rule.actions) {
|
||||
switch (action) {
|
||||
case Action.CLOSE_ISSUE:
|
||||
await provider.closeIssue(issueId);
|
||||
break;
|
||||
case Action.REOPEN_ISSUE:
|
||||
await provider.reopenIssue(issueId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Send review routing notification when developer completes
|
||||
if (role === "developer" && result === "done") {
|
||||
// Re-fetch issue to get labels after transition
|
||||
const updated = await provider.getIssue(issueId);
|
||||
const routing = detectStepRouting(updated.labels, "review") as "human" | "agent" | null;
|
||||
if (routing === "human" || routing === "agent") {
|
||||
notify(
|
||||
{
|
||||
type: "reviewNeeded",
|
||||
project: projectName,
|
||||
groupId,
|
||||
issueId,
|
||||
issueUrl: updated.web_url,
|
||||
issueTitle: updated.title,
|
||||
routing,
|
||||
prUrl,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
channel: channel ?? "telegram",
|
||||
runtime,
|
||||
},
|
||||
).catch((err) => {
|
||||
auditLog(workspaceDir, "pipeline_warning", { step: "reviewNotify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build announcement using workflow-derived emoji
|
||||
const emoji = getCompletionEmoji(role, result);
|
||||
@@ -172,7 +192,7 @@ export async function executeCompletion(opts: {
|
||||
nextState,
|
||||
prUrl,
|
||||
issueUrl: issue.web_url,
|
||||
issueClosed: rule.closeIssue,
|
||||
issueReopened: rule.reopenIssue,
|
||||
issueClosed: rule.actions.includes(Action.CLOSE_ISSUE),
|
||||
issueReopened: rule.actions.includes(Action.REOPEN_ISSUE),
|
||||
};
|
||||
}
|
||||
|
||||
127
lib/services/queue-scan.ts
Normal file
127
lib/services/queue-scan.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* queue-scan.ts — Issue queue scanning helpers.
|
||||
*
|
||||
* Shared by: tick (projectTick), work-start (auto-pickup), and other consumers
|
||||
* that need to find queued issues or detect roles/levels from labels.
|
||||
*/
|
||||
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||
import type { IssueProvider } from "../providers/provider.js";
|
||||
import { getLevelsForRole, getAllLevels } from "../roles/index.js";
|
||||
import {
|
||||
getQueueLabels,
|
||||
getAllQueueLabels,
|
||||
detectRoleFromLabel as workflowDetectRole,
|
||||
type WorkflowConfig,
|
||||
type Role,
|
||||
} from "../workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function detectLevelFromLabels(labels: string[]): string | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
|
||||
// Priority 1: Match role:level labels (e.g., "developer:senior", "tester:junior")
|
||||
for (const l of lower) {
|
||||
const colon = l.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const level = l.slice(colon + 1);
|
||||
const all = getAllLevels();
|
||||
if (all.includes(level)) return level;
|
||||
}
|
||||
|
||||
// Priority 2: Match legacy role.level labels (e.g., "dev.senior", "qa.mid")
|
||||
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);
|
||||
const roleLevels = getLevelsForRole(role);
|
||||
if (roleLevels.includes(level)) return level;
|
||||
}
|
||||
|
||||
// Fallback: plain level name
|
||||
const all = getAllLevels();
|
||||
return all.find((l) => lower.includes(l)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect role and level from colon-format labels (e.g. "developer:senior").
|
||||
* Returns the first match found, or null if no role:level label exists.
|
||||
*/
|
||||
export function detectRoleLevelFromLabels(
|
||||
labels: string[],
|
||||
): { role: string; level: string } | null {
|
||||
for (const label of labels) {
|
||||
const colon = label.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const role = label.slice(0, colon).toLowerCase();
|
||||
const level = label.slice(colon + 1).toLowerCase();
|
||||
const roleLevels = getLevelsForRole(role);
|
||||
if (roleLevels.includes(level)) return { role, level };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect step routing from labels (e.g. "review:human", "test:skip").
|
||||
* Returns the routing value for the given step, or null if no routing label exists.
|
||||
*/
|
||||
export function detectStepRouting(
|
||||
labels: string[], step: string,
|
||||
): string | null {
|
||||
const prefix = `${step}:`;
|
||||
const match = labels.find((l) => l.toLowerCase().startsWith(prefix));
|
||||
return match ? match.slice(prefix.length).toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect role from a label using workflow config.
|
||||
*/
|
||||
export function detectRoleFromLabel(
|
||||
label: StateLabel,
|
||||
workflow: WorkflowConfig,
|
||||
): Role | null {
|
||||
return workflowDetectRole(workflow, label);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue queue queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function findNextIssueForRole(
|
||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||
role: Role,
|
||||
workflow: WorkflowConfig,
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = getQueueLabels(workflow, role);
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
|
||||
*/
|
||||
export async function findNextIssue(
|
||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||
role: Role | undefined,
|
||||
workflow: WorkflowConfig,
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = role
|
||||
? getQueueLabels(workflow, role)
|
||||
: getAllQueueLabels(workflow);
|
||||
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -9,30 +9,11 @@ import { createProvider } from "../providers/index.js";
|
||||
import type { Project } from "../projects.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW,
|
||||
StateType,
|
||||
type WorkflowConfig,
|
||||
type Role,
|
||||
} from "../workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use string labels from workflow config instead.
|
||||
* Kept for backward compatibility.
|
||||
*/
|
||||
export type QueueLabel = "To Improve" | "To Test" | "To Do";
|
||||
|
||||
/**
|
||||
* @deprecated Use getQueuePriority() instead.
|
||||
* Kept for backward compatibility.
|
||||
*/
|
||||
export const QUEUE_PRIORITY: Record<string, number> = {
|
||||
"To Improve": 3,
|
||||
"To Test": 2,
|
||||
"To Do": 1,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow-driven helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -47,7 +28,7 @@ export function getQueueLabelsWithPriority(
|
||||
const labels: Array<{ label: string; priority: number; role?: Role }> = [];
|
||||
|
||||
for (const state of Object.values(workflow.states)) {
|
||||
if (state.type === "queue") {
|
||||
if (state.type === StateType.QUEUE) {
|
||||
labels.push({
|
||||
label: state.label,
|
||||
priority: state.priority ?? 0,
|
||||
|
||||
133
lib/services/review.ts
Normal file
133
lib/services/review.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* review.ts — Poll review-type states for PR status changes.
|
||||
*
|
||||
* Scans review states in the workflow and transitions issues
|
||||
* whose PR check condition (merged/approved) is met.
|
||||
* Called by the heartbeat service during its periodic sweep.
|
||||
*/
|
||||
import type { IssueProvider } from "../providers/provider.js";
|
||||
import { PrState } from "../providers/provider.js";
|
||||
import {
|
||||
Action,
|
||||
ReviewCheck,
|
||||
WorkflowEvent,
|
||||
type WorkflowConfig,
|
||||
type StateConfig,
|
||||
} from "../workflow.js";
|
||||
import { runCommand } from "../run-command.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
|
||||
/**
|
||||
* Scan review-type states and transition issues whose PR check condition is met.
|
||||
* Returns the number of transitions made.
|
||||
*/
|
||||
export async function reviewPass(opts: {
|
||||
workspaceDir: string;
|
||||
groupId: string;
|
||||
workflow: WorkflowConfig;
|
||||
provider: IssueProvider;
|
||||
repoPath: string;
|
||||
gitPullTimeoutMs?: number;
|
||||
}): Promise<number> {
|
||||
const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts;
|
||||
let transitions = 0;
|
||||
|
||||
// Find all states with a review check (e.g. toReview with check: prApproved)
|
||||
const reviewStates = Object.entries(workflow.states)
|
||||
.filter(([, s]) => s.check != null) as [string, StateConfig][];
|
||||
|
||||
for (const [stateKey, state] of reviewStates) {
|
||||
if (!state.on || !state.check) continue;
|
||||
|
||||
const issues = await provider.listIssuesByLabel(state.label);
|
||||
for (const issue of issues) {
|
||||
const status = await provider.getPrStatus(issue.iid);
|
||||
|
||||
const conditionMet =
|
||||
(state.check === ReviewCheck.PR_MERGED && status.state === PrState.MERGED) ||
|
||||
(state.check === ReviewCheck.PR_APPROVED && (status.state === PrState.APPROVED || status.state === PrState.MERGED));
|
||||
|
||||
if (!conditionMet) continue;
|
||||
|
||||
// Find the success transition — use the APPROVED event (matches check condition)
|
||||
const successEvent = Object.keys(state.on).find(
|
||||
(e) => e === WorkflowEvent.APPROVED,
|
||||
);
|
||||
if (!successEvent) continue;
|
||||
|
||||
const transition = state.on[successEvent];
|
||||
const targetKey = typeof transition === "string" ? transition : transition.target;
|
||||
const actions = typeof transition === "object" ? transition.actions : undefined;
|
||||
const targetState = workflow.states[targetKey];
|
||||
if (!targetState) continue;
|
||||
|
||||
// Execute transition actions — mergePr is critical (aborts on failure)
|
||||
let aborted = false;
|
||||
if (actions) {
|
||||
for (const action of actions) {
|
||||
switch (action) {
|
||||
case Action.MERGE_PR:
|
||||
try {
|
||||
await provider.mergePr(issue.iid);
|
||||
} catch (err) {
|
||||
// Merge failed → fire MERGE_FAILED transition (developer fixes conflicts)
|
||||
await auditLog(workspaceDir, "review_merge_failed", {
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
from: state.label,
|
||||
error: (err as Error).message ?? String(err),
|
||||
});
|
||||
const failedTransition = state.on[WorkflowEvent.MERGE_FAILED];
|
||||
if (failedTransition) {
|
||||
const failedKey = typeof failedTransition === "string" ? failedTransition : failedTransition.target;
|
||||
const failedState = workflow.states[failedKey];
|
||||
if (failedState) {
|
||||
await provider.transitionLabel(issue.iid, state.label, failedState.label);
|
||||
await auditLog(workspaceDir, "review_transition", {
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
from: state.label,
|
||||
to: failedState.label,
|
||||
reason: "merge_failed",
|
||||
});
|
||||
transitions++;
|
||||
}
|
||||
}
|
||||
aborted = true;
|
||||
}
|
||||
break;
|
||||
case Action.GIT_PULL:
|
||||
try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ }
|
||||
break;
|
||||
case Action.CLOSE_ISSUE:
|
||||
await provider.closeIssue(issue.iid);
|
||||
break;
|
||||
case Action.REOPEN_ISSUE:
|
||||
await provider.reopenIssue(issue.iid);
|
||||
break;
|
||||
}
|
||||
if (aborted) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (aborted) continue; // skip normal transition, move to next issue
|
||||
|
||||
// Transition label
|
||||
await provider.transitionLabel(issue.iid, state.label, targetState.label);
|
||||
|
||||
await auditLog(workspaceDir, "review_transition", {
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
from: state.label,
|
||||
to: targetState.label,
|
||||
check: state.check,
|
||||
prState: status.state,
|
||||
prUrl: status.url,
|
||||
});
|
||||
|
||||
transitions++;
|
||||
}
|
||||
}
|
||||
|
||||
return transitions;
|
||||
}
|
||||
@@ -11,103 +11,16 @@ import { createProvider } from "../providers/index.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { getAllRoleIds, getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js";
|
||||
import { getLevelsForRole } from "../roles/index.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW,
|
||||
getQueueLabels,
|
||||
getAllQueueLabels,
|
||||
ExecutionMode,
|
||||
ReviewPolicy,
|
||||
getActiveLabel,
|
||||
detectRoleFromLabel as workflowDetectRole,
|
||||
type WorkflowConfig,
|
||||
type Role,
|
||||
} from "../workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward compatibility exports (deprecated)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use getQueueLabels(workflow, "dev") instead.
|
||||
*/
|
||||
export const DEV_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "dev");
|
||||
|
||||
/**
|
||||
* @deprecated Use getQueueLabels(workflow, "qa") instead.
|
||||
*/
|
||||
export const QA_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "qa");
|
||||
|
||||
/**
|
||||
* @deprecated Use getAllQueueLabels(workflow) instead.
|
||||
*/
|
||||
export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers (used by tick, work-start, auto-pickup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function detectLevelFromLabels(labels: string[]): string | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
|
||||
// Match role.level labels (e.g., "dev.senior", "qa.reviewer", "architect.opus")
|
||||
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);
|
||||
const roleLevels = getLevelsForRole(role);
|
||||
if (roleLevels.includes(level)) return level;
|
||||
}
|
||||
|
||||
// Fallback: plain level name
|
||||
const all = getAllLevels();
|
||||
return all.find((l) => lower.includes(l)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect role from a label using workflow config.
|
||||
*/
|
||||
export function detectRoleFromLabel(
|
||||
label: StateLabel,
|
||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||
): Role | null {
|
||||
return workflowDetectRole(workflow, label);
|
||||
}
|
||||
|
||||
export async function findNextIssueForRole(
|
||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||
role: Role,
|
||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = getQueueLabels(workflow, role);
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
|
||||
*/
|
||||
export async function findNextIssue(
|
||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||
role?: Role,
|
||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||
const labels = role
|
||||
? getQueueLabels(workflow, role)
|
||||
: getAllQueueLabels(workflow);
|
||||
|
||||
for (const label of labels) {
|
||||
try {
|
||||
const issues = await provider.listIssuesByLabel(label);
|
||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import { detectRoleLevelFromLabels, detectStepRouting, findNextIssueForRole } from "./queue-scan.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// projectTick
|
||||
@@ -156,15 +69,20 @@ export async function projectTick(opts: {
|
||||
const {
|
||||
workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun,
|
||||
maxPickups, targetRole, runtime,
|
||||
workflow = DEFAULT_WORKFLOW,
|
||||
} = opts;
|
||||
|
||||
const project = (await readProjects(workspaceDir)).projects[groupId];
|
||||
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
|
||||
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
const workflow = opts.workflow ?? resolvedConfig.workflow;
|
||||
|
||||
const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider;
|
||||
const roleExecution = project.roleExecution ?? "parallel";
|
||||
const roles: Role[] = targetRole ? [targetRole] : getAllRoleIds() as Role[];
|
||||
const roleExecution = project.roleExecution ?? ExecutionMode.PARALLEL;
|
||||
const enabledRoles = Object.entries(resolvedConfig.roles)
|
||||
.filter(([, r]) => r.enabled)
|
||||
.map(([id]) => id);
|
||||
const roles: Role[] = targetRole ? [targetRole] : enabledRoles;
|
||||
|
||||
const pickups: TickAction[] = [];
|
||||
const skipped: TickResult["skipped"] = [];
|
||||
@@ -186,18 +104,43 @@ export async function projectTick(opts: {
|
||||
continue;
|
||||
}
|
||||
// Check sequential role execution: any other role must be inactive
|
||||
const otherRoles = getAllRoleIds().filter(r => r !== role);
|
||||
if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r as any).active)) {
|
||||
const otherRoles = enabledRoles.filter((r: string) => r !== role);
|
||||
if (roleExecution === ExecutionMode.SEQUENTIAL && otherRoles.some((r: string) => getWorker(fresh, r).active)) {
|
||||
skipped.push({ role, reason: "Sequential: other role active" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Review policy gate: fallback for issues dispatched before step routing labels existed
|
||||
if (role === "reviewer") {
|
||||
const policy = workflow.reviewPolicy ?? ReviewPolicy.AUTO;
|
||||
if (policy === ReviewPolicy.HUMAN) {
|
||||
skipped.push({ role, reason: "Review policy: human (heartbeat handles via PR polling)" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const next = await findNextIssueForRole(provider, role, workflow);
|
||||
if (!next) continue;
|
||||
|
||||
const { issue, label: currentLabel } = next;
|
||||
const targetLabel = getActiveLabel(workflow, role);
|
||||
|
||||
// Step routing: check for review:human / review:skip / test:skip labels
|
||||
if (role === "reviewer") {
|
||||
const routing = detectStepRouting(issue.labels, "review");
|
||||
if (routing === "human" || routing === "skip") {
|
||||
skipped.push({ role, reason: `review:${routing} label` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (role === "tester") {
|
||||
const routing = detectStepRouting(issue.labels, "test");
|
||||
if (routing === "skip") {
|
||||
skipped.push({ role, reason: "test:skip label" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Level selection: label → heuristic
|
||||
const selectedLevel = resolveLevelForIssue(issue, role);
|
||||
|
||||
@@ -241,15 +184,25 @@ export async function projectTick(opts: {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine the level for an issue based on labels, role overrides, and heuristic fallback.
|
||||
* Determine the level for an issue based on labels and heuristic fallback.
|
||||
*
|
||||
* Priority:
|
||||
* 1. This role's own label (e.g. tester:medior from a previous dispatch)
|
||||
* 2. Inherit from another role's label (e.g. developer:medior → tester uses medior)
|
||||
* 3. Heuristic fallback (first dispatch, no labels yet)
|
||||
*/
|
||||
function resolveLevelForIssue(issue: Issue, role: Role): string {
|
||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||
if (labelLevel) {
|
||||
const labelRole = roleForLevel(labelLevel);
|
||||
// If label level belongs to a different role, use heuristic for correct role
|
||||
if (labelRole && labelRole !== role) return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||
return labelLevel;
|
||||
const roleLevel = detectRoleLevelFromLabels(issue.labels);
|
||||
|
||||
// Own role label
|
||||
if (roleLevel?.role === role) return roleLevel.level;
|
||||
|
||||
// Inherit from another role's label if level is valid for this role
|
||||
if (roleLevel) {
|
||||
const levels = getLevelsForRole(role);
|
||||
if (levels.includes(roleLevel.level)) return roleLevel.level;
|
||||
}
|
||||
|
||||
// Heuristic fallback
|
||||
return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
/**
|
||||
* setup/config.ts — Plugin config writer (openclaw.json).
|
||||
*
|
||||
* Handles: model level config, tool restrictions, subagent cleanup.
|
||||
* Handles: tool restrictions, subagent cleanup, heartbeat defaults.
|
||||
* Models are stored in workflow.yaml (not openclaw.json).
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
||||
|
||||
type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
|
||||
import type { ExecutionMode } from "../workflow.js";
|
||||
|
||||
/**
|
||||
* Write DevClaw model level config to openclaw.json plugins section.
|
||||
* Write DevClaw plugin config to openclaw.json plugins section.
|
||||
*
|
||||
* Also configures:
|
||||
* Configures:
|
||||
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
|
||||
* - Subagent cleanup interval (30 days) to keep development sessions alive
|
||||
* - Heartbeat defaults
|
||||
*
|
||||
* Read-modify-write to preserve existing config.
|
||||
* Note: models are NOT stored here — they live in workflow.yaml.
|
||||
*/
|
||||
export async function writePluginConfig(
|
||||
api: OpenClawPluginApi,
|
||||
models: ModelConfig,
|
||||
agentId?: string,
|
||||
projectExecution?: "parallel" | "sequential",
|
||||
projectExecution?: ExecutionMode,
|
||||
): Promise<void> {
|
||||
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
||||
|
||||
ensurePluginStructure(config);
|
||||
(config as any).plugins.entries.devclaw.config.models = models;
|
||||
|
||||
if (projectExecution) {
|
||||
(config as any).plugins.entries.devclaw.config.projectExecution = projectExecution;
|
||||
}
|
||||
|
||||
// Clean up legacy models from openclaw.json (moved to workflow.yaml)
|
||||
delete (config as any).plugins.entries.devclaw.config.models;
|
||||
|
||||
ensureInternalHooks(config);
|
||||
ensureHeartbeatDefaults(config);
|
||||
configureSubagentCleanup(config);
|
||||
|
||||
@@ -74,6 +78,13 @@ function addToolRestrictions(config: Record<string, unknown>, agentId: string):
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInternalHooks(config: Record<string, unknown>): void {
|
||||
if (!config.hooks) config.hooks = {};
|
||||
const hooks = config.hooks as Record<string, unknown>;
|
||||
if (!hooks.internal) hooks.internal = {};
|
||||
(hooks.internal as Record<string, unknown>).enabled = true;
|
||||
}
|
||||
|
||||
function ensureHeartbeatDefaults(config: Record<string, unknown>): void {
|
||||
const devclaw = (config as any).plugins.entries.devclaw.config;
|
||||
if (!devclaw.work_heartbeat) {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
/**
|
||||
* setup/index.ts — DevClaw setup orchestrator.
|
||||
*
|
||||
* Coordinates: agent creation → model config → workspace scaffolding.
|
||||
* Coordinates: agent creation → plugin config → workspace scaffolding → model config.
|
||||
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_MODELS } from "../tiers.js";
|
||||
import { getAllDefaultModels } from "../roles/index.js";
|
||||
import { migrateChannelBinding } from "../binding-manager.js";
|
||||
import { createAgent, resolveWorkspacePath } from "./agent.js";
|
||||
import { writePluginConfig } from "./config.js";
|
||||
import { scaffoldWorkspace } from "./workspace.js";
|
||||
import { DATA_DIR } from "./migrate-layout.js";
|
||||
import type { ExecutionMode } from "../workflow.js";
|
||||
|
||||
export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
|
||||
export type ModelConfig = Record<string, Record<string, string>>;
|
||||
|
||||
export type SetupOpts = {
|
||||
/** OpenClaw plugin API for config access. */
|
||||
@@ -27,9 +32,9 @@ export type SetupOpts = {
|
||||
/** Override workspace path (auto-detected from agent if not given). */
|
||||
workspacePath?: string;
|
||||
/** Model overrides per role.level. Missing levels use defaults. */
|
||||
models?: { dev?: Partial<Record<string, string>>; qa?: Partial<Record<string, string>> };
|
||||
models?: Record<string, Partial<Record<string, string>>>;
|
||||
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
|
||||
projectExecution?: "parallel" | "sequential";
|
||||
projectExecution?: ExecutionMode;
|
||||
};
|
||||
|
||||
export type SetupResult = {
|
||||
@@ -49,8 +54,9 @@ export type SetupResult = {
|
||||
* 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)
|
||||
* 2. Write plugin config to openclaw.json (heartbeat, tool restrictions — no models)
|
||||
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, workflow.yaml, prompts)
|
||||
* 4. Write model config to workflow.yaml (single source of truth)
|
||||
*/
|
||||
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
const warnings: string[] = [];
|
||||
@@ -58,10 +64,13 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
const { agentId, workspacePath, agentCreated, bindingMigrated } =
|
||||
await resolveOrCreateAgent(opts, warnings);
|
||||
|
||||
const models = buildModelConfig(opts.models);
|
||||
await writePluginConfig(opts.api, models, agentId, opts.projectExecution);
|
||||
await writePluginConfig(opts.api, agentId, opts.projectExecution);
|
||||
|
||||
const filesWritten = await scaffoldWorkspace(workspacePath);
|
||||
const defaultWorkspacePath = getDefaultWorkspacePath(opts.api);
|
||||
const filesWritten = await scaffoldWorkspace(workspacePath, defaultWorkspacePath);
|
||||
|
||||
const models = buildModelConfig(opts.models);
|
||||
await writeModelsToWorkflow(workspacePath, models);
|
||||
|
||||
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
|
||||
}
|
||||
@@ -113,19 +122,59 @@ async function tryMigrateBinding(
|
||||
}
|
||||
|
||||
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
|
||||
const dev: Record<string, string> = { ...DEFAULT_MODELS.dev };
|
||||
const qa: Record<string, string> = { ...DEFAULT_MODELS.qa };
|
||||
const defaults = getAllDefaultModels();
|
||||
const result: ModelConfig = {};
|
||||
|
||||
if (overrides?.dev) {
|
||||
for (const [level, model] of Object.entries(overrides.dev)) {
|
||||
if (model) dev[level] = model;
|
||||
}
|
||||
for (const [role, levels] of Object.entries(defaults)) {
|
||||
result[role] = { ...levels };
|
||||
}
|
||||
if (overrides?.qa) {
|
||||
for (const [level, model] of Object.entries(overrides.qa)) {
|
||||
if (model) qa[level] = model;
|
||||
|
||||
if (overrides) {
|
||||
for (const [role, roleOverrides] of Object.entries(overrides)) {
|
||||
if (!result[role]) result[role] = {};
|
||||
for (const [level, model] of Object.entries(roleOverrides)) {
|
||||
if (model) result[role][level] = model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dev, qa };
|
||||
return result;
|
||||
}
|
||||
|
||||
function getDefaultWorkspacePath(api: OpenClawPluginApi): string | undefined {
|
||||
try {
|
||||
const config = api.runtime.config.loadConfig();
|
||||
return (config as any).agents?.defaults?.workspace ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write model configuration to workflow.yaml (single source of truth).
|
||||
* Reads the existing workflow.yaml, merges model overrides into the roles section, and writes back.
|
||||
*/
|
||||
async function writeModelsToWorkflow(workspacePath: string, models: ModelConfig): Promise<void> {
|
||||
const workflowPath = path.join(workspacePath, DATA_DIR, "workflow.yaml");
|
||||
|
||||
let doc: Record<string, unknown> = {};
|
||||
try {
|
||||
const content = await fs.readFile(workflowPath, "utf-8");
|
||||
doc = (YAML.parse(content) as Record<string, unknown>) ?? {};
|
||||
} catch { /* file doesn't exist yet — start fresh */ }
|
||||
|
||||
// Merge models into roles section
|
||||
if (!doc.roles) doc.roles = {};
|
||||
const roles = doc.roles as Record<string, unknown>;
|
||||
|
||||
for (const [role, levels] of Object.entries(models)) {
|
||||
if (!roles[role] || roles[role] === false) {
|
||||
roles[role] = { models: levels };
|
||||
} else {
|
||||
const roleObj = roles[role] as Record<string, unknown>;
|
||||
roleObj.models = levels;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(workflowPath, YAML.stringify(doc, { lineWidth: 120 }), "utf-8");
|
||||
}
|
||||
|
||||
@@ -4,22 +4,63 @@
|
||||
* Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles.
|
||||
*/
|
||||
import { runCommand } from "../run-command.js";
|
||||
import { ROLE_REGISTRY } from "../roles/index.js";
|
||||
import type { ModelAssignment } from "./smart-model-selector.js";
|
||||
|
||||
export type ModelAssignment = {
|
||||
dev: {
|
||||
junior: string;
|
||||
medior: string;
|
||||
senior: string;
|
||||
};
|
||||
qa: {
|
||||
reviewer: string;
|
||||
tester: string;
|
||||
};
|
||||
architect: {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Build a ModelAssignment where every role/level maps to the same model.
|
||||
*/
|
||||
function singleModelAssignment(model: string): ModelAssignment {
|
||||
const result: ModelAssignment = {};
|
||||
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||
result[roleId] = {};
|
||||
for (const level of config.levels) {
|
||||
result[roleId][level] = model;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON format example for the LLM prompt, derived from registry.
|
||||
*/
|
||||
function buildJsonExample(): string {
|
||||
const obj: Record<string, Record<string, string>> = {};
|
||||
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||
obj[roleId] = {};
|
||||
for (const level of config.levels) {
|
||||
obj[roleId][level] = "provider/model-name";
|
||||
}
|
||||
}
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a parsed assignment has all required roles and levels.
|
||||
*/
|
||||
function validateAssignment(assignment: Record<string, unknown>, fallbackModel: string): ModelAssignment | null {
|
||||
const result: ModelAssignment = {};
|
||||
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||
const roleData = assignment[roleId] as Record<string, string> | undefined;
|
||||
if (!roleData) {
|
||||
// Backfill missing roles from the first available role or fallback
|
||||
result[roleId] = {};
|
||||
for (const level of config.levels) {
|
||||
result[roleId][level] = fallbackModel;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result[roleId] = {};
|
||||
for (const level of config.levels) {
|
||||
if (!roleData[level]) {
|
||||
console.error(`Missing ${roleId}.${level} in LLM assignment`);
|
||||
return null;
|
||||
}
|
||||
result[roleId][level] = roleData[level];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use an LLM to intelligently select and assign models to DevClaw roles.
|
||||
@@ -34,53 +75,33 @@ export async function selectModelsWithLLM(
|
||||
|
||||
// If only one model, assign it to all roles
|
||||
if (availableModels.length === 1) {
|
||||
const model = availableModels[0].model;
|
||||
return {
|
||||
dev: { junior: model, medior: model, senior: model },
|
||||
qa: { reviewer: model, tester: model },
|
||||
architect: { opus: model, sonnet: model },
|
||||
};
|
||||
return singleModelAssignment(availableModels[0].model);
|
||||
}
|
||||
|
||||
// Create a prompt for the LLM
|
||||
const modelList = availableModels.map((m) => m.model).join("\n");
|
||||
const jsonExample = buildJsonExample();
|
||||
|
||||
const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities.
|
||||
|
||||
Available models:
|
||||
${modelList}
|
||||
|
||||
Assign models to these roles based on capability:
|
||||
All roles use the same level scheme based on task complexity:
|
||||
- **senior** (most capable): Complex architecture, refactoring, critical decisions
|
||||
- **medior** (balanced): Features, bug fixes, code review
|
||||
- **junior** (fast/efficient): Simple fixes, testing, routine tasks
|
||||
- **reviewer** (same as medior): Code review
|
||||
- **tester** (same as junior): Testing
|
||||
- **medior** (balanced): Features, bug fixes, code review, standard tasks
|
||||
- **junior** (fast/efficient): Simple fixes, routine tasks
|
||||
|
||||
Rules:
|
||||
1. Prefer same provider for consistency
|
||||
2. Assign most capable model to senior
|
||||
3. Assign mid-tier model to medior/reviewer
|
||||
4. Assign fastest/cheapest model to junior/tester
|
||||
3. Assign mid-tier model to medior
|
||||
4. Assign fastest/cheapest model to junior
|
||||
5. Consider model version numbers (higher = newer/better)
|
||||
6. Stable versions (no date) > snapshot versions (with date like 20250514)
|
||||
|
||||
Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
||||
{
|
||||
"dev": {
|
||||
"junior": "provider/model-name",
|
||||
"medior": "provider/model-name",
|
||||
"senior": "provider/model-name"
|
||||
},
|
||||
"qa": {
|
||||
"reviewer": "provider/model-name",
|
||||
"tester": "provider/model-name"
|
||||
},
|
||||
"architect": {
|
||||
"opus": "provider/model-name",
|
||||
"sonnet": "provider/model-name"
|
||||
}
|
||||
}`;
|
||||
${jsonExample}`;
|
||||
|
||||
try {
|
||||
const sessionId = sessionKey ?? "devclaw-model-selection";
|
||||
@@ -127,27 +148,14 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
||||
// Log what we got for debugging
|
||||
console.log("LLM returned:", JSON.stringify(assignment, null, 2));
|
||||
|
||||
// Validate the structure
|
||||
// Backfill architect if LLM didn't return it (graceful upgrade)
|
||||
if (!assignment.architect) {
|
||||
assignment.architect = {
|
||||
opus: assignment.dev?.senior ?? availableModels[0].model,
|
||||
sonnet: assignment.dev?.medior ?? availableModels[0].model,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!assignment.dev?.junior ||
|
||||
!assignment.dev?.medior ||
|
||||
!assignment.dev?.senior ||
|
||||
!assignment.qa?.reviewer ||
|
||||
!assignment.qa?.tester
|
||||
) {
|
||||
// Validate and backfill
|
||||
const validated = validateAssignment(assignment, availableModels[0].model);
|
||||
if (!validated) {
|
||||
console.error("Invalid assignment structure. Got:", assignment);
|
||||
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);
|
||||
}
|
||||
|
||||
return assignment as ModelAssignment;
|
||||
return validated;
|
||||
} catch (err) {
|
||||
console.error("LLM model selection failed:", (err as Error).message);
|
||||
return null;
|
||||
|
||||
312
lib/setup/migrate-layout.test.ts
Normal file
312
lib/setup/migrate-layout.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Tests for workspace layout migration.
|
||||
* Run with: npx tsx --test lib/setup/migrate-layout.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { migrateWorkspaceLayout } from "./migrate-layout.js";
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
describe("migrateWorkspaceLayout — very old layout → devclaw/", () => {
|
||||
it("should move projects/projects.json to devclaw/projects.json", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
await fs.mkdir(projDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be at devclaw/");
|
||||
assert.ok(!await fileExists(path.join(projDir, "projects.json")), "old projects.json should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should rename projects/config.yaml to devclaw/workflow.yaml", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
await fs.mkdir(projDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(projDir, "config.yaml"), "roles:\n dev:\n defaultLevel: medior\n");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be at devclaw/");
|
||||
assert.ok(!await fileExists(path.join(projDir, "config.yaml")), "old config.yaml should be removed");
|
||||
const content = await fs.readFile(path.join(tmpDir, "devclaw", "workflow.yaml"), "utf-8");
|
||||
assert.ok(content.includes("defaultLevel: medior"), "content should be preserved");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should move roles/default/* to devclaw/prompts/ with renames", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
const defaultDir = path.join(projDir, "roles", "default");
|
||||
await fs.mkdir(defaultDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(defaultDir, "dev.md"), "# Dev instructions");
|
||||
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA instructions");
|
||||
await fs.writeFile(path.join(defaultDir, "architect.md"), "# Architect instructions");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md");
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md");
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "architect.md")), "architect.md should stay");
|
||||
|
||||
const devContent = await fs.readFile(path.join(tmpDir, "devclaw", "prompts", "developer.md"), "utf-8");
|
||||
assert.strictEqual(devContent, "# Dev instructions");
|
||||
|
||||
assert.ok(!await fileExists(defaultDir), "old default dir should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should move roles/<project>/* to devclaw/projects/<project>/prompts/ with renames", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
const roleDir = path.join(projDir, "roles", "my-app");
|
||||
await fs.mkdir(roleDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(roleDir, "dev.md"), "# My App Developer");
|
||||
await fs.writeFile(path.join(roleDir, "qa.md"), "# My App Tester");
|
||||
await fs.writeFile(path.join(roleDir, "architect.md"), "# My App Architect");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md");
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md");
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "architect.md")), "architect.md should be in prompts/");
|
||||
|
||||
const content = await fs.readFile(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md"), "utf-8");
|
||||
assert.strictEqual(content, "# My App Developer");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should rename projects/<project>/config.yaml to devclaw/projects/<project>/workflow.yaml", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
const appDir = path.join(projDir, "my-app");
|
||||
await fs.mkdir(appDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(appDir, "config.yaml"), "roles:\n dev:\n defaultLevel: senior\n");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should exist");
|
||||
assert.ok(!await fileExists(path.join(appDir, "config.yaml")), "old config.yaml should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should move log/ to devclaw/log/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projDir = path.join(tmpDir, "projects");
|
||||
const logDir = path.join(tmpDir, "log");
|
||||
await fs.mkdir(projDir, { recursive: true });
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}');
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/");
|
||||
assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old audit.log should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateWorkspaceLayout — intermediate layout → devclaw/", () => {
|
||||
it("should move projects.json from root to devclaw/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be in devclaw/");
|
||||
assert.ok(!await fileExists(path.join(tmpDir, "projects.json")), "root projects.json should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should move workflow.yaml from root to devclaw/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(tmpDir, "workflow.yaml"), "roles:\n dev:\n defaultLevel: medior\n");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be in devclaw/");
|
||||
assert.ok(!await fileExists(path.join(tmpDir, "workflow.yaml")), "root workflow.yaml should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should move prompts/ from root to devclaw/prompts/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const promptsDir = path.join(tmpDir, "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "developer.md should be in devclaw/prompts/");
|
||||
assert.ok(!await fileExists(path.join(promptsDir, "developer.md")), "old prompts/developer.md should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should move project .md files into prompts/ subdir", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projectDir = path.join(tmpDir, "projects", "my-app");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(projectDir, "developer.md"), "# My App Dev");
|
||||
await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "developer.md should be in prompts/ subdir");
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should stay at project root");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should rename old role files (dev.md, qa.md) in prompts/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const promptsDir = path.join(tmpDir, "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(promptsDir, "dev.md"), "# Old Dev");
|
||||
await fs.writeFile(path.join(promptsDir, "qa.md"), "# Old QA");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md");
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should rename old role files in project prompts/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const projectDir = path.join(tmpDir, "projects", "my-app");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(projectDir, "dev.md"), "# My App Dev");
|
||||
await fs.writeFile(path.join(projectDir, "qa.md"), "# My App QA");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md");
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should move log/ from root to devclaw/log/", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const logDir = path.join(tmpDir, "log");
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}');
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/");
|
||||
assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old log/audit.log should be removed");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateWorkspaceLayout — flat project prompts → prompts/ subdir", () => {
|
||||
it("should move flat .md files into prompts/ subdir", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const dataDir = path.join(tmpDir, "devclaw");
|
||||
const projectDir = path.join(dataDir, "projects", "my-app");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(projectDir, "developer.md"), "# Dev");
|
||||
await fs.writeFile(path.join(projectDir, "tester.md"), "# Tester");
|
||||
await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "developer.md should be in prompts/");
|
||||
assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "tester.md should be in prompts/");
|
||||
assert.ok(!await fileExists(path.join(projectDir, "developer.md")), "flat developer.md should be removed");
|
||||
assert.ok(await fileExists(path.join(projectDir, "workflow.yaml")), "workflow.yaml should stay");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should rename old role files during subdir migration", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const dataDir = path.join(tmpDir, "devclaw");
|
||||
const projectDir = path.join(dataDir, "projects", "my-app");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(projectDir, "dev.md"), "# Old Dev");
|
||||
await fs.writeFile(path.join(projectDir, "qa.md"), "# Old QA");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "dev.md should become prompts/developer.md");
|
||||
assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "qa.md should become prompts/tester.md");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should skip projects that already have prompts/ subdir", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const dataDir = path.join(tmpDir, "devclaw");
|
||||
const projectDir = path.join(dataDir, "projects", "my-app");
|
||||
const promptsDir = path.join(projectDir, "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Already migrated");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
const content = await fs.readFile(path.join(promptsDir, "developer.md"), "utf-8");
|
||||
assert.strictEqual(content, "# Already migrated", "existing prompts/ should not be touched");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateWorkspaceLayout — no-op cases", () => {
|
||||
it("should no-op when already fully migrated", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
const dataDir = path.join(tmpDir, "devclaw");
|
||||
const promptsDir = path.join(dataDir, "projects", "app", "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev");
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
assert.ok(await fileExists(path.join(promptsDir, "developer.md")), "prompts should still exist");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("should no-op when workspace is empty", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||
|
||||
await migrateWorkspaceLayout(tmpDir);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
365
lib/setup/migrate-layout.ts
Normal file
365
lib/setup/migrate-layout.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* setup/migrate-layout.ts — One-time workspace layout migration.
|
||||
*
|
||||
* Migrates from old layouts to the current devclaw/ data directory:
|
||||
*
|
||||
* Very old layout (pre-restructure):
|
||||
* projects/projects.json → devclaw/projects.json
|
||||
* projects/config.yaml → devclaw/workflow.yaml
|
||||
* projects/roles/default/* → devclaw/prompts/* (with dev.md→developer.md, qa.md→tester.md)
|
||||
* projects/roles/<project>/* → devclaw/projects/<project>/prompts/*
|
||||
* projects/<project>/config.yaml → devclaw/projects/<project>/workflow.yaml
|
||||
*
|
||||
* Intermediate layout (post-restructure, pre-devclaw/):
|
||||
* projects.json → devclaw/projects.json
|
||||
* workflow.yaml → devclaw/workflow.yaml
|
||||
* prompts/* → devclaw/prompts/*
|
||||
* projects/<project>/*.md → devclaw/projects/<project>/prompts/*
|
||||
* projects/<project>/workflow.yaml→ devclaw/projects/<project>/workflow.yaml
|
||||
* log/* → devclaw/log/*
|
||||
*
|
||||
* Flat project layout (early devclaw/ without prompts subdir):
|
||||
* devclaw/projects/<project>/*.md → devclaw/projects/<project>/prompts/*
|
||||
*
|
||||
* This file can be removed once all workspaces have been migrated.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** Role file renames: old filename → new filename. */
|
||||
const ROLE_FILE_RENAMES: Record<string, string> = {
|
||||
"dev.md": "developer.md",
|
||||
"qa.md": "tester.md",
|
||||
};
|
||||
|
||||
/** The data directory name inside the workspace. */
|
||||
export const DATA_DIR = "devclaw";
|
||||
|
||||
/** Track which workspaces have been migrated this process. */
|
||||
const migrated = new Set<string>();
|
||||
|
||||
/**
|
||||
* Ensure a workspace has been migrated and default files exist (at most once per process).
|
||||
* Safe to call from any code path — no-ops if already run this process.
|
||||
*/
|
||||
export async function ensureWorkspaceMigrated(workspaceDir: string): Promise<void> {
|
||||
if (migrated.has(workspaceDir)) return;
|
||||
migrated.add(workspaceDir);
|
||||
await migrateWorkspaceLayout(workspaceDir);
|
||||
// Lazy import to avoid circular dependency (workspace.ts imports from this file)
|
||||
const { ensureDefaultFiles } = await import("./workspace.js");
|
||||
await ensureDefaultFiles(workspaceDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate workspace from old layouts to new devclaw/ data directory.
|
||||
*
|
||||
* Detects four states:
|
||||
* 1. Already migrated: devclaw/projects.json exists → check prompt subdir migration
|
||||
* 2. Intermediate layout: projects.json at workspace root → move into devclaw/
|
||||
* 3. Very old layout: projects/projects.json → full migration into devclaw/
|
||||
* 4. Empty workspace → no-op
|
||||
*/
|
||||
export async function migrateWorkspaceLayout(workspaceDir: string): Promise<void> {
|
||||
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||
const newProjectsJson = path.join(dataDir, "projects.json");
|
||||
|
||||
// Already migrated — but may need prompt subdir migration
|
||||
if (await fileExists(newProjectsJson)) {
|
||||
await migratePromptSubdirs(dataDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for intermediate layout (post-restructure, pre-devclaw/)
|
||||
const rootProjectsJson = path.join(workspaceDir, "projects.json");
|
||||
if (await fileExists(rootProjectsJson)) {
|
||||
await migrateFromIntermediate(workspaceDir, dataDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for very old layout (projects/projects.json)
|
||||
const oldProjectsJson = path.join(workspaceDir, "projects", "projects.json");
|
||||
if (await fileExists(oldProjectsJson)) {
|
||||
await migrateFromOldLayout(workspaceDir, dataDir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move flat prompt files in project dirs into prompts/ subdirs.
|
||||
* Handles: devclaw/projects/<project>/<role>.md → devclaw/projects/<project>/prompts/<role>.md
|
||||
*/
|
||||
async function migratePromptSubdirs(dataDir: string): Promise<void> {
|
||||
const projectsDir = path.join(dataDir, "projects");
|
||||
if (!await dirExists(projectsDir)) return;
|
||||
|
||||
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const projectDir = path.join(projectsDir, entry.name);
|
||||
|
||||
// Skip if already has prompts/ subdir
|
||||
const promptsDir = path.join(projectDir, "prompts");
|
||||
if (await dirExists(promptsDir)) continue;
|
||||
|
||||
// Check if there are .md files at project root
|
||||
const files = await fs.readdir(projectDir);
|
||||
const mdFiles = files.filter(f => f.endsWith(".md"));
|
||||
if (mdFiles.length === 0) continue;
|
||||
|
||||
// Move .md files into prompts/ subdir (with renames)
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
for (const file of mdFiles) {
|
||||
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||
const dest = path.join(promptsDir, newName);
|
||||
if (!await fileExists(dest)) {
|
||||
await safeCopy(path.join(projectDir, file), dest);
|
||||
}
|
||||
await fs.unlink(path.join(projectDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from intermediate layout (files at workspace root) into devclaw/.
|
||||
*/
|
||||
async function migrateFromIntermediate(workspaceDir: string, dataDir: string): Promise<void> {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
// Move projects.json
|
||||
await moveIfExists(
|
||||
path.join(workspaceDir, "projects.json"),
|
||||
path.join(dataDir, "projects.json"),
|
||||
);
|
||||
|
||||
// Move workflow.yaml
|
||||
await moveIfExists(
|
||||
path.join(workspaceDir, "workflow.yaml"),
|
||||
path.join(dataDir, "workflow.yaml"),
|
||||
);
|
||||
|
||||
// Move prompts/ directory (with role file renames)
|
||||
await moveDirWithRenames(
|
||||
path.join(workspaceDir, "prompts"),
|
||||
path.join(dataDir, "prompts"),
|
||||
);
|
||||
|
||||
// Move projects/ directory — prompt files go into prompts/ subdir
|
||||
await moveProjectDirs(
|
||||
path.join(workspaceDir, "projects"),
|
||||
path.join(dataDir, "projects"),
|
||||
);
|
||||
|
||||
// Move log/ directory
|
||||
await moveDirIfExists(
|
||||
path.join(workspaceDir, "log"),
|
||||
path.join(dataDir, "log"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from very old layout (projects/projects.json) directly into devclaw/.
|
||||
*/
|
||||
async function migrateFromOldLayout(workspaceDir: string, dataDir: string): Promise<void> {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
// 1. Move projects/projects.json → devclaw/projects.json
|
||||
const oldProjectsJson = path.join(workspaceDir, "projects", "projects.json");
|
||||
await safeCopy(oldProjectsJson, path.join(dataDir, "projects.json"));
|
||||
await fs.unlink(oldProjectsJson);
|
||||
|
||||
// 2. Move projects/config.yaml → devclaw/workflow.yaml
|
||||
const oldConfig = path.join(workspaceDir, "projects", "config.yaml");
|
||||
const newConfig = path.join(dataDir, "workflow.yaml");
|
||||
if (await fileExists(oldConfig) && !await fileExists(newConfig)) {
|
||||
await safeCopy(oldConfig, newConfig);
|
||||
await fs.unlink(oldConfig);
|
||||
}
|
||||
|
||||
// 3. Move projects/roles/default/* → devclaw/prompts/* (with renames)
|
||||
const oldDefaultsDir = path.join(workspaceDir, "projects", "roles", "default");
|
||||
const newPromptsDir = path.join(dataDir, "prompts");
|
||||
if (await dirExists(oldDefaultsDir)) {
|
||||
await fs.mkdir(newPromptsDir, { recursive: true });
|
||||
const files = await fs.readdir(oldDefaultsDir);
|
||||
for (const file of files) {
|
||||
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||
const dest = path.join(newPromptsDir, newName);
|
||||
if (!await fileExists(dest)) {
|
||||
await safeCopy(path.join(oldDefaultsDir, file), dest);
|
||||
}
|
||||
await fs.unlink(path.join(oldDefaultsDir, file));
|
||||
}
|
||||
await rmEmptyDir(oldDefaultsDir);
|
||||
}
|
||||
|
||||
// 4. Move projects/roles/<project>/* → devclaw/projects/<project>/prompts/* (with renames)
|
||||
const oldRolesDir = path.join(workspaceDir, "projects", "roles");
|
||||
if (await dirExists(oldRolesDir)) {
|
||||
const entries = await fs.readdir(oldRolesDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const projectName = entry.name;
|
||||
const srcDir = path.join(oldRolesDir, projectName);
|
||||
const destDir = path.join(dataDir, "projects", projectName, "prompts");
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
|
||||
const roleFiles = await fs.readdir(srcDir);
|
||||
for (const file of roleFiles) {
|
||||
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||
const dest = path.join(destDir, newName);
|
||||
if (!await fileExists(dest)) {
|
||||
await safeCopy(path.join(srcDir, file), dest);
|
||||
}
|
||||
await fs.unlink(path.join(srcDir, file));
|
||||
}
|
||||
await rmEmptyDir(srcDir);
|
||||
}
|
||||
await rmEmptyDir(oldRolesDir);
|
||||
}
|
||||
|
||||
// 5. Rename projects/<project>/config.yaml → devclaw/projects/<project>/workflow.yaml
|
||||
const oldProjectsDir = path.join(workspaceDir, "projects");
|
||||
if (await dirExists(oldProjectsDir)) {
|
||||
const entries = await fs.readdir(oldProjectsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const oldCfg = path.join(oldProjectsDir, entry.name, "config.yaml");
|
||||
const newCfg = path.join(dataDir, "projects", entry.name, "workflow.yaml");
|
||||
if (await fileExists(oldCfg) && !await fileExists(newCfg)) {
|
||||
await safeCopy(oldCfg, newCfg);
|
||||
await fs.unlink(oldCfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Move log/ directory
|
||||
await moveDirIfExists(
|
||||
path.join(workspaceDir, "log"),
|
||||
path.join(dataDir, "log"),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function dirExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(p);
|
||||
return stat.isDirectory();
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function safeCopy(src: string, dest: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.copyFile(src, dest);
|
||||
}
|
||||
|
||||
async function rmEmptyDir(dir: string): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(dir);
|
||||
if (entries.length === 0) await fs.rmdir(dir);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Move a file if it exists and dest doesn't. */
|
||||
async function moveIfExists(src: string, dest: string): Promise<void> {
|
||||
if (await fileExists(src) && !await fileExists(dest)) {
|
||||
await safeCopy(src, dest);
|
||||
await fs.unlink(src);
|
||||
}
|
||||
}
|
||||
|
||||
/** Move an entire directory's contents if it exists. */
|
||||
async function moveDirIfExists(srcDir: string, destDir: string): Promise<void> {
|
||||
if (!await dirExists(srcDir)) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
const destPath = path.join(destDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await moveDirIfExists(srcPath, destPath);
|
||||
} else {
|
||||
if (!await fileExists(destPath)) {
|
||||
await safeCopy(srcPath, destPath);
|
||||
}
|
||||
await fs.unlink(srcPath);
|
||||
}
|
||||
}
|
||||
await rmEmptyDir(srcDir);
|
||||
}
|
||||
|
||||
/** Move a directory, applying ROLE_FILE_RENAMES to files and recursing into subdirs. */
|
||||
async function moveDirWithRenames(srcDir: string, destDir: string): Promise<void> {
|
||||
if (!await dirExists(srcDir)) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await moveDirWithRenames(srcPath, path.join(destDir, entry.name));
|
||||
} else {
|
||||
const newName = ROLE_FILE_RENAMES[entry.name] ?? entry.name;
|
||||
const destPath = path.join(destDir, newName);
|
||||
if (!await fileExists(destPath)) {
|
||||
await safeCopy(srcPath, destPath);
|
||||
}
|
||||
await fs.unlink(srcPath);
|
||||
}
|
||||
}
|
||||
await rmEmptyDir(srcDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move project directories: .md files go into prompts/ subdir (with renames),
|
||||
* other files (workflow.yaml) stay at project root.
|
||||
*/
|
||||
async function moveProjectDirs(srcDir: string, destDir: string): Promise<void> {
|
||||
if (!await dirExists(srcDir)) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
|
||||
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Each subdirectory is a project — move its contents with prompt separation
|
||||
const destProjectDir = path.join(destDir, entry.name);
|
||||
await fs.mkdir(destProjectDir, { recursive: true });
|
||||
|
||||
const projectFiles = await fs.readdir(srcPath);
|
||||
for (const file of projectFiles) {
|
||||
const fileSrc = path.join(srcPath, file);
|
||||
if (file.endsWith(".md")) {
|
||||
// Prompt file → prompts/ subdir (with renames)
|
||||
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||
const promptsDest = path.join(destProjectDir, "prompts", newName);
|
||||
if (!await fileExists(promptsDest)) {
|
||||
await safeCopy(fileSrc, promptsDest);
|
||||
}
|
||||
} else {
|
||||
// Config file → project root
|
||||
const fileDest = path.join(destProjectDir, file);
|
||||
if (!await fileExists(fileDest)) {
|
||||
await safeCopy(fileSrc, fileDest);
|
||||
}
|
||||
}
|
||||
await fs.unlink(fileSrc);
|
||||
}
|
||||
await rmEmptyDir(srcPath);
|
||||
} else {
|
||||
// Top-level file in projects/ dir — just move
|
||||
const destPath = path.join(destDir, entry.name);
|
||||
if (!await fileExists(destPath)) {
|
||||
await safeCopy(srcPath, destPath);
|
||||
}
|
||||
await fs.unlink(srcPath);
|
||||
}
|
||||
}
|
||||
await rmEmptyDir(srcDir);
|
||||
}
|
||||
@@ -3,22 +3,25 @@
|
||||
*
|
||||
* Uses an LLM to intelligently analyze and assign models to DevClaw roles.
|
||||
*/
|
||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { ROLE_REGISTRY } from "../roles/index.js";
|
||||
|
||||
export type ModelAssignment = {
|
||||
dev: {
|
||||
junior: string;
|
||||
medior: string;
|
||||
senior: string;
|
||||
};
|
||||
qa: {
|
||||
reviewer: string;
|
||||
tester: string;
|
||||
};
|
||||
architect: {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
};
|
||||
};
|
||||
/** Model assignment: role → level → model ID. Derived from registry structure. */
|
||||
export type ModelAssignment = Record<string, Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Build a ModelAssignment where every role/level maps to the same model.
|
||||
*/
|
||||
function singleModelAssignment(model: string): ModelAssignment {
|
||||
const result: ModelAssignment = {};
|
||||
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||
result[roleId] = {};
|
||||
for (const level of config.levels) {
|
||||
result[roleId][level] = model;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligently assign available models to DevClaw roles using an LLM.
|
||||
@@ -41,12 +44,7 @@ export async function assignModels(
|
||||
|
||||
// If only one model, use it for everything
|
||||
if (authenticated.length === 1) {
|
||||
const model = authenticated[0].model;
|
||||
return {
|
||||
dev: { junior: model, medior: model, senior: model },
|
||||
qa: { reviewer: model, tester: model },
|
||||
architect: { opus: model, sonnet: model },
|
||||
};
|
||||
return singleModelAssignment(authenticated[0].model);
|
||||
}
|
||||
|
||||
// Multiple models: use LLM-based selection
|
||||
@@ -65,16 +63,18 @@ export async function assignModels(
|
||||
*/
|
||||
export function formatAssignment(assignment: ModelAssignment): string {
|
||||
const lines = [
|
||||
"| Role | Level | Model |",
|
||||
"|------|----------|--------------------------|",
|
||||
`| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`,
|
||||
`| DEV | medior | ${assignment.dev.medior.padEnd(24)} |`,
|
||||
`| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`,
|
||||
`| QA | reviewer | ${assignment.qa.reviewer.padEnd(24)} |`,
|
||||
`| QA | tester | ${assignment.qa.tester.padEnd(24)} |`,
|
||||
`| ARCH | opus | ${assignment.architect.opus.padEnd(24)} |`,
|
||||
`| ARCH | sonnet | ${assignment.architect.sonnet.padEnd(24)} |`,
|
||||
"| Role | Level | Model |",
|
||||
"|-----------|----------|--------------------------|",
|
||||
];
|
||||
for (const roleId of getAllRoleIds()) {
|
||||
const roleModels = assignment[roleId];
|
||||
if (!roleModels) continue;
|
||||
const displayName = ROLE_REGISTRY[roleId]?.displayName ?? roleId.toUpperCase();
|
||||
for (const level of getLevelsForRole(roleId)) {
|
||||
const model = roleModels[level] ?? "";
|
||||
lines.push(`| ${displayName.padEnd(9)} | ${level.padEnd(8)} | ${model.padEnd(24)} |`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +1,109 @@
|
||||
/**
|
||||
* setup/workspace.ts — Workspace file scaffolding.
|
||||
*
|
||||
* Writes AGENTS.md, HEARTBEAT.md, default role instructions, and projects.json.
|
||||
* Writes AGENTS.md, HEARTBEAT.md, default role prompts, and projects.json.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
AGENTS_MD_TEMPLATE,
|
||||
HEARTBEAT_MD_TEMPLATE,
|
||||
DEFAULT_DEV_INSTRUCTIONS,
|
||||
DEFAULT_QA_INSTRUCTIONS,
|
||||
DEFAULT_ARCHITECT_INSTRUCTIONS,
|
||||
IDENTITY_MD_TEMPLATE,
|
||||
SOUL_MD_TEMPLATE,
|
||||
WORKFLOW_YAML_TEMPLATE,
|
||||
DEFAULT_ROLE_INSTRUCTIONS,
|
||||
} from "../templates.js";
|
||||
import { getAllRoleIds } from "../roles/index.js";
|
||||
import { migrateWorkspaceLayout, DATA_DIR } from "./migrate-layout.js";
|
||||
|
||||
/**
|
||||
* Ensure default data files exist in the workspace.
|
||||
* Only creates files that are missing — never overwrites existing ones.
|
||||
* Called automatically after migration (via ensureWorkspaceMigrated).
|
||||
*/
|
||||
export async function ensureDefaultFiles(workspacePath: string): Promise<void> {
|
||||
const dataDir = path.join(workspacePath, DATA_DIR);
|
||||
|
||||
// devclaw/workflow.yaml
|
||||
const workflowPath = path.join(dataDir, "workflow.yaml");
|
||||
if (!await fileExists(workflowPath)) {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await fs.writeFile(workflowPath, WORKFLOW_YAML_TEMPLATE, "utf-8");
|
||||
}
|
||||
|
||||
// devclaw/projects.json
|
||||
const projectsJsonPath = path.join(dataDir, "projects.json");
|
||||
if (!await fileExists(projectsJsonPath)) {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
// devclaw/projects/ directory
|
||||
await fs.mkdir(path.join(dataDir, "projects"), { recursive: true });
|
||||
|
||||
// devclaw/prompts/ — default role instructions
|
||||
const promptsDir = path.join(dataDir, "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
for (const role of getAllRoleIds()) {
|
||||
const rolePath = path.join(promptsDir, `${role}.md`);
|
||||
if (!await fileExists(rolePath)) {
|
||||
const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`;
|
||||
await fs.writeFile(rolePath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// devclaw/log/ directory (audit.log created on first write)
|
||||
await fs.mkdir(path.join(dataDir, "log"), { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all workspace files for a DevClaw agent.
|
||||
* Returns the list of files that were written (skips files that already exist).
|
||||
*
|
||||
* @param defaultWorkspacePath — If provided, USER.md is copied from here (only if not already present).
|
||||
*/
|
||||
export async function scaffoldWorkspace(workspacePath: string): Promise<string[]> {
|
||||
const filesWritten: string[] = [];
|
||||
export async function scaffoldWorkspace(workspacePath: string, defaultWorkspacePath?: string): Promise<string[]> {
|
||||
// Migrate old layout if detected
|
||||
await migrateWorkspaceLayout(workspacePath);
|
||||
|
||||
// AGENTS.md (backup existing)
|
||||
const written: string[] = [];
|
||||
|
||||
// AGENTS.md (backup existing — stays at workspace root)
|
||||
await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE);
|
||||
filesWritten.push("AGENTS.md");
|
||||
written.push("AGENTS.md");
|
||||
|
||||
// HEARTBEAT.md
|
||||
// HEARTBEAT.md (stays at workspace root)
|
||||
await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE);
|
||||
filesWritten.push("HEARTBEAT.md");
|
||||
written.push("HEARTBEAT.md");
|
||||
|
||||
// projects/projects.json
|
||||
const projectsDir = path.join(workspacePath, "projects");
|
||||
await fs.mkdir(projectsDir, { recursive: true });
|
||||
const projectsJsonPath = path.join(projectsDir, "projects.json");
|
||||
if (!await fileExists(projectsJsonPath)) {
|
||||
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
|
||||
filesWritten.push("projects/projects.json");
|
||||
// IDENTITY.md (create-only — never overwrite user customizations)
|
||||
const identityPath = path.join(workspacePath, "IDENTITY.md");
|
||||
if (!await fileExists(identityPath)) {
|
||||
await fs.writeFile(identityPath, IDENTITY_MD_TEMPLATE, "utf-8");
|
||||
written.push("IDENTITY.md");
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
const architectRolePath = path.join(defaultRolesDir, "architect.md");
|
||||
if (!await fileExists(architectRolePath)) {
|
||||
await fs.writeFile(architectRolePath, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8");
|
||||
filesWritten.push("projects/roles/default/architect.md");
|
||||
// SOUL.md (create-only — never overwrite user customizations)
|
||||
const soulPath = path.join(workspacePath, "SOUL.md");
|
||||
if (!await fileExists(soulPath)) {
|
||||
await fs.writeFile(soulPath, SOUL_MD_TEMPLATE, "utf-8");
|
||||
written.push("SOUL.md");
|
||||
}
|
||||
|
||||
// log/ directory (audit.log created on first write)
|
||||
const logDir = path.join(workspacePath, "log");
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
// USER.md — copy from default workspace if available (create-only)
|
||||
const userPath = path.join(workspacePath, "USER.md");
|
||||
if (!await fileExists(userPath) && defaultWorkspacePath) {
|
||||
const sourceUser = path.join(defaultWorkspacePath, "USER.md");
|
||||
if (await fileExists(sourceUser)) {
|
||||
await fs.copyFile(sourceUser, userPath);
|
||||
written.push("USER.md");
|
||||
}
|
||||
}
|
||||
|
||||
return filesWritten;
|
||||
// Ensure all data-dir defaults (workflow.yaml, prompts, etc.)
|
||||
await ensureDefaultFiles(workspacePath);
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
256
lib/templates.ts
256
lib/templates.ts
@@ -2,8 +2,11 @@
|
||||
* Shared templates for workspace files.
|
||||
* Used by setup and project_register.
|
||||
*/
|
||||
import YAML from "yaml";
|
||||
import { DEFAULT_WORKFLOW } from "./workflow.js";
|
||||
import { ROLE_REGISTRY } from "./roles/registry.js";
|
||||
|
||||
export const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
|
||||
export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions
|
||||
|
||||
## Context You Receive
|
||||
|
||||
@@ -21,22 +24,21 @@ Read the comments carefully — they often contain clarifications, decisions, or
|
||||
|
||||
- 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
|
||||
- Create an MR/PR to the base branch
|
||||
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
|
||||
- Clean up the worktree after merging
|
||||
- When done, call work_finish with role "dev", result "done", and a brief summary
|
||||
- **Do NOT merge the PR yourself** — leave it open for review. The system will auto-merge when approved.
|
||||
- If you discover unrelated bugs, call task_create to file them
|
||||
- Do NOT call work_start, status, health, or project_register
|
||||
`;
|
||||
|
||||
export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
||||
export const DEFAULT_QA_INSTRUCTIONS = `# TESTER 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
|
||||
- **Always** call task_comment with your review findings — even if everything looks good, leave a brief summary of what you checked
|
||||
- When done, call work_finish with role "qa" and one of:
|
||||
- When done, call work_finish with role "tester" 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
|
||||
@@ -46,43 +48,42 @@ export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
||||
|
||||
export const DEFAULT_ARCHITECT_INSTRUCTIONS = `# Architect Worker Instructions
|
||||
|
||||
You design and investigate architecture/design questions systematically.
|
||||
You research design/architecture questions and produce detailed, development-ready findings.
|
||||
|
||||
## Your Job
|
||||
|
||||
Investigate the design problem thoroughly:
|
||||
1. **Understand the problem** — Read the issue, comments, and codebase
|
||||
2. **Research alternatives** — Explore >= 3 viable approaches
|
||||
3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit
|
||||
4. **Recommend** — Pick the best option with clear reasoning
|
||||
5. **Outline implementation** — Break down into dev tasks
|
||||
The issue contains background context and constraints. Your goal is to produce findings detailed enough that a developer can start implementation immediately — no further research needed.
|
||||
|
||||
1. **Understand the problem** — Read the issue body carefully. It contains the background context, constraints, and focus areas.
|
||||
2. **Research thoroughly** — Explore the codebase, read docs, search the web. Understand the current state deeply.
|
||||
3. **Investigate alternatives** — Research >= 3 viable approaches with concrete pros/cons and effort estimates.
|
||||
4. **Recommend** — Pick the best option with clear, evidence-based reasoning.
|
||||
5. **Outline implementation** — Break down into specific, actionable developer tasks with enough detail to start coding.
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your findings as:
|
||||
Post your findings as issue comments. Structure them as:
|
||||
|
||||
### Problem Statement
|
||||
Why is this design decision important?
|
||||
Why is this design decision important? What breaks if we get it wrong?
|
||||
|
||||
### Current State
|
||||
What exists today? Current limitations?
|
||||
What exists today? Current limitations? Relevant code paths.
|
||||
|
||||
### Alternatives Investigated
|
||||
|
||||
**Option A: [Name]**
|
||||
- Approach: [Concrete description of what this looks like]
|
||||
- Pros: ...
|
||||
- Cons: ...
|
||||
- Effort estimate: X hours
|
||||
- Key code paths affected: [files/modules]
|
||||
|
||||
**Option B: [Name]**
|
||||
- Pros: ...
|
||||
- Cons: ...
|
||||
- Effort estimate: X hours
|
||||
(same structure)
|
||||
|
||||
**Option C: [Name]**
|
||||
- Pros: ...
|
||||
- Cons: ...
|
||||
- Effort estimate: X hours
|
||||
(same structure)
|
||||
|
||||
### Recommendation
|
||||
**Option X** is recommended because:
|
||||
@@ -91,33 +92,81 @@ What exists today? Current limitations?
|
||||
- [Long-term implications]
|
||||
|
||||
### Implementation Outline
|
||||
- [ ] Task 1: [Description]
|
||||
Detailed enough for a developer to start immediately:
|
||||
- [ ] Task 1: [Description — what to change, where, how]
|
||||
- [ ] Task 2: [Description]
|
||||
- [ ] Task 3: [Description]
|
||||
|
||||
### References
|
||||
- [Code examples, prior art, related issues]
|
||||
- [Code paths, docs, prior art, related issues]
|
||||
|
||||
## Available Tools
|
||||
## Important
|
||||
|
||||
- web_search, web_fetch (research patterns)
|
||||
- Read files (explore codebase)
|
||||
- exec (run commands, search code)
|
||||
- **Be thorough** — Your output becomes the spec for development. Missing detail = blocked developer.
|
||||
- **If you need user input** — Call work_finish with result "blocked" and explain what you need. Do NOT guess on ambiguous requirements.
|
||||
- **Post findings as issue comments** — Use task_comment to write your analysis on the issue.
|
||||
|
||||
## Completion
|
||||
|
||||
When done, call work_finish with:
|
||||
- role: "architect"
|
||||
- result: "done"
|
||||
- result: "done" — findings posted, ready for human review
|
||||
- result: "blocked" — you need human input to proceed (goes to Refining)
|
||||
- summary: Brief summary of your recommendation
|
||||
|
||||
Your session is persistent — you may be called back for refinements.
|
||||
Do NOT call work_start, status, health, or project_register.
|
||||
`;
|
||||
|
||||
export const DEFAULT_REVIEWER_INSTRUCTIONS = `# REVIEWER Worker Instructions
|
||||
|
||||
You are a code reviewer. Your job is to review the PR diff for quality, correctness, and style.
|
||||
|
||||
## Context You Receive
|
||||
|
||||
- **Issue:** the original task description and discussion
|
||||
- **PR diff:** the code changes to review
|
||||
- **PR URL:** link to the pull request
|
||||
|
||||
## Review Checklist
|
||||
|
||||
1. **Correctness** — Does the code do what the issue asks for?
|
||||
2. **Bugs** — Any logic errors, off-by-one, null handling issues?
|
||||
3. **Security** — SQL injection, XSS, hardcoded secrets, command injection?
|
||||
4. **Style** — Consistent with the codebase? Readable?
|
||||
5. **Tests** — Are changes tested? Any missing edge cases?
|
||||
6. **Scope** — Does the PR stay within the issue scope? Any unrelated changes?
|
||||
|
||||
## Your Job
|
||||
|
||||
- Read the PR diff carefully
|
||||
- Check the code against the review checklist
|
||||
- Call task_comment with your review findings
|
||||
- Then call work_finish with role "reviewer" and one of:
|
||||
- result "approve" if the code looks good
|
||||
- result "reject" with specific issues if problems found
|
||||
- result "blocked" if you can't complete the review
|
||||
|
||||
## Important
|
||||
|
||||
- You do NOT run code or tests — you only review the diff
|
||||
- Be specific about issues: file, line, what's wrong, how to fix
|
||||
- If you approve, briefly note what you checked
|
||||
- If you reject, list actionable items the developer must fix
|
||||
- Do NOT call work_start, status, health, or project_register
|
||||
`;
|
||||
|
||||
/** Default role instructions indexed by role ID. Used by project scaffolding. */
|
||||
export const DEFAULT_ROLE_INSTRUCTIONS: Record<string, string> = {
|
||||
developer: DEFAULT_DEV_INSTRUCTIONS,
|
||||
tester: DEFAULT_QA_INSTRUCTIONS,
|
||||
architect: DEFAULT_ARCHITECT_INSTRUCTIONS,
|
||||
reviewer: DEFAULT_REVIEWER_INSTRUCTIONS,
|
||||
};
|
||||
|
||||
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
|
||||
|
||||
## If You Are a Sub-Agent (DEV/QA Worker)
|
||||
## If You Are a Sub-Agent (DEVELOPER/TESTER/REVIEWER Worker)
|
||||
|
||||
Skip the orchestrator section. Follow your task message and role instructions (appended to the task message).
|
||||
|
||||
@@ -126,21 +175,23 @@ Skip the orchestrator section. Follow your task message and role instructions (a
|
||||
- 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
|
||||
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses QA.
|
||||
- **QA tests on the deployed version** and inspects code on the base branch
|
||||
- **QA always calls task_comment** with review findings before completing
|
||||
- **DEVELOPER always works in a git worktree** (never switch branches in the main repo)
|
||||
- **DEVELOPER must merge to base branch** before announcing completion
|
||||
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses testing.
|
||||
- **TESTER tests on the deployed version** and inspects code on the base branch
|
||||
- **TESTER always calls task_comment** with review findings before completing
|
||||
- Always run tests before completing
|
||||
|
||||
### Completing Your Task
|
||||
|
||||
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
|
||||
|
||||
- **DEV done:** \`work_finish({ role: "dev", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **QA pass:** \`work_finish({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||
- **QA refine:** \`work_finish({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
||||
- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||
- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||
- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
||||
- **REVIEWER approve:** \`work_finish({ role: "reviewer", result: "approve", projectGroupId: "<from task message>", summary: "<what you checked>" })\`
|
||||
- **REVIEWER reject:** \`work_finish({ role: "reviewer", result: "reject", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||
- **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation summary>" })\`
|
||||
|
||||
The \`projectGroupId\` is included in your task message.
|
||||
@@ -167,14 +218,14 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
|
||||
**Never write code yourself.** All implementation work MUST go through the issue → worker pipeline:
|
||||
|
||||
1. Create an issue via \`task_create\`
|
||||
2. Dispatch a DEV worker via \`work_start\`
|
||||
2. Dispatch a DEVELOPER worker via \`work_start\`
|
||||
3. Let the worker handle implementation, git, and PRs
|
||||
|
||||
**Why this matters:**
|
||||
- **Audit trail** — Every code change is tracked to an issue
|
||||
- **Tier selection** — Junior/medior/senior models match task complexity
|
||||
- **Level selection** — Junior/medior/senior models match task complexity
|
||||
- **Parallelization** — Workers run in parallel, you stay free to plan
|
||||
- **QA pipeline** — Code goes through review before closing
|
||||
- **Testing pipeline** — Code goes through review before closing
|
||||
|
||||
**What you CAN do directly:**
|
||||
- Planning, analysis, architecture discussions
|
||||
@@ -195,7 +246,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
|
||||
|
||||
Examples:
|
||||
- ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42"
|
||||
- ✅ "Picked up #42 for DEV (medior) 🔗 https://github.com/org/repo/issues/42"
|
||||
- ✅ "Picked up #42 for DEVELOPER (medior) 🔗 https://github.com/org/repo/issues/42"
|
||||
- ❌ "Created issue #42 about the login bug" (missing URL)
|
||||
|
||||
### DevClaw Tools
|
||||
@@ -211,20 +262,30 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
|
||||
| \`health\` | Scan worker health: zombies, stale workers, orphaned state. Pass fix=true to auto-fix |
|
||||
| \`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 |
|
||||
| \`design_task\` | Spawn an architect for design investigation. Creates To Design issue and dispatches architect |
|
||||
| \`research_task\` | Spawn an architect for design investigation. Creates Planning issue with rich context and dispatches architect |
|
||||
|
||||
### First Thing on Session Start
|
||||
|
||||
**Always call \`status\` first** when you start a new session. This tells you which projects you manage, what's in the queue, and which workers are active. Don't guess — check.
|
||||
|
||||
### Pipeline Flow
|
||||
|
||||
\`\`\`
|
||||
Planning → To Do → Doing → To Test → Testing → Done
|
||||
↓
|
||||
To Improve → Doing (fix cycle)
|
||||
↓
|
||||
Refining (human decision)
|
||||
Planning → To Do → Doing → To Review ──┬── [agent] → Reviewing → approve → To Test → Testing → Done
|
||||
│ → reject → To Improve
|
||||
│ → blocked → Refining
|
||||
└── [human] → PR approved → To Test (heartbeat auto-transitions)
|
||||
|
||||
To Design → Designing → Planning (design complete)
|
||||
To Improve → Doing (fix cycle)
|
||||
Refining (human decision)
|
||||
research_task → Planning (architect researches, posts findings, stays in Planning)
|
||||
\`\`\`
|
||||
|
||||
Review policy (configurable per project in workflow.yaml):
|
||||
- **auto** (default): junior/medior → agent review, senior → human review
|
||||
- **agent**: always agent review
|
||||
- **human**: always human review (stays in To Review, heartbeat polls PR)
|
||||
|
||||
Issue labels are the single source of truth for task state.
|
||||
|
||||
### Developer Assignment
|
||||
@@ -234,9 +295,8 @@ Evaluate each task and pass the appropriate developer level to \`work_start\`:
|
||||
- **junior** — trivial: typos, single-file fix, quick change
|
||||
- **medior** — standard: features, bug fixes, multi-file changes
|
||||
- **senior** — complex: architecture, system-wide refactoring, 5+ services
|
||||
- **reviewer** — QA: code inspection, validation, test runs
|
||||
- **opus** — Architect: complex, high-impact design investigations
|
||||
- **sonnet** — Architect: standard feature design investigations
|
||||
|
||||
All roles (Developer, Tester, Architect) use the same level scheme. Levels describe task complexity, not the model.
|
||||
|
||||
### Picking Up Work
|
||||
|
||||
@@ -250,28 +310,33 @@ Evaluate each task and pass the appropriate developer level to \`work_start\`:
|
||||
|
||||
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
|
||||
|
||||
- DEV "done" → issue moves to "To Test" → scheduler dispatches QA
|
||||
- QA "fail" → issue moves to "To Improve" → scheduler dispatches DEV
|
||||
- QA "pass" → Done, no further dispatch
|
||||
- QA "refine" / blocked → needs human input
|
||||
- Architect "done" → issue moves to "Planning" → ready for tech lead review
|
||||
- Developer "done" → "To Review" → routes based on review policy:
|
||||
- Agent/auto-junior: reviewer agent dispatched → "Reviewing" → approve/reject
|
||||
- Human/auto-senior: heartbeat polls PR status → auto-merges when approved → "To Test"
|
||||
- Reviewer "approve" → merges PR → "To Test" → scheduler dispatches Tester
|
||||
- Reviewer "reject" → "To Improve" → scheduler dispatches Developer
|
||||
- Tester "fail" → "To Improve" → scheduler dispatches Developer
|
||||
- Tester "pass" → Done, no further dispatch
|
||||
- Tester "refine" / blocked → needs human input
|
||||
- Architect "done" → stays in "Planning" → ready for tech lead review
|
||||
- Architect "blocked" → "Refining" → needs human input
|
||||
|
||||
**Always include issue URLs** in your response — these are in the \`announcement\` fields.
|
||||
|
||||
### Prompt Instructions
|
||||
|
||||
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.
|
||||
Workers receive role-specific instructions appended to their task message. These are loaded from \`devclaw/projects/<project-name>/prompts/<role>.md\` in the workspace, falling back to \`devclaw/prompts/<role>.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
|
||||
|
||||
### Heartbeats
|
||||
|
||||
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
|
||||
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "To Review" issues when PRs are approved), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
|
||||
|
||||
### Safety
|
||||
|
||||
- **Never write code yourself** — always dispatch a DEV worker
|
||||
- **Never write code yourself** — always dispatch a Developer worker
|
||||
- Don't push to main directly
|
||||
- Don't force-push
|
||||
- Don't close issues without QA pass
|
||||
- Don't close issues without Tester pass
|
||||
- Ask before architectural decisions affecting multiple projects
|
||||
`;
|
||||
|
||||
@@ -279,3 +344,70 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md
|
||||
|
||||
Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically.
|
||||
`;
|
||||
|
||||
export const IDENTITY_MD_TEMPLATE = `# IDENTITY.md - Who Am I?
|
||||
|
||||
- **Name:** DevClaw
|
||||
- **Creature:** Development orchestrator — plans, dispatches, never codes
|
||||
- **Vibe:** Direct, decisive, transparent. No fluff.
|
||||
- **Emoji:** 🦞
|
||||
`;
|
||||
|
||||
export const SOUL_MD_TEMPLATE = `# SOUL.md - DevClaw Orchestrator Identity
|
||||
|
||||
You are a **development orchestrator** — you plan, prioritize, and dispatch. You never write code yourself.
|
||||
|
||||
## Core Principles
|
||||
|
||||
**Be direct.** Skip pleasantries, get to the point. Say what you're doing and why.
|
||||
|
||||
**Be decisive.** Evaluate task complexity, pick the right level, dispatch. Don't deliberate when the answer is obvious.
|
||||
|
||||
**Be transparent.** Always include issue URLs. Always explain what happened and what's next. No black boxes.
|
||||
|
||||
**Be resourceful.** Check status before asking. Read the issue before dispatching. Understand the codebase before planning. Come back with answers, not questions.
|
||||
|
||||
## How You Work
|
||||
|
||||
- You receive requests via chat (Telegram, WhatsApp, or web)
|
||||
- You break work into issues, assign complexity levels, and dispatch workers
|
||||
- Workers (developer, reviewer, tester, architect) do the actual work in isolated sessions
|
||||
- You track progress, handle failures, and keep the human informed
|
||||
- The heartbeat runs automatically — you don't manage it
|
||||
|
||||
## Communication Style
|
||||
|
||||
- Concise status updates with issue links
|
||||
- Use the announcement format from tool responses
|
||||
- Flag blockers and failures immediately
|
||||
- Don't over-explain routine operations
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Never write code** — dispatch a developer worker
|
||||
- **Never skip testing** — every code change goes through QA
|
||||
- **Never close issues** without a tester pass
|
||||
- **Ask before** architectural decisions affecting multiple projects
|
||||
|
||||
## Continuity
|
||||
|
||||
Each session starts fresh. AGENTS.md defines your operational procedures. This file defines who you are. USER.md tells you about the humans you work with. Update these files as you learn.
|
||||
`;
|
||||
|
||||
|
||||
/**
|
||||
* Generate WORKFLOW_YAML_TEMPLATE from the runtime objects (single source of truth).
|
||||
*/
|
||||
function buildWorkflowYaml(): string {
|
||||
const roles: Record<string, { models: Record<string, string> }> = {};
|
||||
for (const [id, config] of Object.entries(ROLE_REGISTRY)) {
|
||||
roles[id] = { models: { ...config.models } };
|
||||
}
|
||||
|
||||
const header =
|
||||
"# DevClaw workflow configuration\n" +
|
||||
"# Modify values to customize. Copy to devclaw/projects/<project>/workflow.yaml for project-specific overrides.\n\n";
|
||||
return header + YAML.stringify({ roles, workflow: DEFAULT_WORKFLOW });
|
||||
}
|
||||
|
||||
export const WORKFLOW_YAML_TEMPLATE = buildWorkflowYaml();
|
||||
|
||||
294
lib/testing/harness.ts
Normal file
294
lib/testing/harness.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Test harness — scaffolds a temporary workspace with projects.json,
|
||||
* installs a mock runCommand, and provides helpers for E2E pipeline tests.
|
||||
*
|
||||
* Usage:
|
||||
* const h = await createTestHarness({ ... });
|
||||
* try { ... } finally { await h.cleanup(); }
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { initRunCommand } from "../run-command.js";
|
||||
import { writeProjects, type ProjectsData, type Project, emptyWorkerState } from "../projects.js";
|
||||
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||
import { registerBootstrapHook } from "../bootstrap-hook.js";
|
||||
import { TestProvider } from "./test-provider.js";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap file type (mirrors OpenClaw's internal type)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BootstrapFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
content?: string;
|
||||
missing: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command interceptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CapturedCommand = {
|
||||
argv: string[];
|
||||
opts: { timeoutMs: number; cwd?: string };
|
||||
/** Extracted from gateway `agent` call params, if applicable. */
|
||||
taskMessage?: string;
|
||||
/** Extracted from gateway `sessions.patch` params, if applicable. */
|
||||
sessionPatch?: { key: string; model: string };
|
||||
};
|
||||
|
||||
export type CommandInterceptor = {
|
||||
/** All captured commands, in order. */
|
||||
commands: CapturedCommand[];
|
||||
/** Filter commands by first argv element. */
|
||||
commandsFor(cmd: string): CapturedCommand[];
|
||||
/** Get all task messages sent via `openclaw gateway call agent`. */
|
||||
taskMessages(): string[];
|
||||
/** Get all session patches. */
|
||||
sessionPatches(): Array<{ key: string; model: string }>;
|
||||
/** Reset captured commands. */
|
||||
reset(): void;
|
||||
};
|
||||
|
||||
function createCommandInterceptor(): {
|
||||
interceptor: CommandInterceptor;
|
||||
handler: (argv: string[], opts: number | { timeoutMs: number; cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number | null; signal: null; killed: false }>;
|
||||
} {
|
||||
const commands: CapturedCommand[] = [];
|
||||
|
||||
const handler = async (
|
||||
argv: string[],
|
||||
optsOrTimeout: number | { timeoutMs: number; cwd?: string },
|
||||
) => {
|
||||
const opts = typeof optsOrTimeout === "number"
|
||||
? { timeoutMs: optsOrTimeout }
|
||||
: optsOrTimeout;
|
||||
|
||||
const captured: CapturedCommand = { argv, opts };
|
||||
|
||||
// Parse gateway agent calls to extract task message
|
||||
if (argv[0] === "openclaw" && argv[1] === "gateway" && argv[2] === "call") {
|
||||
const rpcMethod = argv[3];
|
||||
const paramsIdx = argv.indexOf("--params");
|
||||
if (paramsIdx !== -1 && argv[paramsIdx + 1]) {
|
||||
try {
|
||||
const params = JSON.parse(argv[paramsIdx + 1]);
|
||||
if (rpcMethod === "agent" && params.message) {
|
||||
captured.taskMessage = params.message;
|
||||
}
|
||||
if (rpcMethod === "sessions.patch") {
|
||||
captured.sessionPatch = { key: params.key, model: params.model };
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
commands.push(captured);
|
||||
|
||||
return { stdout: "{}", stderr: "", code: 0, signal: null as null, killed: false as const };
|
||||
};
|
||||
|
||||
const interceptor: CommandInterceptor = {
|
||||
commands,
|
||||
commandsFor(cmd: string) {
|
||||
return commands.filter((c) => c.argv[0] === cmd);
|
||||
},
|
||||
taskMessages() {
|
||||
return commands
|
||||
.filter((c) => c.taskMessage !== undefined)
|
||||
.map((c) => c.taskMessage!);
|
||||
},
|
||||
sessionPatches() {
|
||||
return commands
|
||||
.filter((c) => c.sessionPatch !== undefined)
|
||||
.map((c) => c.sessionPatch!);
|
||||
},
|
||||
reset() {
|
||||
commands.length = 0;
|
||||
},
|
||||
};
|
||||
|
||||
return { interceptor, handler };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TestHarness = {
|
||||
/** Temporary workspace directory. */
|
||||
workspaceDir: string;
|
||||
/** In-memory issue provider. */
|
||||
provider: TestProvider;
|
||||
/** Command interceptor — captures all runCommand calls. */
|
||||
commands: CommandInterceptor;
|
||||
/** The project group ID used for test data. */
|
||||
groupId: string;
|
||||
/** The project data. */
|
||||
project: Project;
|
||||
/** Workflow config. */
|
||||
workflow: WorkflowConfig;
|
||||
/** Write updated projects data to disk. */
|
||||
writeProjects(data: ProjectsData): Promise<void>;
|
||||
/** Read current projects data from disk. */
|
||||
readProjects(): Promise<ProjectsData>;
|
||||
/**
|
||||
* Write a role prompt file to the workspace.
|
||||
* @param role - Role name (e.g. "developer", "tester")
|
||||
* @param content - Prompt file content
|
||||
* @param projectName - If provided, writes project-specific prompt; otherwise writes default.
|
||||
*/
|
||||
writePrompt(role: string, content: string, projectName?: string): Promise<void>;
|
||||
/**
|
||||
* Simulate the agent:bootstrap hook firing for a session key.
|
||||
* Registers the real hook with a mock API, fires it, returns the injected bootstrap files.
|
||||
* This tests the full hook chain: session key → parse → load instructions → inject.
|
||||
*/
|
||||
simulateBootstrap(sessionKey: string): Promise<BootstrapFile[]>;
|
||||
/** Clean up temp directory. */
|
||||
cleanup(): Promise<void>;
|
||||
};
|
||||
|
||||
export type HarnessOptions = {
|
||||
/** Project name (default: "test-project"). */
|
||||
projectName?: string;
|
||||
/** Group ID (default: "-1234567890"). */
|
||||
groupId?: string;
|
||||
/** Repo path (default: "/tmp/test-repo"). */
|
||||
repo?: string;
|
||||
/** Base branch (default: "main"). */
|
||||
baseBranch?: string;
|
||||
/** Workflow config (default: DEFAULT_WORKFLOW). */
|
||||
workflow?: WorkflowConfig;
|
||||
/** Initial worker state overrides. */
|
||||
workers?: Record<string, Partial<import("../projects.js").WorkerState>>;
|
||||
/** Additional projects to seed. */
|
||||
extraProjects?: Record<string, Project>;
|
||||
};
|
||||
|
||||
export async function createTestHarness(opts?: HarnessOptions): Promise<TestHarness> {
|
||||
const {
|
||||
projectName = "test-project",
|
||||
groupId = "-1234567890",
|
||||
repo = "/tmp/test-repo",
|
||||
baseBranch = "main",
|
||||
workflow = DEFAULT_WORKFLOW,
|
||||
workers: workerOverrides,
|
||||
extraProjects,
|
||||
} = opts ?? {};
|
||||
|
||||
// Create temp workspace
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-e2e-"));
|
||||
const dataDir = path.join(workspaceDir, "devclaw");
|
||||
const logDir = path.join(dataDir, "log");
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
|
||||
// Build project
|
||||
const defaultWorkers: Record<string, import("../projects.js").WorkerState> = {
|
||||
developer: emptyWorkerState(["junior", "medior", "senior"]),
|
||||
tester: emptyWorkerState(["junior", "medior", "senior"]),
|
||||
architect: emptyWorkerState(["junior", "senior"]),
|
||||
reviewer: emptyWorkerState(["junior", "senior"]),
|
||||
};
|
||||
|
||||
// Apply worker overrides
|
||||
if (workerOverrides) {
|
||||
for (const [role, overrides] of Object.entries(workerOverrides)) {
|
||||
if (defaultWorkers[role]) {
|
||||
defaultWorkers[role] = { ...defaultWorkers[role], ...overrides };
|
||||
} else {
|
||||
defaultWorkers[role] = { ...emptyWorkerState([]), ...overrides };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const project: Project = {
|
||||
name: projectName,
|
||||
repo,
|
||||
groupName: "Test Group",
|
||||
deployUrl: "",
|
||||
baseBranch,
|
||||
deployBranch: baseBranch,
|
||||
provider: "github",
|
||||
workers: defaultWorkers,
|
||||
};
|
||||
|
||||
const projectsData: ProjectsData = {
|
||||
projects: {
|
||||
[groupId]: project,
|
||||
...extraProjects,
|
||||
},
|
||||
};
|
||||
|
||||
await writeProjects(workspaceDir, projectsData);
|
||||
|
||||
// Install mock runCommand
|
||||
const { interceptor, handler } = createCommandInterceptor();
|
||||
initRunCommand({
|
||||
runtime: {
|
||||
system: { runCommandWithTimeout: handler },
|
||||
},
|
||||
} as unknown as OpenClawPluginApi);
|
||||
|
||||
// Create test provider
|
||||
const provider = new TestProvider({ workflow });
|
||||
|
||||
return {
|
||||
workspaceDir,
|
||||
provider,
|
||||
commands: interceptor,
|
||||
groupId,
|
||||
project,
|
||||
workflow,
|
||||
async writeProjects(data: ProjectsData) {
|
||||
await writeProjects(workspaceDir, data);
|
||||
},
|
||||
async readProjects() {
|
||||
const { readProjects } = await import("../projects.js");
|
||||
return readProjects(workspaceDir);
|
||||
},
|
||||
async writePrompt(role: string, content: string, forProject?: string) {
|
||||
const dir = forProject
|
||||
? path.join(dataDir, "projects", forProject, "prompts")
|
||||
: path.join(dataDir, "prompts");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, `${role}.md`), content, "utf-8");
|
||||
},
|
||||
async simulateBootstrap(sessionKey: string) {
|
||||
// Capture the hook callback by mocking api.registerHook
|
||||
let hookCallback: ((event: any) => Promise<void>) | null = null;
|
||||
const mockApi = {
|
||||
registerHook(_name: string, cb: (event: any) => Promise<void>) {
|
||||
hookCallback = cb;
|
||||
},
|
||||
logger: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
registerBootstrapHook(mockApi);
|
||||
if (!hookCallback) throw new Error("registerBootstrapHook did not register a callback");
|
||||
|
||||
// Build a bootstrap event matching what OpenClaw sends
|
||||
const bootstrapFiles: BootstrapFile[] = [];
|
||||
await hookCallback({
|
||||
sessionKey,
|
||||
context: {
|
||||
workspaceDir,
|
||||
bootstrapFiles,
|
||||
},
|
||||
});
|
||||
|
||||
return bootstrapFiles;
|
||||
},
|
||||
async cleanup() {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
16
lib/testing/index.ts
Normal file
16
lib/testing/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* testing/ — Test infrastructure for DevClaw integration tests.
|
||||
*
|
||||
* Exports:
|
||||
* - TestProvider: In-memory IssueProvider with call tracking
|
||||
* - createTestHarness: Scaffolds temp workspace + mock runCommand
|
||||
*/
|
||||
export { TestProvider, type ProviderCall } from "./test-provider.js";
|
||||
export {
|
||||
createTestHarness,
|
||||
type TestHarness,
|
||||
type HarnessOptions,
|
||||
type CommandInterceptor,
|
||||
type CapturedCommand,
|
||||
type BootstrapFile,
|
||||
} from "./harness.js";
|
||||
286
lib/testing/test-provider.ts
Normal file
286
lib/testing/test-provider.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* TestProvider — In-memory IssueProvider for integration tests.
|
||||
*
|
||||
* Tracks all method calls for assertion. Issues are stored in a simple map.
|
||||
* No external dependencies — pure TypeScript.
|
||||
*/
|
||||
import type {
|
||||
IssueProvider,
|
||||
Issue,
|
||||
StateLabel,
|
||||
IssueComment,
|
||||
PrStatus,
|
||||
} from "../providers/provider.js";
|
||||
import { getStateLabels } from "../workflow.js";
|
||||
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Call tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ProviderCall =
|
||||
| { method: "ensureLabel"; args: { name: string; color: string } }
|
||||
| { method: "ensureAllStateLabels"; args: {} }
|
||||
| {
|
||||
method: "createIssue";
|
||||
args: {
|
||||
title: string;
|
||||
description: string;
|
||||
label: StateLabel;
|
||||
assignees?: string[];
|
||||
};
|
||||
}
|
||||
| { method: "listIssuesByLabel"; args: { label: StateLabel } }
|
||||
| { method: "getIssue"; args: { issueId: number } }
|
||||
| { method: "listComments"; args: { issueId: number } }
|
||||
| {
|
||||
method: "transitionLabel";
|
||||
args: { issueId: number; from: StateLabel; to: StateLabel };
|
||||
}
|
||||
| { method: "addLabel"; args: { issueId: number; label: string } }
|
||||
| { method: "removeLabels"; args: { issueId: number; labels: string[] } }
|
||||
| { method: "closeIssue"; args: { issueId: number } }
|
||||
| { method: "reopenIssue"; args: { issueId: number } }
|
||||
| { method: "hasMergedMR"; args: { issueId: number } }
|
||||
| { method: "getMergedMRUrl"; args: { issueId: number } }
|
||||
| { method: "getPrStatus"; args: { issueId: number } }
|
||||
| { method: "mergePr"; args: { issueId: number } }
|
||||
| { method: "getPrDiff"; args: { issueId: number } }
|
||||
| { method: "addComment"; args: { issueId: number; body: string } }
|
||||
| { method: "healthCheck"; args: {} };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TestProvider implements IssueProvider {
|
||||
/** All issues keyed by iid. */
|
||||
issues = new Map<number, Issue>();
|
||||
/** Comments per issue. */
|
||||
comments = new Map<number, IssueComment[]>();
|
||||
/** Labels that have been ensured. */
|
||||
labels = new Map<string, string>();
|
||||
/** PR status overrides per issue. Default: { state: "closed", url: null }. */
|
||||
prStatuses = new Map<number, PrStatus>();
|
||||
/** Merged MR URLs per issue. */
|
||||
mergedMrUrls = new Map<number, string>();
|
||||
/** Issue IDs where mergePr should fail (simulates merge conflicts). */
|
||||
mergePrFailures = new Set<number>();
|
||||
/** PR diffs per issue (for reviewer tests). */
|
||||
prDiffs = new Map<number, string>();
|
||||
/** All calls, in order. */
|
||||
calls: ProviderCall[] = [];
|
||||
|
||||
private nextIssueId = 1;
|
||||
private workflow: WorkflowConfig;
|
||||
|
||||
constructor(opts?: { workflow?: WorkflowConfig }) {
|
||||
this.workflow = opts?.workflow ?? DEFAULT_WORKFLOW;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Create an issue directly in the store (bypasses createIssue tracking). */
|
||||
seedIssue(overrides: Partial<Issue> & { iid: number }): Issue {
|
||||
const issue: Issue = {
|
||||
iid: overrides.iid,
|
||||
title: overrides.title ?? `Issue #${overrides.iid}`,
|
||||
description: overrides.description ?? "",
|
||||
labels: overrides.labels ?? [],
|
||||
state: overrides.state ?? "opened",
|
||||
web_url:
|
||||
overrides.web_url ?? `https://example.com/issues/${overrides.iid}`,
|
||||
};
|
||||
this.issues.set(issue.iid, issue);
|
||||
if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1;
|
||||
return issue;
|
||||
}
|
||||
|
||||
/** Set PR status for an issue (used by review pass tests). */
|
||||
setPrStatus(issueId: number, status: PrStatus): void {
|
||||
this.prStatuses.set(issueId, status);
|
||||
}
|
||||
|
||||
/** Get calls filtered by method name. */
|
||||
callsTo<M extends ProviderCall["method"]>(
|
||||
method: M,
|
||||
): Extract<ProviderCall, { method: M }>[] {
|
||||
return this.calls.filter((c) => c.method === method) as any;
|
||||
}
|
||||
|
||||
/** Reset call tracking (keeps issue state). */
|
||||
resetCalls(): void {
|
||||
this.calls = [];
|
||||
}
|
||||
|
||||
/** Full reset — clear everything. */
|
||||
reset(): void {
|
||||
this.issues.clear();
|
||||
this.comments.clear();
|
||||
this.labels.clear();
|
||||
this.prStatuses.clear();
|
||||
this.mergedMrUrls.clear();
|
||||
this.mergePrFailures.clear();
|
||||
this.prDiffs.clear();
|
||||
this.calls = [];
|
||||
this.nextIssueId = 1;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IssueProvider implementation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
this.calls.push({ method: "ensureLabel", args: { name, color } });
|
||||
this.labels.set(name, color);
|
||||
}
|
||||
|
||||
async ensureAllStateLabels(): Promise<void> {
|
||||
this.calls.push({ method: "ensureAllStateLabels", args: {} });
|
||||
const stateLabels = getStateLabels(this.workflow);
|
||||
for (const label of stateLabels) {
|
||||
this.labels.set(label, "#000000");
|
||||
}
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
title: string,
|
||||
description: string,
|
||||
label: StateLabel,
|
||||
assignees?: string[],
|
||||
): Promise<Issue> {
|
||||
this.calls.push({
|
||||
method: "createIssue",
|
||||
args: { title, description, label, assignees },
|
||||
});
|
||||
const iid = this.nextIssueId++;
|
||||
const issue: Issue = {
|
||||
iid,
|
||||
title,
|
||||
description,
|
||||
labels: [label],
|
||||
state: "opened",
|
||||
web_url: `https://example.com/issues/${iid}`,
|
||||
};
|
||||
this.issues.set(iid, issue);
|
||||
return issue;
|
||||
}
|
||||
|
||||
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
|
||||
this.calls.push({ method: "listIssuesByLabel", args: { label } });
|
||||
return [...this.issues.values()].filter((i) => i.labels.includes(label));
|
||||
}
|
||||
|
||||
async getIssue(issueId: number): Promise<Issue> {
|
||||
this.calls.push({ method: "getIssue", args: { issueId } });
|
||||
const issue = this.issues.get(issueId);
|
||||
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
|
||||
return issue;
|
||||
}
|
||||
|
||||
async listComments(issueId: number): Promise<IssueComment[]> {
|
||||
this.calls.push({ method: "listComments", args: { issueId } });
|
||||
return this.comments.get(issueId) ?? [];
|
||||
}
|
||||
|
||||
async transitionLabel(
|
||||
issueId: number,
|
||||
from: StateLabel,
|
||||
to: StateLabel,
|
||||
): Promise<void> {
|
||||
this.calls.push({ method: "transitionLabel", args: { issueId, from, to } });
|
||||
const issue = this.issues.get(issueId);
|
||||
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
|
||||
// Remove all state labels, add the new one
|
||||
const stateLabels = getStateLabels(this.workflow);
|
||||
issue.labels = issue.labels.filter((l) => !stateLabels.includes(l));
|
||||
issue.labels.push(to);
|
||||
}
|
||||
|
||||
async addLabel(issueId: number, label: string): Promise<void> {
|
||||
this.calls.push({ method: "addLabel", args: { issueId, label } });
|
||||
const issue = this.issues.get(issueId);
|
||||
if (issue && !issue.labels.includes(label)) {
|
||||
issue.labels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
async removeLabels(issueId: number, labels: string[]): Promise<void> {
|
||||
this.calls.push({ method: "removeLabels", args: { issueId, labels } });
|
||||
const issue = this.issues.get(issueId);
|
||||
if (issue) {
|
||||
issue.labels = issue.labels.filter((l) => !labels.includes(l));
|
||||
}
|
||||
}
|
||||
|
||||
async closeIssue(issueId: number): Promise<void> {
|
||||
this.calls.push({ method: "closeIssue", args: { issueId } });
|
||||
const issue = this.issues.get(issueId);
|
||||
if (issue) issue.state = "closed";
|
||||
}
|
||||
|
||||
async reopenIssue(issueId: number): Promise<void> {
|
||||
this.calls.push({ method: "reopenIssue", args: { issueId } });
|
||||
const issue = this.issues.get(issueId);
|
||||
if (issue) issue.state = "opened";
|
||||
}
|
||||
|
||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
|
||||
return issue.labels.includes(expected);
|
||||
}
|
||||
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
||||
const stateLabels = getStateLabels(this.workflow);
|
||||
return stateLabels.find((l) => issue.labels.includes(l)) ?? null;
|
||||
}
|
||||
|
||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||
this.calls.push({ method: "hasMergedMR", args: { issueId } });
|
||||
return this.mergedMrUrls.has(issueId);
|
||||
}
|
||||
|
||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||
this.calls.push({ method: "getMergedMRUrl", args: { issueId } });
|
||||
return this.mergedMrUrls.get(issueId) ?? null;
|
||||
}
|
||||
|
||||
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||
this.calls.push({ method: "getPrStatus", args: { issueId } });
|
||||
return this.prStatuses.get(issueId) ?? { state: "closed", url: null };
|
||||
}
|
||||
|
||||
async mergePr(issueId: number): Promise<void> {
|
||||
this.calls.push({ method: "mergePr", args: { issueId } });
|
||||
if (this.mergePrFailures.has(issueId)) {
|
||||
throw new Error(`Merge conflict: cannot merge PR for issue #${issueId}`);
|
||||
}
|
||||
// Simulate successful merge — update PR status to merged
|
||||
const existing = this.prStatuses.get(issueId);
|
||||
if (existing) {
|
||||
this.prStatuses.set(issueId, { state: "merged", url: existing.url });
|
||||
}
|
||||
}
|
||||
|
||||
async getPrDiff(issueId: number): Promise<string | null> {
|
||||
this.calls.push({ method: "getPrDiff", args: { issueId } });
|
||||
return this.prDiffs.get(issueId) ?? null;
|
||||
}
|
||||
|
||||
async addComment(issueId: number, body: string): Promise<void> {
|
||||
this.calls.push({ method: "addComment", args: { issueId, body } });
|
||||
const existing = this.comments.get(issueId) ?? [];
|
||||
existing.push({
|
||||
author: "test",
|
||||
body,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
this.comments.set(issueId, existing);
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
this.calls.push({ method: "healthCheck", args: {} });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
90
lib/tiers.ts
90
lib/tiers.ts
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* tiers.ts — Developer level definitions and model resolution.
|
||||
*
|
||||
* This module now delegates to the centralized role registry (lib/roles/).
|
||||
* Kept for backward compatibility — new code should import from lib/roles/ directly.
|
||||
*
|
||||
* Level names are plain: "junior", "senior", "reviewer", etc.
|
||||
* Role context (dev/qa/architect) is always provided by the caller.
|
||||
*/
|
||||
import {
|
||||
type WorkerRole,
|
||||
ROLE_REGISTRY,
|
||||
getLevelsForRole,
|
||||
getAllDefaultModels,
|
||||
roleForLevel,
|
||||
getDefaultModel,
|
||||
getEmoji,
|
||||
resolveModel as registryResolveModel,
|
||||
} from "./roles/index.js";
|
||||
|
||||
// Re-export WorkerRole from the registry
|
||||
export type { WorkerRole };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level constants — derived from registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @deprecated Use roles/selectors.getAllDefaultModels() */
|
||||
export const DEFAULT_MODELS = getAllDefaultModels();
|
||||
|
||||
/** @deprecated Use roles/selectors.getEmoji() */
|
||||
export const LEVEL_EMOJI: Record<string, Record<string, string>> = Object.fromEntries(
|
||||
Object.entries(ROLE_REGISTRY).map(([id, config]) => [id, { ...config.emoji }]),
|
||||
);
|
||||
|
||||
export const DEV_LEVELS = getLevelsForRole("dev") as readonly string[];
|
||||
export const QA_LEVELS = getLevelsForRole("qa") as readonly string[];
|
||||
export const ARCHITECT_LEVELS = getLevelsForRole("architect") as readonly string[];
|
||||
|
||||
export type DevLevel = string;
|
||||
export type QaLevel = string;
|
||||
export type ArchitectLevel = string;
|
||||
export type Level = string;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level checks — delegate to registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if a level belongs to the dev role. */
|
||||
export function isDevLevel(value: string): boolean {
|
||||
return DEV_LEVELS.includes(value);
|
||||
}
|
||||
|
||||
/** Check if a level belongs to the qa role. */
|
||||
export function isQaLevel(value: string): boolean {
|
||||
return QA_LEVELS.includes(value);
|
||||
}
|
||||
|
||||
/** Check if a level belongs to the architect role. */
|
||||
export function isArchitectLevel(value: string): boolean {
|
||||
return ARCHITECT_LEVELS.includes(value);
|
||||
}
|
||||
|
||||
/** Determine the role a level belongs to. */
|
||||
export function levelRole(level: string): WorkerRole | undefined {
|
||||
return roleForLevel(level) as WorkerRole | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model + emoji — delegate to registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @deprecated Use roles/selectors.getDefaultModel() */
|
||||
export function defaultModel(role: WorkerRole, level: string): string | undefined {
|
||||
return getDefaultModel(role, level);
|
||||
}
|
||||
|
||||
/** @deprecated Use roles/selectors.getEmoji() */
|
||||
export function levelEmoji(role: WorkerRole, level: string): string | undefined {
|
||||
return getEmoji(role, level);
|
||||
}
|
||||
|
||||
/** @deprecated Use roles/selectors.resolveModel() */
|
||||
export function resolveModel(
|
||||
role: WorkerRole,
|
||||
level: string,
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): string {
|
||||
return registryResolveModel(role, level, pluginConfig);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function createAutoConfigureModelsTool(api: OpenClawPluginApi) {
|
||||
if (modelCount === 1) {
|
||||
message += "ℹ️ Only one authenticated model found — assigned to all roles.";
|
||||
} else {
|
||||
message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → medior/reviewer, Tier 3 → junior/tester).";
|
||||
message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → mid, Tier 3 → junior).";
|
||||
}
|
||||
|
||||
if (preferProvider) {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Tests for architect role, design_task tool, and workflow integration.
|
||||
* Run with: npx tsx --test lib/tools/design-task.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parseDevClawSessionKey } from "../bootstrap-hook.js";
|
||||
import { isArchitectLevel, levelRole, resolveModel, defaultModel, levelEmoji } from "../tiers.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule,
|
||||
getCompletionEmoji, detectRoleFromLabel, getStateLabels,
|
||||
} from "../workflow.js";
|
||||
|
||||
describe("architect tiers", () => {
|
||||
it("should recognize architect levels", () => {
|
||||
assert.strictEqual(isArchitectLevel("opus"), true);
|
||||
assert.strictEqual(isArchitectLevel("sonnet"), true);
|
||||
assert.strictEqual(isArchitectLevel("medior"), false);
|
||||
});
|
||||
|
||||
it("should map architect levels to role", () => {
|
||||
assert.strictEqual(levelRole("opus"), "architect");
|
||||
assert.strictEqual(levelRole("sonnet"), "architect");
|
||||
});
|
||||
|
||||
it("should resolve default architect models", () => {
|
||||
assert.strictEqual(defaultModel("architect", "opus"), "anthropic/claude-opus-4-5");
|
||||
assert.strictEqual(defaultModel("architect", "sonnet"), "anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("should resolve architect model from config", () => {
|
||||
const config = { models: { architect: { opus: "custom/model" } } };
|
||||
assert.strictEqual(resolveModel("architect", "opus", config), "custom/model");
|
||||
});
|
||||
|
||||
it("should have architect emoji", () => {
|
||||
assert.strictEqual(levelEmoji("architect", "opus"), "🏗️");
|
||||
assert.strictEqual(levelEmoji("architect", "sonnet"), "📐");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect workflow states", () => {
|
||||
it("should include To Design and Designing in state labels", () => {
|
||||
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
||||
assert.ok(labels.includes("To Design"));
|
||||
assert.ok(labels.includes("Designing"));
|
||||
});
|
||||
|
||||
it("should have To Design as architect queue label", () => {
|
||||
const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect");
|
||||
assert.deepStrictEqual(queues, ["To Design"]);
|
||||
});
|
||||
|
||||
it("should have Designing as architect active label", () => {
|
||||
assert.strictEqual(getActiveLabel(DEFAULT_WORKFLOW, "architect"), "Designing");
|
||||
});
|
||||
|
||||
it("should detect architect role from To Design label", () => {
|
||||
assert.strictEqual(detectRoleFromLabel(DEFAULT_WORKFLOW, "To Design"), "architect");
|
||||
});
|
||||
|
||||
it("should have architect:done completion rule", () => {
|
||||
const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done");
|
||||
assert.ok(rule);
|
||||
assert.strictEqual(rule!.from, "Designing");
|
||||
assert.strictEqual(rule!.to, "Planning");
|
||||
});
|
||||
|
||||
it("should have architect:blocked completion rule", () => {
|
||||
const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked");
|
||||
assert.ok(rule);
|
||||
assert.strictEqual(rule!.from, "Designing");
|
||||
assert.strictEqual(rule!.to, "Refining");
|
||||
});
|
||||
|
||||
it("should have architect completion emoji", () => {
|
||||
assert.strictEqual(getCompletionEmoji("architect", "done"), "🏗️");
|
||||
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect model selection", () => {
|
||||
it("should select sonnet for standard design tasks", () => {
|
||||
const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect");
|
||||
assert.strictEqual(result.level, "sonnet");
|
||||
});
|
||||
|
||||
it("should select opus for complex design tasks", () => {
|
||||
const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect");
|
||||
assert.strictEqual(result.level, "opus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect session key parsing", () => {
|
||||
it("should parse architect session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-opus");
|
||||
assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" });
|
||||
});
|
||||
|
||||
it("should parse architect sonnet session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-sonnet");
|
||||
assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" });
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* design_task — Spawn an architect to investigate a design problem.
|
||||
*
|
||||
* Creates a "To Design" issue and optionally dispatches an architect worker.
|
||||
* The architect investigates systematically, then produces structured findings
|
||||
* as a GitHub issue in Planning state.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { getWorker } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
||||
|
||||
export function createDesignTaskTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "design_task",
|
||||
label: "Design Task",
|
||||
description: `Spawn an architect to investigate a design/architecture problem. Creates a "To Design" issue and dispatches an architect worker with persistent session.
|
||||
|
||||
The architect will:
|
||||
1. Investigate the problem systematically
|
||||
2. Research alternatives (>= 3 options)
|
||||
3. Produce structured findings with recommendation
|
||||
4. Complete with work_finish, moving the issue to Planning
|
||||
|
||||
Example:
|
||||
design_task({
|
||||
projectGroupId: "-5176490302",
|
||||
title: "Design: Session persistence strategy",
|
||||
description: "How should sessions be persisted across restarts?",
|
||||
complexity: "complex"
|
||||
})`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "title"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Project group ID",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Design title (e.g., 'Design: Session persistence')",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "What are we designing & why? Include context and constraints.",
|
||||
},
|
||||
focusAreas: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Specific areas to investigate (e.g., ['performance', 'scalability', 'simplicity'])",
|
||||
},
|
||||
complexity: {
|
||||
type: "string",
|
||||
enum: ["simple", "medium", "complex"],
|
||||
description: "Suggests architect level: simple/medium → sonnet, complex → opus. Defaults to medium.",
|
||||
},
|
||||
dryRun: {
|
||||
type: "boolean",
|
||||
description: "Preview without executing. Defaults to false.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const title = params.title as string;
|
||||
const description = (params.description as string) ?? "";
|
||||
const focusAreas = (params.focusAreas as string[]) ?? [];
|
||||
const complexity = (params.complexity as "simple" | "medium" | "complex") ?? "medium";
|
||||
const dryRun = (params.dryRun as boolean) ?? false;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
if (!groupId) throw new Error("projectGroupId is required");
|
||||
if (!title) throw new Error("title is required");
|
||||
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
const { provider } = await resolveProvider(project);
|
||||
|
||||
// Build issue body with focus areas
|
||||
const bodyParts = [description];
|
||||
if (focusAreas.length > 0) {
|
||||
bodyParts.push("", "## Focus Areas", ...focusAreas.map(a => `- ${a}`));
|
||||
}
|
||||
bodyParts.push(
|
||||
"", "---",
|
||||
"", "## Architect Output Template",
|
||||
"",
|
||||
"When complete, the architect will produce findings covering:",
|
||||
"1. **Problem Statement** — Why is this design decision important?",
|
||||
"2. **Current State** — What exists today? Limitations?",
|
||||
"3. **Alternatives** (>= 3 options with pros/cons and effort estimates)",
|
||||
"4. **Recommendation** — Which option and why?",
|
||||
"5. **Implementation Outline** — What dev tasks are needed?",
|
||||
"6. **References** — Code, docs, prior art",
|
||||
);
|
||||
const issueBody = bodyParts.join("\n");
|
||||
|
||||
// Create issue in To Design state
|
||||
const issue = await provider.createIssue(title, issueBody, "To Design" as StateLabel);
|
||||
|
||||
await auditLog(workspaceDir, "design_task", {
|
||||
project: project.name, groupId, issueId: issue.iid,
|
||||
title, complexity, focusAreas, dryRun,
|
||||
});
|
||||
|
||||
// Select level based on complexity
|
||||
const level = complexity === "complex" ? "opus" : "sonnet";
|
||||
|
||||
if (dryRun) {
|
||||
return jsonResult({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" },
|
||||
design: {
|
||||
level,
|
||||
model: complexity === "complex" ? "anthropic/claude-opus-4-5" : "anthropic/claude-sonnet-4-5",
|
||||
status: "dry_run",
|
||||
},
|
||||
announcement: `📐 [DRY RUN] Would spawn architect (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check architect availability
|
||||
const worker = getWorker(project, "architect");
|
||||
if (worker.active) {
|
||||
// Issue created but can't dispatch yet — will be picked up by heartbeat
|
||||
return jsonResult({
|
||||
success: true,
|
||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" },
|
||||
design: {
|
||||
level,
|
||||
status: "queued",
|
||||
reason: `Architect already active on #${worker.issueId}. Issue queued for pickup.`,
|
||||
},
|
||||
announcement: `📐 Created design task #${issue.iid}: ${title} (queued — architect busy)\n🔗 ${issue.web_url}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatch architect
|
||||
const workflow = DEFAULT_WORKFLOW;
|
||||
const targetLabel = getActiveLabel(workflow, "architect");
|
||||
const pluginConfig = getPluginConfig(api);
|
||||
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issueBody,
|
||||
issueUrl: issue.web_url,
|
||||
role: "architect",
|
||||
level,
|
||||
fromLabel: "To Design",
|
||||
toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
provider,
|
||||
pluginConfig,
|
||||
channel: project.channel,
|
||||
sessionKey: ctx.sessionKey,
|
||||
runtime: api.runtime,
|
||||
});
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: targetLabel },
|
||||
design: {
|
||||
sessionKey: dr.sessionKey,
|
||||
level: dr.level,
|
||||
model: dr.model,
|
||||
sessionAction: dr.sessionAction,
|
||||
status: "in_progress",
|
||||
},
|
||||
project: project.name,
|
||||
announcement: dr.announcement,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { readProjects, getProject } from "../projects.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js";
|
||||
import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js";
|
||||
import { getAllRoleIds } from "../roles/index.js";
|
||||
|
||||
export function createHealthTool() {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -52,13 +51,13 @@ export function createHealthTool() {
|
||||
if (!project) continue;
|
||||
const { provider } = await resolveProvider(project);
|
||||
|
||||
for (const role of getAllRoleIds()) {
|
||||
for (const role of Object.keys(project.workers)) {
|
||||
// Worker health check (session liveness, label consistency, etc)
|
||||
const healthFixes = await checkWorkerHealth({
|
||||
workspaceDir,
|
||||
groupId: pid,
|
||||
project,
|
||||
role: role as any,
|
||||
role,
|
||||
sessions,
|
||||
autoFix: fix,
|
||||
provider,
|
||||
@@ -70,7 +69,7 @@ export function createHealthTool() {
|
||||
workspaceDir,
|
||||
groupId: pid,
|
||||
project,
|
||||
role: role as any,
|
||||
role,
|
||||
autoFix: fix,
|
||||
provider,
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export function createOnboardTool(api: OpenClawPluginApi) {
|
||||
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
||||
: configured && hasWorkspace ? "reconfigure" : "first-run";
|
||||
|
||||
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
||||
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext();
|
||||
|
||||
return jsonResult({
|
||||
success: true, mode, configured, instructions,
|
||||
|
||||
@@ -15,40 +15,29 @@ import { resolveRepoPath } from "../projects.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js";
|
||||
import { ExecutionMode, getRoleLabels } from "../workflow.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
|
||||
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||
|
||||
/**
|
||||
* Scaffold project-specific prompt files.
|
||||
* Scaffold project-specific prompt files for all registered roles.
|
||||
* Returns true if files were created, false if they already existed.
|
||||
*/
|
||||
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
||||
const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const promptsDir = path.join(workspaceDir, DATA_DIR, "projects", projectName, "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
|
||||
const projectDev = path.join(projectDir, "dev.md");
|
||||
const projectQa = path.join(projectDir, "qa.md");
|
||||
let created = false;
|
||||
|
||||
try {
|
||||
await fs.access(projectDev);
|
||||
} catch {
|
||||
await fs.writeFile(projectDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
|
||||
created = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(projectQa);
|
||||
} catch {
|
||||
await fs.writeFile(projectQa, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
||||
created = true;
|
||||
}
|
||||
|
||||
const projectArchitect = path.join(projectDir, "architect.md");
|
||||
try {
|
||||
await fs.access(projectArchitect);
|
||||
} catch {
|
||||
await fs.writeFile(projectArchitect, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8");
|
||||
created = true;
|
||||
for (const role of getAllRoleIds()) {
|
||||
const filePath = path.join(promptsDir, `${role}.md`);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`;
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
created = true;
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
@@ -97,7 +86,7 @@ export function createProjectRegisterTool() {
|
||||
},
|
||||
roleExecution: {
|
||||
type: "string",
|
||||
enum: ["parallel", "sequential"],
|
||||
enum: Object.values(ExecutionMode),
|
||||
description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.",
|
||||
},
|
||||
},
|
||||
@@ -112,7 +101,7 @@ export function createProjectRegisterTool() {
|
||||
const baseBranch = params.baseBranch as string;
|
||||
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
||||
const deployUrl = (params.deployUrl as string) ?? "";
|
||||
const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel";
|
||||
const roleExecution = (params.roleExecution as ExecutionMode) ?? ExecutionMode.PARALLEL;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
@@ -122,7 +111,8 @@ export function createProjectRegisterTool() {
|
||||
// 1. Check project not already registered (allow re-register if incomplete)
|
||||
const data = await readProjects(workspaceDir);
|
||||
const existing = data.projects[groupId];
|
||||
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
|
||||
const existingWorkers = existing?.workers ?? {};
|
||||
if (existing && Object.values(existingWorkers).some(w => w.sessions && Object.keys(w.sessions).length > 0)) {
|
||||
throw new Error(
|
||||
`Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`,
|
||||
);
|
||||
@@ -162,7 +152,20 @@ export function createProjectRegisterTool() {
|
||||
// 4. Create all state labels (idempotent)
|
||||
await provider.ensureAllStateLabels();
|
||||
|
||||
// 4b. Create role:level + step routing labels (e.g. developer:junior, review:human, test:skip)
|
||||
const resolvedConfig = await loadConfig(workspaceDir, name);
|
||||
const roleLabels = getRoleLabels(resolvedConfig.roles);
|
||||
for (const { name: labelName, color } of roleLabels) {
|
||||
await provider.ensureLabel(labelName, color);
|
||||
}
|
||||
|
||||
// 5. Add project to projects.json
|
||||
// Build workers map from all registered roles
|
||||
const workers: Record<string, import("../projects.js").WorkerState> = {};
|
||||
for (const role of getAllRoleIds()) {
|
||||
workers[role] = emptyWorkerState([...getLevelsForRole(role)]);
|
||||
}
|
||||
|
||||
data.projects[groupId] = {
|
||||
name,
|
||||
repo,
|
||||
@@ -173,9 +176,7 @@ export function createProjectRegisterTool() {
|
||||
channel,
|
||||
provider: providerType,
|
||||
roleExecution,
|
||||
dev: emptyWorkerState([...getLevelsForRole("dev")]),
|
||||
qa: emptyWorkerState([...getLevelsForRole("qa")]),
|
||||
architect: emptyWorkerState([...getLevelsForRole("architect")]),
|
||||
workers,
|
||||
};
|
||||
|
||||
await writeProjects(workspaceDir, data);
|
||||
|
||||
@@ -15,18 +15,18 @@ describe("status execution-aware sequencing", () => {
|
||||
});
|
||||
|
||||
describe("role assignment", () => {
|
||||
it("should assign To Improve to dev", () => {
|
||||
// To Improve = dev work
|
||||
it("should assign To Improve to developer", () => {
|
||||
// To Improve = developer work
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("should assign To Do to dev", () => {
|
||||
// To Do = dev work
|
||||
it("should assign To Do to developer", () => {
|
||||
// To Do = developer work
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("should assign To Test to qa", () => {
|
||||
// To Test = qa work
|
||||
it("should assign To Test to tester", () => {
|
||||
// To Test = tester work
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
@@ -43,12 +43,12 @@ describe("status execution-aware sequencing", () => {
|
||||
});
|
||||
|
||||
it("should support parallel role execution within project", () => {
|
||||
// DEV and QA can run simultaneously
|
||||
// Developer and Tester can run simultaneously
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("should support sequential role execution within project", () => {
|
||||
// DEV and QA alternate
|
||||
// Developer and Tester alternate
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
|
||||
104
lib/tools/research-task.test.ts
Normal file
104
lib/tools/research-task.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for architect role, research_task tool, and workflow integration.
|
||||
* Run with: npx tsx --test lib/tools/research-task.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parseDevClawSessionKey } from "../bootstrap-hook.js";
|
||||
import { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW, getQueueLabels, getCompletionRule,
|
||||
getCompletionEmoji, getStateLabels, hasWorkflowStates,
|
||||
} from "../workflow.js";
|
||||
|
||||
describe("architect tiers", () => {
|
||||
it("should recognize architect levels", () => {
|
||||
assert.strictEqual(isLevelForRole("junior", "architect"), true);
|
||||
assert.strictEqual(isLevelForRole("senior", "architect"), true);
|
||||
assert.strictEqual(isLevelForRole("medior", "architect"), false);
|
||||
});
|
||||
|
||||
it("should map architect levels to role", () => {
|
||||
// "junior" and "senior" appear in developer first (registry order), so roleForLevel returns "developer"
|
||||
// This is expected — use isLevelForRole for role-specific checks
|
||||
assert.strictEqual(roleForLevel("junior"), "developer");
|
||||
assert.strictEqual(roleForLevel("senior"), "developer");
|
||||
});
|
||||
|
||||
it("should resolve default architect models", () => {
|
||||
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
|
||||
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("should resolve architect model from resolved role config", () => {
|
||||
const resolvedRole = { models: { senior: "custom/model" }, levels: ["junior", "senior"], defaultLevel: "junior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||
assert.strictEqual(resolveModel("architect", "senior", resolvedRole), "custom/model");
|
||||
});
|
||||
|
||||
it("should have architect emoji", () => {
|
||||
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
|
||||
assert.strictEqual(getEmoji("architect", "junior"), "📐");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect workflow — no dedicated states", () => {
|
||||
it("should NOT have To Design or Designing in state labels", () => {
|
||||
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
||||
assert.ok(!labels.includes("To Design"), "To Design should not exist");
|
||||
assert.ok(!labels.includes("Designing"), "Designing should not exist");
|
||||
});
|
||||
|
||||
it("should have no queue labels for architect", () => {
|
||||
const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect");
|
||||
assert.deepStrictEqual(queues, []);
|
||||
});
|
||||
|
||||
it("should report architect has no workflow states", () => {
|
||||
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "architect"), false);
|
||||
});
|
||||
|
||||
it("should report developer has workflow states", () => {
|
||||
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "developer"), true);
|
||||
});
|
||||
|
||||
it("should report tester has workflow states", () => {
|
||||
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "tester"), true);
|
||||
});
|
||||
|
||||
it("should have no completion rules for architect (no active state)", () => {
|
||||
const doneRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done");
|
||||
assert.strictEqual(doneRule, null);
|
||||
const blockedRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked");
|
||||
assert.strictEqual(blockedRule, null);
|
||||
});
|
||||
|
||||
it("should still have completion emoji for architect results", () => {
|
||||
assert.strictEqual(getCompletionEmoji("architect", "done"), "✅");
|
||||
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect model selection", () => {
|
||||
it("should select junior for standard design tasks", () => {
|
||||
const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect");
|
||||
assert.strictEqual(result.level, "junior");
|
||||
});
|
||||
|
||||
it("should select senior for complex design tasks", () => {
|
||||
const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect");
|
||||
assert.strictEqual(result.level, "senior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("architect session key parsing", () => {
|
||||
it("should parse architect session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-senior");
|
||||
assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" });
|
||||
});
|
||||
|
||||
it("should parse architect junior session key", () => {
|
||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-junior");
|
||||
assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" });
|
||||
});
|
||||
});
|
||||
190
lib/tools/research-task.ts
Normal file
190
lib/tools/research-task.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* research_task — Spawn an architect to research a design/architecture problem.
|
||||
*
|
||||
* Creates a Planning issue with rich context and dispatches an architect worker.
|
||||
* The architect researches the problem and produces detailed findings as issue comments.
|
||||
* The issue stays in Planning — ready for human review when the architect completes.
|
||||
*
|
||||
* No queue states — tool-triggered only.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { getWorker } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { resolveModel } from "../roles/index.js";
|
||||
|
||||
/** Planning label — architect issues go directly here. */
|
||||
const PLANNING_LABEL = "Planning";
|
||||
|
||||
export function createResearchTaskTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "research_task",
|
||||
label: "Research Task",
|
||||
description: `Spawn an architect to research a design/architecture problem. Creates a Planning issue and dispatches an architect worker.
|
||||
|
||||
IMPORTANT: Provide a detailed description with enough background context for the architect
|
||||
to produce actionable, development-ready findings. Include: current state, constraints,
|
||||
requirements, relevant code paths, and any prior decisions. The output should be detailed
|
||||
enough for a developer to start implementation immediately.
|
||||
|
||||
The architect will:
|
||||
1. Research the problem systematically (codebase, docs, web)
|
||||
2. Investigate >= 3 alternatives with tradeoffs
|
||||
3. Produce a recommendation with implementation outline
|
||||
4. Post findings as issue comments, then complete with work_finish
|
||||
|
||||
Example:
|
||||
research_task({
|
||||
projectGroupId: "-5176490302",
|
||||
title: "Research: Session persistence strategy",
|
||||
description: "Sessions are lost on restart. Current impl uses in-memory Map in session-store.ts. Constraints: must work with SQLite (already a dep), max 50ms latency on read. Prior discussion in #42 ruled out Redis.",
|
||||
focusAreas: ["SQLite vs file-based", "migration path", "cache invalidation"],
|
||||
complexity: "complex"
|
||||
})`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "title", "description"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Project group ID",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Research title (e.g., 'Research: Session persistence strategy')",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Detailed background context: what exists today, why this needs investigation, constraints, relevant code paths, prior decisions. Must be detailed enough for the architect to produce development-ready findings.",
|
||||
},
|
||||
focusAreas: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Specific areas to investigate (e.g., ['performance', 'scalability', 'simplicity'])",
|
||||
},
|
||||
complexity: {
|
||||
type: "string",
|
||||
enum: ["simple", "medium", "complex"],
|
||||
description: "Suggests architect level: simple/medium → junior, complex → senior. Defaults to medium.",
|
||||
},
|
||||
dryRun: {
|
||||
type: "boolean",
|
||||
description: "Preview without executing. Defaults to false.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const title = params.title as string;
|
||||
const description = (params.description as string) ?? "";
|
||||
const focusAreas = (params.focusAreas as string[]) ?? [];
|
||||
const complexity = (params.complexity as "simple" | "medium" | "complex") ?? "medium";
|
||||
const dryRun = (params.dryRun as boolean) ?? false;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
if (!groupId) throw new Error("projectGroupId is required");
|
||||
if (!title) throw new Error("title is required");
|
||||
if (!description) throw new Error("description is required — provide detailed background context for the architect");
|
||||
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
const { provider } = await resolveProvider(project);
|
||||
const pluginConfig = getPluginConfig(api);
|
||||
const role = "architect";
|
||||
|
||||
// Build issue body with rich context
|
||||
const bodyParts = [
|
||||
"## Background",
|
||||
"",
|
||||
description,
|
||||
];
|
||||
if (focusAreas.length > 0) {
|
||||
bodyParts.push("", "## Focus Areas", ...focusAreas.map(a => `- ${a}`));
|
||||
}
|
||||
const issueBody = bodyParts.join("\n");
|
||||
|
||||
// Create issue directly in Planning state (no queue — tool-triggered only)
|
||||
const issue = await provider.createIssue(title, issueBody, PLANNING_LABEL as StateLabel);
|
||||
|
||||
await auditLog(workspaceDir, "research_task", {
|
||||
project: project.name, groupId, issueId: issue.iid,
|
||||
title, complexity, focusAreas, dryRun,
|
||||
});
|
||||
|
||||
// Select level: use complexity hint to guide the heuristic
|
||||
const level = complexity === "complex"
|
||||
? selectLevel(title, "system-wide " + description, role).level
|
||||
: selectLevel(title, description, role).level;
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
const resolvedRole = resolvedConfig.roles[role];
|
||||
const model = resolveModel(role, level, resolvedRole);
|
||||
|
||||
if (dryRun) {
|
||||
return jsonResult({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
|
||||
design: { level, model, status: "dry_run" },
|
||||
announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check worker availability
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) {
|
||||
return jsonResult({
|
||||
success: true,
|
||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
|
||||
design: {
|
||||
level,
|
||||
status: "queued",
|
||||
reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue created in Planning — dispatch manually when architect is free.`,
|
||||
},
|
||||
announcement: `📐 Created research task #${issue.iid}: ${title} (architect busy — issue in Planning)\n🔗 ${issue.web_url}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatch architect directly — issue stays in Planning (no state transition)
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir,
|
||||
agentId: ctx.agentId,
|
||||
groupId,
|
||||
project,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
issueDescription: issueBody,
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
level,
|
||||
fromLabel: PLANNING_LABEL,
|
||||
toLabel: PLANNING_LABEL,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
provider,
|
||||
pluginConfig,
|
||||
channel: project.channel,
|
||||
sessionKey: ctx.sessionKey,
|
||||
runtime: api.runtime,
|
||||
});
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
|
||||
design: {
|
||||
sessionKey: dr.sessionKey,
|
||||
level: dr.level,
|
||||
model: dr.model,
|
||||
sessionAction: dr.sessionAction,
|
||||
status: "in_progress",
|
||||
},
|
||||
project: project.name,
|
||||
announcement: dr.announcement,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { runSetup, type SetupOpts } from "../setup/index.js";
|
||||
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js";
|
||||
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { ExecutionMode } from "../workflow.js";
|
||||
|
||||
export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "setup",
|
||||
label: "Setup",
|
||||
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.`,
|
||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, devclaw/projects.json, devclaw/prompts/, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -36,44 +37,22 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
models: {
|
||||
type: "object",
|
||||
description: "Model overrides per role and level.",
|
||||
properties: {
|
||||
dev: {
|
||||
properties: Object.fromEntries(
|
||||
getAllRoleIds().map((role) => [role, {
|
||||
type: "object",
|
||||
description: "Developer level models",
|
||||
properties: {
|
||||
junior: {
|
||||
description: `${role.toUpperCase()} level models`,
|
||||
properties: Object.fromEntries(
|
||||
getLevelsForRole(role).map((level) => [level, {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.dev.junior}`,
|
||||
},
|
||||
medior: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.dev.medior}`,
|
||||
},
|
||||
senior: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.dev.senior}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
qa: {
|
||||
type: "object",
|
||||
description: "QA level models",
|
||||
properties: {
|
||||
reviewer: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.qa.reviewer}`,
|
||||
},
|
||||
tester: {
|
||||
type: "string",
|
||||
description: `Default: ${DEFAULT_MODELS.qa.tester}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`,
|
||||
}]),
|
||||
),
|
||||
}]),
|
||||
),
|
||||
},
|
||||
projectExecution: {
|
||||
type: "string",
|
||||
enum: ["parallel", "sequential"],
|
||||
enum: Object.values(ExecutionMode),
|
||||
description: "Project execution mode. Default: parallel.",
|
||||
},
|
||||
},
|
||||
@@ -90,8 +69,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||
models: params.models as SetupOpts["models"],
|
||||
projectExecution: params.projectExecution as
|
||||
| "parallel"
|
||||
| "sequential"
|
||||
| ExecutionMode
|
||||
| undefined,
|
||||
});
|
||||
|
||||
@@ -107,12 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"Models:",
|
||||
...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`),
|
||||
...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`),
|
||||
"",
|
||||
);
|
||||
lines.push("Models:");
|
||||
for (const [role, levels] of Object.entries(result.models)) {
|
||||
for (const [level, model] of Object.entries(levels)) {
|
||||
lines.push(` ${role}.${level}: ${model}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`));
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
|
||||
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
|
||||
import { DEFAULT_WORKFLOW } from "../workflow.js";
|
||||
import { loadWorkflow, ExecutionMode } from "../workflow.js";
|
||||
|
||||
export function createStatusTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -30,10 +30,10 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
||||
const groupId = params.projectGroupId as string | undefined;
|
||||
|
||||
const pluginConfig = getPluginConfig(api);
|
||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
|
||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
|
||||
|
||||
// TODO: Load per-project workflow when supported
|
||||
const workflow = DEFAULT_WORKFLOW;
|
||||
// Load workspace-level workflow (per-project loaded inside map)
|
||||
const workflow = await loadWorkflow(workspaceDir);
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectIds = groupId ? [groupId] : Object.keys(data.projects);
|
||||
@@ -52,28 +52,22 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
||||
queueCounts[label] = issues.length;
|
||||
}
|
||||
|
||||
// Build dynamic workers summary
|
||||
const workers: Record<string, { active: boolean; issueId: string | null; level: string | null; startTime: string | null }> = {};
|
||||
for (const [role, worker] of Object.entries(project.workers)) {
|
||||
workers[role] = {
|
||||
active: worker.active,
|
||||
issueId: worker.issueId,
|
||||
level: worker.level,
|
||||
startTime: worker.startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: project.name,
|
||||
groupId: pid,
|
||||
roleExecution: project.roleExecution ?? "parallel",
|
||||
dev: {
|
||||
active: project.dev.active,
|
||||
issueId: project.dev.issueId,
|
||||
level: project.dev.level,
|
||||
startTime: project.dev.startTime,
|
||||
},
|
||||
qa: {
|
||||
active: project.qa.active,
|
||||
issueId: project.qa.issueId,
|
||||
level: project.qa.level,
|
||||
startTime: project.qa.startTime,
|
||||
},
|
||||
architect: {
|
||||
active: project.architect.active,
|
||||
issueId: project.architect.issueId,
|
||||
level: project.architect.level,
|
||||
startTime: project.architect.startTime,
|
||||
},
|
||||
roleExecution: project.roleExecution ?? ExecutionMode.PARALLEL,
|
||||
workers,
|
||||
queue: queueCounts,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* task_comment — Add review comments or notes to an issue.
|
||||
*
|
||||
* Use cases:
|
||||
* - QA worker adds review feedback without blocking pass/fail
|
||||
* - DEV worker posts implementation notes
|
||||
* - Tester worker adds review feedback without blocking pass/fail
|
||||
* - Developer worker posts implementation notes
|
||||
* - Orchestrator adds summary comments
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
@@ -11,10 +11,11 @@ import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||
import { getAllRoleIds, getFallbackEmoji } from "../roles/index.js";
|
||||
|
||||
/** Valid author roles for attribution */
|
||||
const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const;
|
||||
type AuthorRole = (typeof AUTHOR_ROLES)[number];
|
||||
/** Valid author roles for attribution — all registry roles + orchestrator */
|
||||
const AUTHOR_ROLES = [...getAllRoleIds(), "orchestrator"];
|
||||
type AuthorRole = string;
|
||||
|
||||
export function createTaskCommentTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -23,15 +24,15 @@ export function createTaskCommentTool(api: OpenClawPluginApi) {
|
||||
description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change.
|
||||
|
||||
Use cases:
|
||||
- QA adds review feedback without blocking pass/fail
|
||||
- DEV posts implementation notes or progress updates
|
||||
- Tester adds review feedback without blocking pass/fail
|
||||
- Developer posts implementation notes or progress updates
|
||||
- Orchestrator adds summary comments
|
||||
- Cross-referencing related issues or PRs
|
||||
|
||||
Examples:
|
||||
- Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" }
|
||||
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "qa" }
|
||||
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "dev" }`,
|
||||
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "tester" }
|
||||
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "developer" }`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "issueId", "body"],
|
||||
@@ -73,7 +74,7 @@ Examples:
|
||||
const issue = await provider.getIssue(issueId);
|
||||
|
||||
const commentBody = authorRole
|
||||
? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}`
|
||||
? `${getRoleEmoji(authorRole)} **${authorRole.toUpperCase()}**: ${body}`
|
||||
: body;
|
||||
|
||||
await provider.addComment(issueId, commentBody);
|
||||
@@ -99,8 +100,7 @@ Examples:
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROLE_EMOJI: Record<AuthorRole, string> = {
|
||||
dev: "👨💻",
|
||||
qa: "🔍",
|
||||
orchestrator: "🎛️",
|
||||
};
|
||||
function getRoleEmoji(role: string): string {
|
||||
if (role === "orchestrator") return "🎛️";
|
||||
return getFallbackEmoji(role);
|
||||
}
|
||||
|
||||
@@ -13,19 +13,23 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||
|
||||
/** Derive the initial state label from the workflow config. */
|
||||
const INITIAL_LABEL = DEFAULT_WORKFLOW.states[DEFAULT_WORKFLOW.initial].label;
|
||||
|
||||
export function createTaskCreateTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_create",
|
||||
label: "Task Create",
|
||||
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
|
||||
|
||||
**IMPORTANT:** Always creates in "Planning" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "Planning" issues require human review before entering the queue.
|
||||
**IMPORTANT:** Always creates in "${INITIAL_LABEL}" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "${INITIAL_LABEL}" issues require human review before entering the queue.
|
||||
|
||||
Examples:
|
||||
- Default: { title: "Fix login bug" } → created in Planning
|
||||
- Default: { title: "Fix login bug" } → created in ${INITIAL_LABEL}
|
||||
- User says "create and start working": { title: "Implement auth", description: "...", label: "To Do" }`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
@@ -45,8 +49,8 @@ Examples:
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
description: `State label. Defaults to "Planning" — only use "To Do" when the user explicitly asks to start work immediately.`,
|
||||
enum: STATE_LABELS,
|
||||
description: `State label. Defaults to "${INITIAL_LABEL}" — only use "To Do" when the user explicitly asks to start work immediately.`,
|
||||
enum: getStateLabels(DEFAULT_WORKFLOW),
|
||||
},
|
||||
assignees: {
|
||||
type: "array",
|
||||
@@ -64,7 +68,7 @@ Examples:
|
||||
const groupId = params.projectGroupId as string;
|
||||
const title = params.title as string;
|
||||
const description = (params.description as string) ?? "";
|
||||
const label = (params.label as StateLabel) ?? "Planning";
|
||||
const label = (params.label as StateLabel) ?? INITIAL_LABEL;
|
||||
const assignees = (params.assignees as string[] | undefined) ?? [];
|
||||
const pickup = (params.pickup as boolean) ?? false;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
@@ -1,63 +1,133 @@
|
||||
/**
|
||||
* Integration test for task_update tool.
|
||||
* Tests for task_update tool — state transitions and level overrides.
|
||||
*
|
||||
* Run manually: node --loader ts-node/esm lib/tools/task-update.test.ts
|
||||
* Run: npx tsx --test lib/tools/task-update.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels, ReviewPolicy, resolveReviewRouting } from "../workflow.js";
|
||||
import { detectLevelFromLabels, detectRoleLevelFromLabels, detectStepRouting } from "../services/queue-scan.js";
|
||||
|
||||
describe("task_update tool", () => {
|
||||
it("has correct schema", () => {
|
||||
// Verify the tool signature matches requirements
|
||||
const requiredParams = ["projectGroupId", "issueId", "state"];
|
||||
const optionalParams = ["reason"];
|
||||
|
||||
// Schema validation would go here in a real test
|
||||
assert.ok(true, "Schema structure is valid");
|
||||
// state is now optional — at least one of state or level required
|
||||
const requiredParams = ["projectGroupId", "issueId"];
|
||||
assert.strictEqual(requiredParams.length, 2);
|
||||
});
|
||||
|
||||
it("supports all state labels", () => {
|
||||
const validStates = [
|
||||
"Planning",
|
||||
"To Do",
|
||||
"Doing",
|
||||
"To Test",
|
||||
"Testing",
|
||||
"Done",
|
||||
"To Improve",
|
||||
"Refining",
|
||||
];
|
||||
|
||||
// In a real test, we'd verify these against the tool's enum
|
||||
assert.strictEqual(validStates.length, 8);
|
||||
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
||||
assert.strictEqual(labels.length, 10);
|
||||
assert.ok(labels.includes("Planning"));
|
||||
assert.ok(labels.includes("Done"));
|
||||
assert.ok(labels.includes("To Review"));
|
||||
});
|
||||
|
||||
it("validates required parameters", () => {
|
||||
// Test cases:
|
||||
// - Missing projectGroupId → Error
|
||||
// - Missing issueId → Error
|
||||
// - Missing state → Error
|
||||
// - Invalid state → Error
|
||||
// - Valid params → Success
|
||||
// At least one of state or level required
|
||||
assert.ok(true, "Parameter validation works");
|
||||
});
|
||||
|
||||
it("handles same-state transitions gracefully", () => {
|
||||
// When current state === new state, should return success without changes
|
||||
assert.ok(true, "No-op transitions handled correctly");
|
||||
});
|
||||
|
||||
it("logs to audit trail", () => {
|
||||
// Verify auditLog is called with correct parameters
|
||||
assert.ok(true, "Audit logging works");
|
||||
});
|
||||
});
|
||||
|
||||
// Test scenarios for manual verification:
|
||||
// 1. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning" })
|
||||
// → Should transition from "To Do" to "Planning"
|
||||
// 2. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning", reason: "Needs more discussion" })
|
||||
// → Should log reason in audit trail
|
||||
// 3. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "To Do" })
|
||||
// → Should transition back from "Planning" to "To Do"
|
||||
describe("detectLevelFromLabels — colon format", () => {
|
||||
it("should detect level from colon-format labels", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["developer:senior", "Doing"]), "senior");
|
||||
assert.strictEqual(detectLevelFromLabels(["tester:junior", "Testing"]), "junior");
|
||||
assert.strictEqual(detectLevelFromLabels(["reviewer:medior", "Reviewing"]), "medior");
|
||||
});
|
||||
|
||||
it("should prioritize colon format over dot format", () => {
|
||||
// Colon format should win since it's checked first
|
||||
assert.strictEqual(detectLevelFromLabels(["developer:senior", "dev.junior"]), "senior");
|
||||
});
|
||||
|
||||
it("should fall back to dot format", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["developer.senior", "Doing"]), "senior");
|
||||
});
|
||||
|
||||
it("should fall back to plain level name", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["senior", "Doing"]), "senior");
|
||||
});
|
||||
|
||||
it("should return null when no level found", () => {
|
||||
assert.strictEqual(detectLevelFromLabels(["Doing", "bug"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectRoleLevelFromLabels", () => {
|
||||
it("should detect role and level from colon-format labels", () => {
|
||||
const result = detectRoleLevelFromLabels(["developer:senior", "Doing"]);
|
||||
assert.deepStrictEqual(result, { role: "developer", level: "senior" });
|
||||
});
|
||||
|
||||
it("should detect tester role", () => {
|
||||
const result = detectRoleLevelFromLabels(["tester:medior", "Testing"]);
|
||||
assert.deepStrictEqual(result, { role: "tester", level: "medior" });
|
||||
});
|
||||
|
||||
it("should return null for step routing labels", () => {
|
||||
// review:human is a step routing label, not a role:level label
|
||||
const result = detectRoleLevelFromLabels(["review:human", "Doing"]);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it("should return null when no colon labels present", () => {
|
||||
assert.strictEqual(detectRoleLevelFromLabels(["Doing", "bug"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectStepRouting", () => {
|
||||
it("should detect review:human", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:human", "Doing"], "review"), "human");
|
||||
});
|
||||
|
||||
it("should detect review:agent", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:agent", "To Review"], "review"), "agent");
|
||||
});
|
||||
|
||||
it("should detect review:skip", () => {
|
||||
assert.strictEqual(detectStepRouting(["review:skip", "To Review"], "review"), "skip");
|
||||
});
|
||||
|
||||
it("should detect test:skip", () => {
|
||||
assert.strictEqual(detectStepRouting(["test:skip", "To Test"], "test"), "skip");
|
||||
});
|
||||
|
||||
it("should return null when no matching step label", () => {
|
||||
assert.strictEqual(detectStepRouting(["developer:senior", "Doing"], "review"), null);
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
assert.strictEqual(detectStepRouting(["Review:Human", "Doing"], "review"), "human");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReviewRouting", () => {
|
||||
it("should return review:human for HUMAN policy", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "junior"), "review:human");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "senior"), "review:human");
|
||||
});
|
||||
|
||||
it("should return review:agent for AGENT policy", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "junior"), "review:agent");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "senior"), "review:agent");
|
||||
});
|
||||
|
||||
it("should return review:human for AUTO + senior", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "senior"), "review:human");
|
||||
});
|
||||
|
||||
it("should return review:agent for AUTO + non-senior", () => {
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "junior"), "review:agent");
|
||||
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "medior"), "review:agent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,27 +10,31 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { DEFAULT_WORKFLOW, getStateLabels, findStateByLabel } from "../workflow.js";
|
||||
import { loadConfig } from "../config/index.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||
|
||||
export function createTaskUpdateTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_update",
|
||||
label: "Task Update",
|
||||
description: `Change issue state programmatically. Use this when you need to update an issue's status without going through the full pickup/complete flow.
|
||||
description: `Change issue state and/or role:level assignment. Use this when you need to update an issue's status or override the assigned level.
|
||||
|
||||
Use cases:
|
||||
- Orchestrator or worker needs to change state manually
|
||||
- Manual status adjustments (e.g., Planning → To Do after approval)
|
||||
- Override the assigned level (e.g., escalate to senior for human review)
|
||||
- Force human review via level change
|
||||
- Failed auto-transitions that need correction
|
||||
- Bulk state changes
|
||||
|
||||
Examples:
|
||||
- Simple: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
||||
- With reason: { projectGroupId: "-123456789", issueId: 42, state: "To Do", reason: "Approved for development" }`,
|
||||
- State only: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
||||
- Level only: { projectGroupId: "-123456789", issueId: 42, level: "senior" }
|
||||
- Both: { projectGroupId: "-123456789", issueId: 42, state: "To Do", level: "senior", reason: "Escalating to senior" }`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "issueId", "state"],
|
||||
required: ["projectGroupId", "issueId"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
@@ -42,12 +46,16 @@ Examples:
|
||||
},
|
||||
state: {
|
||||
type: "string",
|
||||
enum: STATE_LABELS,
|
||||
description: `New state for the issue. One of: ${STATE_LABELS.join(", ")}`,
|
||||
enum: getStateLabels(DEFAULT_WORKFLOW),
|
||||
description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`,
|
||||
},
|
||||
level: {
|
||||
type: "string",
|
||||
description: "Override the role:level assignment (e.g., 'senior', 'junior'). Detects role from current state label.",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Optional audit log reason for the state change",
|
||||
description: "Optional audit log reason for the change",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,41 +63,86 @@ Examples:
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const issueId = params.issueId as number;
|
||||
const newState = params.state as StateLabel;
|
||||
const newState = (params.state as StateLabel) ?? undefined;
|
||||
const newLevel = (params.level as string) ?? undefined;
|
||||
const reason = (params.reason as string) ?? undefined;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
if (!newState && !newLevel) {
|
||||
throw new Error("At least one of 'state' or 'level' must be provided.");
|
||||
}
|
||||
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
const { provider, type: providerType } = await resolveProvider(project);
|
||||
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const currentState = provider.getCurrentStateLabel(issue);
|
||||
if (!currentState) {
|
||||
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`);
|
||||
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform update.`);
|
||||
}
|
||||
|
||||
if (currentState === newState) {
|
||||
return jsonResult({
|
||||
success: true, issueId, state: newState, changed: false,
|
||||
message: `Issue #${issueId} is already in state "${newState}".`,
|
||||
project: project.name, provider: providerType,
|
||||
});
|
||||
let stateChanged = false;
|
||||
let levelChanged = false;
|
||||
let fromLevel: string | undefined;
|
||||
|
||||
// Handle state transition
|
||||
if (newState && currentState !== newState) {
|
||||
await provider.transitionLabel(issueId, currentState, newState);
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
await provider.transitionLabel(issueId, currentState, newState);
|
||||
// Handle level override
|
||||
if (newLevel) {
|
||||
// Detect role from current (or new) state label
|
||||
const effectiveState = newState ?? currentState;
|
||||
const workflow = (await loadConfig(workspaceDir, project.name)).workflow;
|
||||
const stateConfig = findStateByLabel(workflow, effectiveState);
|
||||
const role = stateConfig?.role;
|
||||
if (!role) {
|
||||
throw new Error(`Cannot determine role from state "${effectiveState}". Level can only be set on role-assigned states.`);
|
||||
}
|
||||
|
||||
// Validate level exists for role
|
||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||
const roleConfig = resolvedConfig.roles[role];
|
||||
if (!roleConfig || !roleConfig.levels.includes(newLevel)) {
|
||||
throw new Error(`Invalid level "${newLevel}" for role "${role}". Valid levels: ${roleConfig?.levels.join(", ") ?? "none"}`);
|
||||
}
|
||||
|
||||
// Remove old role:* labels, add new role:level
|
||||
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
|
||||
fromLevel = oldRoleLabels[0]?.split(":")[1];
|
||||
if (oldRoleLabels.length > 0) {
|
||||
await provider.removeLabels(issueId, oldRoleLabels);
|
||||
}
|
||||
await provider.addLabel(issueId, `${role}:${newLevel}`);
|
||||
levelChanged = fromLevel !== newLevel;
|
||||
}
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "task_update", {
|
||||
project: project.name, groupId, issueId,
|
||||
fromState: currentState, toState: newState,
|
||||
...(stateChanged ? { fromState: currentState, toState: newState } : {}),
|
||||
...(levelChanged ? { fromLevel: fromLevel ?? null, toLevel: newLevel } : {}),
|
||||
reason: reason ?? null, provider: providerType,
|
||||
});
|
||||
|
||||
// Build announcement
|
||||
const parts: string[] = [];
|
||||
if (stateChanged) parts.push(`"${currentState}" → "${newState}"`);
|
||||
if (levelChanged) parts.push(`level: ${fromLevel ?? "none"} → ${newLevel}`);
|
||||
const changeDesc = parts.join(", ");
|
||||
|
||||
return jsonResult({
|
||||
success: true, issueId, issueTitle: issue.title,
|
||||
state: newState, changed: true,
|
||||
labelTransition: `${currentState} → ${newState}`,
|
||||
...(newState ? { state: newState } : {}),
|
||||
...(newLevel ? { level: newLevel } : {}),
|
||||
changed: stateChanged || levelChanged,
|
||||
...(stateChanged ? { labelTransition: `${currentState} → ${newState}` } : {}),
|
||||
project: project.name, provider: providerType,
|
||||
announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`,
|
||||
announcement: stateChanged || levelChanged
|
||||
? `🔄 Updated #${issueId}: ${changeDesc}${reason ? ` (${reason})` : ""}`
|
||||
: `Issue #${issueId} is already in the requested state.`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,27 +3,33 @@
|
||||
*
|
||||
* Delegates side-effects to pipeline service: label transition, state update,
|
||||
* issue close/reopen, notifications, and audit logging.
|
||||
*
|
||||
* Roles without workflow states (e.g. architect) are handled inline —
|
||||
* deactivate worker, optionally transition label, and notify.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { getWorker, resolveRepoPath } from "../projects.js";
|
||||
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { deactivateWorker, getWorker, resolveRepoPath } from "../projects.js";
|
||||
import { executeCompletion, getRule } from "../services/pipeline.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
|
||||
import { loadWorkflow, hasWorkflowStates, getCompletionEmoji } from "../workflow.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "work_finish",
|
||||
label: "Work Finish",
|
||||
description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
|
||||
description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["role", "result", "projectGroupId"],
|
||||
properties: {
|
||||
role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked", "approve", "reject"], description: "Completion result" },
|
||||
projectGroupId: { type: "string", description: "Project group ID" },
|
||||
summary: { type: "string", description: "Brief summary" },
|
||||
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
|
||||
@@ -31,7 +37,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const role = params.role as "dev" | "qa" | "architect";
|
||||
const role = params.role as string;
|
||||
const result = params.result as string;
|
||||
const groupId = params.projectGroupId as string;
|
||||
const summary = params.summary as string | undefined;
|
||||
@@ -43,8 +49,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
const valid = getCompletionResults(role);
|
||||
throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`);
|
||||
}
|
||||
if (!getRule(role, result))
|
||||
throw new Error(`Invalid completion: ${role}:${result}`);
|
||||
|
||||
// Resolve project + worker
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
@@ -55,18 +59,31 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
|
||||
|
||||
const { provider } = await resolveProvider(project);
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
const issue = await provider.getIssue(issueId);
|
||||
const workflow = await loadWorkflow(workspaceDir, project.name);
|
||||
|
||||
// Roles without workflow states (e.g. architect) — handle inline
|
||||
if (!hasWorkflowStates(workflow, role)) {
|
||||
return handleStatelessCompletion({
|
||||
workspaceDir, groupId, role, result, issueId, summary,
|
||||
provider, projectName: project.name, channel: project.channel,
|
||||
pluginConfig: getPluginConfig(api), runtime: api.runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// Standard pipeline completion for roles with workflow states
|
||||
if (!getRule(role, result))
|
||||
throw new Error(`Invalid completion: ${role}:${result}`);
|
||||
|
||||
const repoPath = resolveRepoPath(project.repo);
|
||||
const pluginConfig = getPluginConfig(api);
|
||||
|
||||
// Execute completion (pipeline service handles notification with runtime)
|
||||
const completion = await executeCompletion({
|
||||
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
|
||||
projectName: project.name,
|
||||
channel: project.channel,
|
||||
pluginConfig,
|
||||
runtime: api.runtime,
|
||||
workflow,
|
||||
});
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
@@ -74,7 +91,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
...completion,
|
||||
};
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "work_finish", {
|
||||
project: project.name, groupId, issue: issueId, role, result,
|
||||
summary: summary ?? null, labelTransition: completion.labelTransition,
|
||||
@@ -84,3 +100,89 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle completion for roles without workflow states (e.g. architect).
|
||||
*
|
||||
* - done: deactivate worker, issue stays in current state (Planning)
|
||||
* - blocked: deactivate worker, transition issue to Refining
|
||||
*/
|
||||
async function handleStatelessCompletion(opts: {
|
||||
workspaceDir: string;
|
||||
groupId: string;
|
||||
role: string;
|
||||
result: string;
|
||||
issueId: number;
|
||||
summary?: string;
|
||||
provider: import("../providers/provider.js").IssueProvider;
|
||||
projectName: string;
|
||||
channel?: string;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
runtime?: import("openclaw/plugin-sdk").PluginRuntime;
|
||||
}): Promise<ReturnType<typeof jsonResult>> {
|
||||
const {
|
||||
workspaceDir, groupId, role, result, issueId, summary,
|
||||
provider, projectName, channel, pluginConfig, runtime,
|
||||
} = opts;
|
||||
|
||||
const issue = await provider.getIssue(issueId);
|
||||
|
||||
// Deactivate worker
|
||||
await deactivateWorker(workspaceDir, groupId, role);
|
||||
|
||||
// If blocked, transition to Refining
|
||||
let labelTransition = "none";
|
||||
if (result === "blocked") {
|
||||
const currentLabel = provider.getCurrentStateLabel(issue) ?? "Planning";
|
||||
await provider.transitionLabel(issueId, currentLabel as StateLabel, "Refining" as StateLabel);
|
||||
labelTransition = `${currentLabel} → Refining`;
|
||||
}
|
||||
|
||||
// Notification
|
||||
const nextState = result === "blocked" ? "awaiting human decision" : "awaiting human decision";
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
notify(
|
||||
{
|
||||
type: "workerComplete",
|
||||
project: projectName,
|
||||
groupId,
|
||||
issueId,
|
||||
issueUrl: issue.web_url,
|
||||
role,
|
||||
result: result as "done" | "blocked",
|
||||
summary,
|
||||
nextState,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
channel: channel ?? "telegram",
|
||||
runtime,
|
||||
},
|
||||
).catch((err) => {
|
||||
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||
});
|
||||
|
||||
// Build announcement
|
||||
const emoji = getCompletionEmoji(role, result);
|
||||
const label = `${role} ${result}`.toUpperCase();
|
||||
let announcement = `${emoji} ${label} #${issueId}`;
|
||||
if (summary) announcement += ` — ${summary}`;
|
||||
announcement += `\n📋 Issue: ${issue.web_url}`;
|
||||
if (result === "blocked") announcement += `\nawaiting human decision.`;
|
||||
|
||||
// Audit
|
||||
await auditLog(workspaceDir, "work_finish", {
|
||||
project: projectName, groupId, issue: issueId, role, result,
|
||||
summary: summary ?? null, labelTransition,
|
||||
});
|
||||
|
||||
return jsonResult({
|
||||
success: true, project: projectName, groupId, issueId, role, result,
|
||||
labelTransition,
|
||||
announcement,
|
||||
nextState,
|
||||
issueUrl: issue.web_url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,11 +12,10 @@ import type { StateLabel } from "../providers/provider.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { getWorker } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
||||
import { isDevLevel } from "../tiers.js";
|
||||
import { getAllRoleIds } from "../roles/index.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectRoleLevelFromLabels } from "../services/queue-scan.js";
|
||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
||||
import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js";
|
||||
|
||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
@@ -30,13 +29,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
projectGroupId: { type: "string", description: "Project group ID." },
|
||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
||||
role: { type: "string", enum: getAllRoleIds(), description: "Worker role. Auto-detected from label if omitted." },
|
||||
level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." },
|
||||
level: { type: "string", description: "Worker level (junior/mid/senior). Auto-detected if omitted." },
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const issueIdParam = params.issueId as number | undefined;
|
||||
const roleParam = params.role as "dev" | "qa" | "architect" | undefined;
|
||||
const roleParam = params.role as string | undefined;
|
||||
const groupId = params.projectGroupId as string;
|
||||
const levelParam = (params.level ?? params.tier) as string | undefined;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
@@ -45,8 +44,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
const { project } = await resolveProject(workspaceDir, groupId);
|
||||
const { provider } = await resolveProvider(project);
|
||||
|
||||
// TODO: Load per-project workflow when supported
|
||||
const workflow = DEFAULT_WORKFLOW;
|
||||
const workflow = await loadWorkflow(workspaceDir, project.name);
|
||||
|
||||
// Find issue
|
||||
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
|
||||
@@ -72,24 +70,27 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
// Check worker availability
|
||||
const worker = getWorker(project, role);
|
||||
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
||||
if ((project.roleExecution ?? "parallel") === "sequential") {
|
||||
const other = role === "dev" ? "qa" : "dev";
|
||||
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
|
||||
if ((project.roleExecution ?? ExecutionMode.PARALLEL) === ExecutionMode.SEQUENTIAL) {
|
||||
for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
|
||||
if (otherRole !== role && otherWorker.active) {
|
||||
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get target label from workflow
|
||||
const targetLabel = getActiveLabel(workflow, role);
|
||||
|
||||
// Select level
|
||||
// Select level: LLM param → own role label → inherit other role label → heuristic
|
||||
let selectedLevel: string, levelReason: string, levelSource: string;
|
||||
if (levelParam) {
|
||||
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
||||
} else {
|
||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||
if (labelLevel) {
|
||||
if (role === "qa" && isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = `QA overrides dev level "${labelLevel}"`; levelSource = "role-override"; }
|
||||
else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; }
|
||||
else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; }
|
||||
const roleLevel = detectRoleLevelFromLabels(issue.labels);
|
||||
if (roleLevel?.role === role) {
|
||||
selectedLevel = roleLevel.level; levelReason = `Label: "${role}:${roleLevel.level}"`; levelSource = "label";
|
||||
} else if (roleLevel && getLevelsForRole(role).includes(roleLevel.level)) {
|
||||
selectedLevel = roleLevel.level; levelReason = `Inherited from ${roleLevel.role}:${roleLevel.level}`; levelSource = "inherited";
|
||||
} else {
|
||||
const s = selectLevel(issue.title, issue.description ?? "", role);
|
||||
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
|
||||
|
||||
386
lib/workflow.ts
386
lib/workflow.ts
@@ -9,21 +9,74 @@
|
||||
*
|
||||
* All workflow behavior is derived from this config — no hardcoded state names.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StateType = "queue" | "active" | "hold" | "terminal";
|
||||
/** @deprecated Use WorkerRole from lib/roles/ */
|
||||
export type Role = "dev" | "qa" | "architect";
|
||||
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
|
||||
/** Built-in state types. */
|
||||
export const StateType = {
|
||||
QUEUE: "queue",
|
||||
ACTIVE: "active",
|
||||
HOLD: "hold",
|
||||
TERMINAL: "terminal",
|
||||
} as const;
|
||||
export type StateType = (typeof StateType)[keyof typeof StateType];
|
||||
|
||||
/** Built-in execution modes for role and project parallelism. */
|
||||
export const ExecutionMode = {
|
||||
PARALLEL: "parallel",
|
||||
SEQUENTIAL: "sequential",
|
||||
} as const;
|
||||
export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode];
|
||||
|
||||
/** Review policy for PR review after developer completion. */
|
||||
export const ReviewPolicy = {
|
||||
HUMAN: "human",
|
||||
AGENT: "agent",
|
||||
AUTO: "auto",
|
||||
} as const;
|
||||
export type ReviewPolicy = (typeof ReviewPolicy)[keyof typeof ReviewPolicy];
|
||||
|
||||
/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
|
||||
export type Role = string;
|
||||
/** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */
|
||||
export type TransitionAction = string;
|
||||
|
||||
/** Built-in transition actions. Custom actions are also valid — these are just the ones with built-in handlers. */
|
||||
export const Action = {
|
||||
GIT_PULL: "gitPull",
|
||||
DETECT_PR: "detectPr",
|
||||
MERGE_PR: "mergePr",
|
||||
CLOSE_ISSUE: "closeIssue",
|
||||
REOPEN_ISSUE: "reopenIssue",
|
||||
} as const;
|
||||
|
||||
/** Built-in review check types for review states. */
|
||||
export const ReviewCheck = {
|
||||
PR_APPROVED: "prApproved",
|
||||
PR_MERGED: "prMerged",
|
||||
} as const;
|
||||
export type ReviewCheckType = (typeof ReviewCheck)[keyof typeof ReviewCheck];
|
||||
|
||||
/** Built-in workflow events. */
|
||||
export const WorkflowEvent = {
|
||||
PICKUP: "PICKUP",
|
||||
COMPLETE: "COMPLETE",
|
||||
REVIEW: "REVIEW",
|
||||
APPROVED: "APPROVED",
|
||||
MERGE_FAILED: "MERGE_FAILED",
|
||||
PASS: "PASS",
|
||||
FAIL: "FAIL",
|
||||
REFINE: "REFINE",
|
||||
BLOCKED: "BLOCKED",
|
||||
APPROVE: "APPROVE",
|
||||
REJECT: "REJECT",
|
||||
} as const;
|
||||
|
||||
export type TransitionTarget = string | {
|
||||
target: string;
|
||||
actions?: TransitionAction[];
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type StateConfig = {
|
||||
@@ -32,21 +85,21 @@ export type StateConfig = {
|
||||
label: string;
|
||||
color: string;
|
||||
priority?: number;
|
||||
description?: string;
|
||||
check?: ReviewCheckType;
|
||||
on?: Record<string, TransitionTarget>;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
initial: string;
|
||||
reviewPolicy?: ReviewPolicy;
|
||||
states: Record<string, StateConfig>;
|
||||
};
|
||||
|
||||
export type CompletionRule = {
|
||||
from: string;
|
||||
to: string;
|
||||
gitPull?: boolean;
|
||||
detectPr?: boolean;
|
||||
closeIssue?: boolean;
|
||||
reopenIssue?: boolean;
|
||||
actions: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,88 +108,99 @@ export type CompletionRule = {
|
||||
|
||||
export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
||||
initial: "planning",
|
||||
reviewPolicy: ReviewPolicy.AUTO,
|
||||
states: {
|
||||
// ── Main pipeline (happy path) ──────────────────────────────
|
||||
planning: {
|
||||
type: "hold",
|
||||
type: StateType.HOLD,
|
||||
label: "Planning",
|
||||
color: "#95a5a6",
|
||||
on: { APPROVE: "todo" },
|
||||
on: { [WorkflowEvent.APPROVE]: "todo" },
|
||||
},
|
||||
todo: {
|
||||
type: "queue",
|
||||
role: "dev",
|
||||
type: StateType.QUEUE,
|
||||
role: "developer",
|
||||
label: "To Do",
|
||||
color: "#428bca",
|
||||
priority: 1,
|
||||
on: { PICKUP: "doing" },
|
||||
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||
},
|
||||
doing: {
|
||||
type: "active",
|
||||
role: "dev",
|
||||
type: StateType.ACTIVE,
|
||||
role: "developer",
|
||||
label: "Doing",
|
||||
color: "#f0ad4e",
|
||||
on: {
|
||||
COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] },
|
||||
BLOCKED: "refining",
|
||||
[WorkflowEvent.COMPLETE]: { target: "toReview", actions: [Action.DETECT_PR] },
|
||||
[WorkflowEvent.BLOCKED]: "refining",
|
||||
},
|
||||
},
|
||||
toReview: {
|
||||
type: StateType.QUEUE,
|
||||
role: "reviewer",
|
||||
label: "To Review",
|
||||
color: "#7057ff",
|
||||
priority: 2,
|
||||
check: ReviewCheck.PR_APPROVED,
|
||||
on: {
|
||||
[WorkflowEvent.PICKUP]: "reviewing",
|
||||
[WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] },
|
||||
[WorkflowEvent.MERGE_FAILED]: "toImprove",
|
||||
},
|
||||
},
|
||||
reviewing: {
|
||||
type: StateType.ACTIVE,
|
||||
role: "reviewer",
|
||||
label: "Reviewing",
|
||||
color: "#c5def5",
|
||||
on: {
|
||||
[WorkflowEvent.APPROVE]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] },
|
||||
[WorkflowEvent.REJECT]: "toImprove",
|
||||
[WorkflowEvent.BLOCKED]: "refining",
|
||||
},
|
||||
},
|
||||
toTest: {
|
||||
type: "queue",
|
||||
role: "qa",
|
||||
type: StateType.QUEUE,
|
||||
role: "tester",
|
||||
label: "To Test",
|
||||
color: "#5bc0de",
|
||||
priority: 2,
|
||||
on: { PICKUP: "testing" },
|
||||
on: { [WorkflowEvent.PICKUP]: "testing" },
|
||||
},
|
||||
testing: {
|
||||
type: "active",
|
||||
role: "qa",
|
||||
type: StateType.ACTIVE,
|
||||
role: "tester",
|
||||
label: "Testing",
|
||||
color: "#9b59b6",
|
||||
on: {
|
||||
PASS: { target: "done", actions: ["closeIssue"] },
|
||||
FAIL: { target: "toImprove", actions: ["reopenIssue"] },
|
||||
REFINE: "refining",
|
||||
BLOCKED: "refining",
|
||||
[WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] },
|
||||
[WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] },
|
||||
[WorkflowEvent.REFINE]: "refining",
|
||||
[WorkflowEvent.BLOCKED]: "refining",
|
||||
},
|
||||
},
|
||||
toImprove: {
|
||||
type: "queue",
|
||||
role: "dev",
|
||||
label: "To Improve",
|
||||
color: "#d9534f",
|
||||
priority: 3,
|
||||
on: { PICKUP: "doing" },
|
||||
},
|
||||
refining: {
|
||||
type: "hold",
|
||||
label: "Refining",
|
||||
color: "#f39c12",
|
||||
on: { APPROVE: "todo" },
|
||||
},
|
||||
done: {
|
||||
type: "terminal",
|
||||
type: StateType.TERMINAL,
|
||||
label: "Done",
|
||||
color: "#5cb85c",
|
||||
},
|
||||
toDesign: {
|
||||
type: "queue",
|
||||
role: "architect",
|
||||
label: "To Design",
|
||||
color: "#0075ca",
|
||||
priority: 1,
|
||||
on: { PICKUP: "designing" },
|
||||
|
||||
// ── Side paths (loops back into main pipeline) ──────────────
|
||||
toImprove: {
|
||||
type: StateType.QUEUE,
|
||||
role: "developer",
|
||||
label: "To Improve",
|
||||
color: "#d9534f",
|
||||
priority: 3,
|
||||
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||
},
|
||||
designing: {
|
||||
type: "active",
|
||||
role: "architect",
|
||||
label: "Designing",
|
||||
color: "#d4c5f9",
|
||||
on: {
|
||||
COMPLETE: "planning",
|
||||
BLOCKED: "refining",
|
||||
},
|
||||
refining: {
|
||||
type: StateType.HOLD,
|
||||
label: "Refining",
|
||||
color: "#f39c12",
|
||||
on: { [WorkflowEvent.APPROVE]: "todo" },
|
||||
},
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
@@ -146,38 +210,15 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
||||
|
||||
/**
|
||||
* Load workflow config for a project.
|
||||
* Priority: project-specific → workspace default → built-in default
|
||||
* Delegates to loadConfig() which handles the three-layer merge.
|
||||
*/
|
||||
export async function loadWorkflow(
|
||||
workspaceDir: string,
|
||||
_groupId?: string,
|
||||
projectName?: string,
|
||||
): Promise<WorkflowConfig> {
|
||||
// TODO: Support per-project overrides from projects.json when needed
|
||||
// For now, try workspace-level config, fall back to default
|
||||
|
||||
const workflowPath = path.join(workspaceDir, "projects", "workflow.json");
|
||||
try {
|
||||
const content = await fs.readFile(workflowPath, "utf-8");
|
||||
const parsed = JSON.parse(content) as { workflow?: WorkflowConfig };
|
||||
if (parsed.workflow) {
|
||||
return mergeWorkflow(DEFAULT_WORKFLOW, parsed.workflow);
|
||||
}
|
||||
} catch {
|
||||
// No custom workflow, use default
|
||||
}
|
||||
|
||||
return DEFAULT_WORKFLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge custom workflow config over defaults.
|
||||
* Custom states are merged, not replaced entirely.
|
||||
*/
|
||||
function mergeWorkflow(base: WorkflowConfig, custom: Partial<WorkflowConfig>): WorkflowConfig {
|
||||
return {
|
||||
initial: custom.initial ?? base.initial,
|
||||
states: { ...base.states, ...custom.states },
|
||||
};
|
||||
const { loadConfig } = await import("./config/loader.js");
|
||||
const config = await loadConfig(workspaceDir, projectName);
|
||||
return config.workflow;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -202,12 +243,89 @@ export function getLabelColors(workflow: WorkflowConfig): Record<string, string>
|
||||
return colors;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role:level labels — dynamic from config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Step routing label values — per-issue overrides for workflow steps. */
|
||||
export const StepRouting = {
|
||||
HUMAN: "human",
|
||||
AGENT: "agent",
|
||||
SKIP: "skip",
|
||||
} as const;
|
||||
export type StepRoutingValue = (typeof StepRouting)[keyof typeof StepRouting];
|
||||
|
||||
/** Known step routing labels (created on the provider during project registration). */
|
||||
export const STEP_ROUTING_LABELS: readonly string[] = [
|
||||
"review:human", "review:agent", "review:skip",
|
||||
"test:skip",
|
||||
];
|
||||
|
||||
/** Step routing label color. */
|
||||
const STEP_ROUTING_COLOR = "#d93f0b";
|
||||
|
||||
/**
|
||||
* Determine review routing label for an issue based on project policy and developer level.
|
||||
* Called during developer dispatch to persist the routing decision as a label.
|
||||
*/
|
||||
export function resolveReviewRouting(
|
||||
policy: ReviewPolicy, level: string,
|
||||
): "review:human" | "review:agent" {
|
||||
if (policy === ReviewPolicy.HUMAN) return "review:human";
|
||||
if (policy === ReviewPolicy.AGENT) return "review:agent";
|
||||
// AUTO: senior → human, else agent
|
||||
return level === "senior" ? "review:human" : "review:agent";
|
||||
}
|
||||
|
||||
/** Default colors per role for role:level labels. */
|
||||
const ROLE_LABEL_COLORS: Record<string, string> = {
|
||||
developer: "#0e8a16",
|
||||
tester: "#5319e7",
|
||||
architect: "#0075ca",
|
||||
reviewer: "#d93f0b",
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate all role:level label definitions from resolved config roles.
|
||||
* Returns array of { name, color } for label creation (e.g. "developer:junior").
|
||||
*/
|
||||
export function getRoleLabels(
|
||||
roles: Record<string, { levels: string[]; enabled?: boolean }>,
|
||||
): Array<{ name: string; color: string }> {
|
||||
const labels: Array<{ name: string; color: string }> = [];
|
||||
for (const [roleId, role] of Object.entries(roles)) {
|
||||
if (role.enabled === false) continue;
|
||||
for (const level of role.levels) {
|
||||
labels.push({
|
||||
name: `${roleId}:${level}`,
|
||||
color: getRoleLabelColor(roleId),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Step routing labels (review:human, review:agent, test:skip, etc.)
|
||||
for (const routingLabel of STEP_ROUTING_LABELS) {
|
||||
labels.push({ name: routingLabel, color: STEP_ROUTING_COLOR });
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the label color for a role. Falls back to gray for unknown roles.
|
||||
*/
|
||||
export function getRoleLabelColor(role: string): string {
|
||||
return ROLE_LABEL_COLORS[role] ?? "#cccccc";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get queue labels for a role, ordered by priority (highest first).
|
||||
*/
|
||||
export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
||||
return Object.values(workflow.states)
|
||||
.filter((s) => s.type === "queue" && s.role === role)
|
||||
.filter((s) => s.type === StateType.QUEUE && s.role === role)
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
.map((s) => s.label);
|
||||
}
|
||||
@@ -217,7 +335,7 @@ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
||||
*/
|
||||
export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
||||
return Object.values(workflow.states)
|
||||
.filter((s) => s.type === "queue")
|
||||
.filter((s) => s.type === StateType.QUEUE)
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
.map((s) => s.label);
|
||||
}
|
||||
@@ -227,7 +345,7 @@ export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
||||
*/
|
||||
export function getActiveLabel(workflow: WorkflowConfig, role: Role): string {
|
||||
const state = Object.values(workflow.states).find(
|
||||
(s) => s.type === "active" && s.role === role,
|
||||
(s) => s.type === StateType.ACTIVE && s.role === role,
|
||||
);
|
||||
if (!state) throw new Error(`No active state for role "${role}"`);
|
||||
return state.label;
|
||||
@@ -245,8 +363,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
||||
|
||||
// Find queue states that transition to this active state
|
||||
for (const [, state] of Object.entries(workflow.states)) {
|
||||
if (state.type !== "queue" || state.role !== role) continue;
|
||||
const pickup = state.on?.PICKUP;
|
||||
if (state.type !== StateType.QUEUE || state.role !== role) continue;
|
||||
const pickup = state.on?.[WorkflowEvent.PICKUP];
|
||||
if (pickup === activeStateKey) {
|
||||
return state.label;
|
||||
}
|
||||
@@ -261,7 +379,7 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
||||
*/
|
||||
export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null {
|
||||
for (const state of Object.values(workflow.states)) {
|
||||
if (state.label === label && state.type === "queue" && state.role) {
|
||||
if (state.label === label && state.type === StateType.QUEUE && state.role) {
|
||||
return state.role;
|
||||
}
|
||||
}
|
||||
@@ -273,7 +391,7 @@ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Ro
|
||||
*/
|
||||
export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||
return Object.values(workflow.states).some(
|
||||
(s) => s.label === label && s.type === "queue",
|
||||
(s) => s.label === label && s.type === StateType.QUEUE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,7 +400,7 @@ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||
*/
|
||||
export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||
return Object.values(workflow.states).some(
|
||||
(s) => s.label === label && s.type === "active",
|
||||
(s) => s.label === label && s.type === StateType.ACTIVE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,36 +418,43 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st
|
||||
return Object.entries(workflow.states).find(([, s]) => s.label === label)?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role has any workflow states (queue, active, etc.).
|
||||
* Roles without workflow states (e.g. architect) are dispatched by tool only.
|
||||
*/
|
||||
export function hasWorkflowStates(workflow: WorkflowConfig, role: Role): boolean {
|
||||
return Object.values(workflow.states).some((s) => s.role === role);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion rules — derived from transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map role:result to completion event name.
|
||||
* Map completion result to workflow transition event name.
|
||||
* Convention: "done" → COMPLETE, others → uppercase.
|
||||
*/
|
||||
const RESULT_TO_EVENT: Record<string, string> = {
|
||||
"dev:done": "COMPLETE",
|
||||
"dev:blocked": "BLOCKED",
|
||||
"qa:pass": "PASS",
|
||||
"qa:fail": "FAIL",
|
||||
"qa:refine": "REFINE",
|
||||
"qa:blocked": "BLOCKED",
|
||||
"architect:done": "COMPLETE",
|
||||
"architect:blocked": "BLOCKED",
|
||||
};
|
||||
function resultToEvent(result: string): string {
|
||||
if (result === "done") return WorkflowEvent.COMPLETE;
|
||||
return result.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completion rule for a role:result pair.
|
||||
* Derives entirely from workflow transitions — no hardcoded role:result mapping.
|
||||
*/
|
||||
export function getCompletionRule(
|
||||
workflow: WorkflowConfig,
|
||||
role: Role,
|
||||
result: string,
|
||||
): CompletionRule | null {
|
||||
const event = RESULT_TO_EVENT[`${role}:${result}`];
|
||||
if (!event) return null;
|
||||
const event = resultToEvent(result);
|
||||
|
||||
let activeLabel: string;
|
||||
try {
|
||||
activeLabel = getActiveLabel(workflow, role);
|
||||
} catch { return null; }
|
||||
|
||||
const activeLabel = getActiveLabel(workflow, role);
|
||||
const activeKey = findStateKeyByLabel(workflow, activeLabel);
|
||||
if (!activeKey) return null;
|
||||
|
||||
@@ -347,15 +472,13 @@ export function getCompletionRule(
|
||||
return {
|
||||
from: activeLabel,
|
||||
to: targetState.label,
|
||||
gitPull: actions?.includes("gitPull"),
|
||||
detectPr: actions?.includes("detectPr"),
|
||||
closeIssue: actions?.includes("closeIssue"),
|
||||
reopenIssue: actions?.includes("reopenIssue"),
|
||||
actions: actions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable next state description.
|
||||
* Derives from target state type — no hardcoded role names.
|
||||
*/
|
||||
export function getNextStateDescription(
|
||||
workflow: WorkflowConfig,
|
||||
@@ -365,15 +488,13 @@ export function getNextStateDescription(
|
||||
const rule = getCompletionRule(workflow, role, result);
|
||||
if (!rule) return "";
|
||||
|
||||
// Find the target state to determine the description
|
||||
const targetState = findStateByLabel(workflow, rule.to);
|
||||
if (!targetState) return "";
|
||||
|
||||
if (targetState.type === "terminal") return "Done!";
|
||||
if (targetState.type === "hold") return "awaiting human decision";
|
||||
if (targetState.type === "queue") {
|
||||
if (targetState.role === "qa") return "QA queue";
|
||||
if (targetState.role === "dev") return "back to DEV";
|
||||
if (targetState.type === StateType.TERMINAL) return "Done!";
|
||||
if (targetState.type === StateType.HOLD) return "awaiting human decision";
|
||||
if (targetState.type === StateType.QUEUE && targetState.role) {
|
||||
return `${targetState.role.toUpperCase()} queue`;
|
||||
}
|
||||
|
||||
return rule.to;
|
||||
@@ -381,19 +502,20 @@ export function getNextStateDescription(
|
||||
|
||||
/**
|
||||
* Get emoji for a completion result.
|
||||
* Keyed by result name — role-independent.
|
||||
*/
|
||||
export function getCompletionEmoji(role: Role, result: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"dev:done": "✅",
|
||||
"qa:pass": "🎉",
|
||||
"qa:fail": "❌",
|
||||
"qa:refine": "🤔",
|
||||
"dev:blocked": "🚫",
|
||||
"qa:blocked": "🚫",
|
||||
"architect:done": "🏗️",
|
||||
"architect:blocked": "🚫",
|
||||
};
|
||||
return map[`${role}:${result}`] ?? "📋";
|
||||
const RESULT_EMOJI: Record<string, string> = {
|
||||
done: "✅",
|
||||
pass: "🎉",
|
||||
fail: "❌",
|
||||
refine: "🤔",
|
||||
blocked: "🚫",
|
||||
approve: "✅",
|
||||
reject: "❌",
|
||||
};
|
||||
|
||||
export function getCompletionEmoji(_role: Role, result: string): string {
|
||||
return RESULT_EMOJI[result] ?? "📋";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user