From a359ffed34a44638e4315eed8f9351e166a95356 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 15 Feb 2026 20:19:09 +0800 Subject: [PATCH] feat(migration): implement workspace layout migration and testing - Added `migrate-layout.ts` to handle migration from old workspace layouts to the new `devclaw/` structure. - Introduced `migrate-layout.test.ts` for comprehensive tests covering various migration scenarios. - Updated `workspace.ts` to ensure default files are created post-migration, including `workflow.yaml` and role-specific prompts. - Refactored role instruction handling to accommodate new directory structure. - Enhanced project registration to scaffold prompt files in the new `devclaw/projects//prompts/` directory. - Adjusted setup tool descriptions and logic to reflect changes in file structure. - Updated templates to align with the new workflow configuration and role instructions. --- index.ts | 23 -- lib/audit.ts | 3 +- lib/bootstrap-hook.test.ts | 36 ++- lib/bootstrap-hook.ts | 31 ++- lib/config/index.ts | 2 +- lib/config/loader.ts | 54 +++-- lib/config/types.ts | 6 +- lib/dispatch.ts | 2 +- lib/onboarding.ts | 24 +- lib/projects.test.ts | 26 +-- lib/projects.ts | 4 +- lib/roles/registry.test.ts | 32 ++- lib/roles/selectors.ts | 21 +- lib/services/heartbeat.ts | 16 +- lib/setup/config.ts | 16 +- lib/setup/index.ts | 46 +++- lib/setup/migrate-layout.test.ts | 312 ++++++++++++++++++++++++++ lib/setup/migrate-layout.ts | 365 +++++++++++++++++++++++++++++++ lib/setup/workspace.ts | 93 ++++---- lib/templates.ts | 108 ++++++++- lib/tools/design-task.test.ts | 6 +- lib/tools/design-task.ts | 5 +- lib/tools/onboard.ts | 2 +- lib/tools/project-register.ts | 7 +- lib/tools/setup.ts | 2 +- 25 files changed, 1035 insertions(+), 207 deletions(-) create mode 100644 lib/setup/migrate-layout.test.ts create mode 100644 lib/setup/migrate-layout.ts diff --git a/index.ts b/index.ts index d8eebb9..d646a1f 100644 --- a/index.ts +++ b/index.ts @@ -15,28 +15,6 @@ import { registerCli } from "./lib/cli.js"; import { registerHeartbeatService } from "./lib/services/heartbeat.js"; import { registerBootstrapHook } from "./lib/bootstrap-hook.js"; import { initRunCommand } from "./lib/run-command.js"; -import { ROLE_REGISTRY } from "./lib/roles/index.js"; - -/** Build the models config schema dynamically from the role registry. */ -function buildModelsSchema(): Record { - const properties: Record = {}; - for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) { - const levelProps: Record = {}; - for (const level of config.levels) { - levelProps[level] = { type: "string" }; - } - properties[roleId] = { - type: "object", - description: `${config.displayName} level models`, - properties: levelProps, - }; - } - return { - type: "object", - description: "Model mapping per role and level", - properties, - }; -} const plugin = { id: "devclaw", @@ -46,7 +24,6 @@ const plugin = { configSchema: { type: "object", properties: { - models: buildModelsSchema(), projectExecution: { type: "string", enum: ["parallel", "sequential"], diff --git a/lib/audit.ts b/lib/audit.ts index 512dfee..64040dd 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -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, ): Promise { - 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, diff --git a/lib/bootstrap-hook.test.ts b/lib/bootstrap-hook.test.ts index d6ac418..faacee5 100644 --- a/lib/bootstrap-hook.test.ts +++ b/lib/bootstrap-hook.test.ts @@ -57,9 +57,9 @@ describe("parseDevClawSessionKey", () => { }); describe("loadRoleInstructions", () => { - it("should load project-specific instructions", async () => { + it("should load project-specific instructions from devclaw/projects//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, "developer.md"), "# Developer Instructions\nDo the thing."); @@ -69,11 +69,11 @@ describe("loadRoleInstructions", () => { 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, "tester.md"), "# Tester 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", "tester"); assert.strictEqual(result, "# Tester Default\nReview carefully."); @@ -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, "developer.md"), "Project-specific instructions"); - await fs.writeFile(path.join(defaultDir, "developer.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", "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 }); + }); }); diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index f81a118..d9f2806 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -11,6 +11,7 @@ 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. @@ -44,18 +45,24 @@ export async function loadRoleInstructions( projectName: string, role: string, ): Promise { - 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 */ - } + const dataDir = path.join(workspaceDir, DATA_DIR); + + // Project-specific: devclaw/projects//prompts/.md + const projectFile = path.join(dataDir, "projects", projectName, "prompts", `${role}.md`); + try { return await fs.readFile(projectFile, "utf-8"); } catch { /* not found */ } + + // Fallback old path: projects/roles//.md + const oldProjectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); + try { return await fs.readFile(oldProjectFile, "utf-8"); } catch { /* not found */ } + + // Default: devclaw/prompts/.md + const defaultFile = path.join(dataDir, "prompts", `${role}.md`); + try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* not found */ } + + // Fallback old default: projects/roles/default/.md + const oldDefaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`); + try { return await fs.readFile(oldDefaultFile, "utf-8"); } catch { /* not found */ } + return ""; } diff --git a/lib/config/index.ts b/lib/config/index.ts index 3e9b285..94345be 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -1,7 +1,7 @@ /** * config/ — Unified DevClaw configuration. * - * Single config.yaml per workspace/project combining roles, models, and workflow. + * Single workflow.yaml per workspace/project combining roles, models, and workflow. */ export type { DevClawConfig, diff --git a/lib/config/loader.ts b/lib/config/loader.ts index c0c883c..60eabc2 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -3,10 +3,10 @@ * * Resolution order: * 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW) - * 2. Workspace: /projects/config.yaml - * 3. Project: /projects//config.yaml + * 2. Workspace: /devclaw/workflow.yaml + * 3. Project: /devclaw/projects//workflow.yaml * - * Also supports legacy workflow.yaml files (merged into the workflow section). + * Also supports legacy config.yaml and workflow.json for backward compat. */ import fs from "node:fs/promises"; import path from "node:path"; @@ -15,48 +15,52 @@ 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, RoleOverride } from "./types.js"; +import { DATA_DIR } from "../setup/migrate-layout.js"; /** * Load and resolve the full DevClaw config for a project. * - * Merges: built-in → workspace config.yaml → project config.yaml. - * Also picks up legacy workflow.yaml files if no workflow section in config.yaml. + * Merges: built-in → workspace workflow.yaml → project workflow.yaml. */ export async function loadConfig( workspaceDir: string, projectName?: string, ): Promise { - const projectsDir = path.join(workspaceDir, "projects"); + const dataDir = path.join(workspaceDir, DATA_DIR); + const projectsDir = path.join(dataDir, "projects"); // Layer 1: built-in defaults const builtIn = buildDefaultConfig(); - // Layer 2: workspace config.yaml + // Layer 2: workspace workflow.yaml (in devclaw/ data dir) let merged = builtIn; - const workspaceConfig = await readConfigFile(projectsDir); + const workspaceConfig = + await readWorkflowFile(dataDir) ?? + await readLegacyConfigFile(path.join(workspaceDir, "projects")); if (workspaceConfig) { merged = mergeConfig(merged, workspaceConfig); } - // Legacy: workspace workflow.yaml (only if no workflow in config.yaml) + // Legacy: standalone workflow.json (only if no workflow section found) if (!workspaceConfig?.workflow) { - const legacyWorkflow = await readWorkflowYaml(projectsDir); + const legacyWorkflow = await readLegacyWorkflowJson(projectsDir); if (legacyWorkflow) { merged = mergeConfig(merged, { workflow: legacyWorkflow }); } } - // Layer 3: project config.yaml + // Layer 3: project workflow.yaml if (projectName) { const projectDir = path.join(projectsDir, projectName); - const projectConfig = await readConfigFile(projectDir); + const projectConfig = + await readWorkflowFile(projectDir) ?? + await readLegacyConfigFile(projectDir); if (projectConfig) { merged = mergeConfig(merged, projectConfig); } - // Legacy: project workflow.yaml if (!projectConfig?.workflow) { - const legacyWorkflow = await readWorkflowYaml(projectDir); + const legacyWorkflow = await readLegacyWorkflowJson(projectDir); if (legacyWorkflow) { merged = mergeConfig(merged, { workflow: legacyWorkflow }); } @@ -143,7 +147,17 @@ function resolve(config: DevClawConfig): ResolvedConfig { // File reading helpers // --------------------------------------------------------------------------- -async function readConfigFile(dir: string): Promise { +/** Read workflow.yaml (new primary config file). */ +async function readWorkflowFile(dir: string): Promise { + try { + const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); + return YAML.parse(content) as DevClawConfig; + } catch { /* not found */ } + return null; +} + +/** Read config.yaml (old name, fallback for unmigrated workspaces). */ +async function readLegacyConfigFile(dir: string): Promise { try { const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8"); return YAML.parse(content) as DevClawConfig; @@ -151,13 +165,8 @@ async function readConfigFile(dir: string): Promise { return null; } -async function readWorkflowYaml(dir: string): Promise | null> { - try { - const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); - return YAML.parse(content) as Partial; - } catch { /* not found */ } - - // Legacy JSON fallback +/** Read legacy workflow.json (standalone workflow section only). */ +async function readLegacyWorkflowJson(dir: string): Promise | null> { try { const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8"); const parsed = JSON.parse(content) as @@ -165,6 +174,5 @@ async function readWorkflowYaml(dir: string): Promise | | { workflow?: Partial }; return (parsed as any).workflow ?? parsed; } catch { /* not found */ } - return null; } diff --git a/lib/config/types.ts b/lib/config/types.ts index da48d31..35ab24f 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -1,13 +1,13 @@ /** * config/types.ts — Types for the unified DevClaw configuration. * - * A single config.yaml combines roles, models, and workflow. + * 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 config.yaml. All fields optional — only override what you need. + * 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 = { @@ -19,7 +19,7 @@ export type RoleOverride = { }; /** - * The full config.yaml shape. + * The full workflow.yaml shape. * All fields optional — missing fields inherit from the layer below. */ export type DevClawConfig = { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 08bacce..c5644b6 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -151,7 +151,7 @@ export async function dispatchTask( const resolvedConfig = await loadConfig(workspaceDir, project.name); const resolvedRole = resolvedConfig.roles[role]; - const model = resolveModel(role, level, pluginConfig, resolvedRole); + const model = resolveModel(role, level, resolvedRole); const worker = getWorker(project, role); const existingSessionKey = getSessionForLevel(worker, level); const sessionAction = existingSessionKey ? "send" : "spawn"; diff --git a/lib/onboarding.ts b/lib/onboarding.ts index 5676eea..1604f0e 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -14,8 +14,8 @@ import { getAllDefaultModels } from "./roles/index.js"; export function isPluginConfigured( pluginConfig?: Record, ): boolean { - const models = (pluginConfig as { models?: Record })?.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,30 +37,26 @@ export async function hasWorkspaceFiles( // Context templates // --------------------------------------------------------------------------- -function buildModelTable(pluginConfig?: Record): string { - const cfg = (pluginConfig as { models?: Record> })?.models; +function buildModelTable(): string { const lines: string[] = []; for (const [role, levels] of Object.entries(getAllDefaultModels())) { - for (const [level, defaultModel] of Object.entries(levels)) { - const model = cfg?.[role]?.[level] || defaultModel; - lines.push( - ` - **${role} ${level}**: ${model} (default: ${defaultModel})`, - ); + for (const [level, model] of Object.entries(levels)) { + lines.push(` - **${role} ${level}**: ${model}`); } } return lines.join("\n"); } -export function buildReconfigContext( - pluginConfig?: Record, -): 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) diff --git a/lib/projects.test.ts b/lib/projects.test.ts index 4a9b471..4f5e0cc 100644 --- a/lib/projects.test.ts +++ b/lib/projects.test.ts @@ -95,8 +95,8 @@ describe("readProjects migration", () => { it("should read new format (workers map) correctly", 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 dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); const newFormat = { projects: { @@ -114,7 +114,7 @@ describe("readProjects migration", () => { }, }, }; - await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(newFormat), "utf-8"); + await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(newFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; @@ -129,8 +129,8 @@ describe("readProjects migration", () => { it("should migrate old worker keys in new 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 dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); // Workers map but with old role keys const mixedFormat = { @@ -149,7 +149,7 @@ describe("readProjects migration", () => { }, }, }; - await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8"); + await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; @@ -165,11 +165,7 @@ describe("readProjects migration", () => { }); describe("getWorker", () => { - it("should return worker from 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 }); - + it("should return worker from workers map", () => { const data: ProjectsData = { projects: { "g1": { @@ -189,11 +185,9 @@ describe("getWorker", () => { const worker = getWorker(data.projects["g1"], "developer"); assert.strictEqual(worker.active, true); assert.strictEqual(worker.issueId, "5"); - - await fs.rm(tmpDir, { recursive: true }); }); - it("should return empty worker for unknown role", async () => { + it("should return empty worker for unknown role", () => { const data: ProjectsData = { projects: { "g1": { @@ -217,8 +211,8 @@ describe("getWorker", () => { 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 projDir = path.join(tmpDir, "projects"); - await fs.mkdir(projDir, { recursive: true }); + const dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); const data: ProjectsData = { projects: { diff --git a/lib/projects.ts b/lib/projects.ts index 5eb99d9..bc3d7d8 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -6,6 +6,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { homedir } from "node:os"; import { migrateProject } from "./migrations.js"; +import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js"; export type WorkerState = { active: boolean; @@ -66,10 +67,11 @@ export function getSessionForLevel( } function projectsPath(workspaceDir: string): string { - return path.join(workspaceDir, "projects", "projects.json"); + return path.join(workspaceDir, DATA_DIR, "projects.json"); } export async function readProjects(workspaceDir: string): Promise { + await ensureWorkspaceMigrated(workspaceDir); const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8"); const data = JSON.parse(raw) as ProjectsData; diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index b1cd9c9..b8a6536 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -140,9 +140,9 @@ describe("models", () => { assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5"); }); - it("should resolve from config override", () => { - const config = { models: { developer: { junior: "custom/model" } } }; - assert.strictEqual(resolveModel("developer", "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", () => { @@ -153,23 +153,19 @@ describe("models", () => { assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6"); }); - it("should resolve old config keys via aliases", () => { - // Old config uses "mid" key — should still resolve via alias - const config = { models: { developer: { mid: "custom/old-config-model" } } }; - assert.strictEqual(resolveModel("developer", "mid", config), "custom/old-config-model"); - // Also works when requesting the canonical name - assert.strictEqual(resolveModel("developer", "medior", {}), "anthropic/claude-sonnet-4-5"); + 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 old role name config keys", () => { - // Old config uses "dev" role key — should still resolve via role alias - const config = { models: { dev: { junior: "custom/model" } } }; - assert.strictEqual(resolveModel("developer", "junior", config), "custom/model"); - }); - - it("should resolve old qa config keys", () => { - const config = { models: { qa: { reviewer: "custom/qa-model" } } }; - assert.strictEqual(resolveModel("tester", "reviewer", config), "custom/qa-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"); }); }); diff --git a/lib/roles/selectors.ts b/lib/roles/selectors.ts index aa6191b..ed853b6 100644 --- a/lib/roles/selectors.ts +++ b/lib/roles/selectors.ts @@ -98,32 +98,21 @@ export function getAllDefaultModels(): Record> { * Resolve a level to a full model ID. * * Resolution order: - * 1. Plugin config `models..` in openclaw.json (highest precedence) - * 2. Resolved config from config.yaml (if provided) - * 3. Registry default model - * 4. Passthrough (treat level as raw model ID) + * 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, resolvedRole?: ResolvedRoleConfig, ): string { const canonical = _canonicalLevel(role, level); - // 1. Plugin config override (openclaw.json) — check canonical role + old aliases - const models = (pluginConfig as { models?: Record })?.models; - if (models && typeof models === "object") { - // Check canonical role name, then fall back to old aliases (e.g., "dev" for "developer") - const roleModels = (models[role] ?? models[Object.entries(_ROLE_ALIASES).find(([, v]) => v === role)?.[0] ?? ""]) as Record | undefined; - if (roleModels?.[canonical]) return roleModels[canonical]; - if (roleModels?.[level]) return roleModels[level]; - } - - // 2. Resolved config (config.yaml) + // 1. Resolved config (workflow.yaml — includes workspace + project overrides) if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical]; - // 3. Built-in registry default + // 2. Built-in registry default return getDefaultModel(role, canonical) ?? canonical; } diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index 94d45f9..7083d70 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -15,6 +15,7 @@ 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 { createProvider } from "../providers/index.js"; @@ -115,7 +116,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: { @@ -131,7 +132,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); } @@ -142,7 +143,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 */ } @@ -151,6 +152,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. */ diff --git a/lib/setup/config.ts b/lib/setup/config.ts index ab74ae4..a112f87 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -1,37 +1,39 @@ /** * 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 = Record>; - /** - * 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", ): Promise { const config = api.runtime.config.loadConfig() as Record; 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; + ensureHeartbeatDefaults(config); configureSubagentCleanup(config); diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 08a541a..4766585 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -1,15 +1,19 @@ /** * 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 { 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"; export type ModelConfig = Record>; @@ -49,8 +53,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 { const warnings: string[] = []; @@ -58,11 +63,13 @@ export async function runSetup(opts: SetupOpts): Promise { 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 models = buildModelConfig(opts.models); + await writeModelsToWorkflow(workspacePath, models); + return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated }; } @@ -131,3 +138,32 @@ function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig { return result; } + +/** + * 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 { + const workflowPath = path.join(workspacePath, DATA_DIR, "workflow.yaml"); + + let doc: Record = {}; + try { + const content = await fs.readFile(workflowPath, "utf-8"); + doc = (YAML.parse(content) as Record) ?? {}; + } catch { /* file doesn't exist yet — start fresh */ } + + // Merge models into roles section + if (!doc.roles) doc.roles = {}; + const roles = doc.roles as Record; + + 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; + roleObj.models = levels; + } + } + + await fs.writeFile(workflowPath, YAML.stringify(doc, { lineWidth: 120 }), "utf-8"); +} diff --git a/lib/setup/migrate-layout.test.ts b/lib/setup/migrate-layout.test.ts new file mode 100644 index 0000000..8c25fb7 --- /dev/null +++ b/lib/setup/migrate-layout.test.ts @@ -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 { + 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//* to devclaw/projects//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//config.yaml to devclaw/projects//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 }); + }); +}); diff --git a/lib/setup/migrate-layout.ts b/lib/setup/migrate-layout.ts new file mode 100644 index 0000000..1b03071 --- /dev/null +++ b/lib/setup/migrate-layout.ts @@ -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//* → devclaw/projects//prompts/* + * projects//config.yaml → devclaw/projects//workflow.yaml + * + * Intermediate layout (post-restructure, pre-devclaw/): + * projects.json → devclaw/projects.json + * workflow.yaml → devclaw/workflow.yaml + * prompts/* → devclaw/prompts/* + * projects//*.md → devclaw/projects//prompts/* + * projects//workflow.yaml→ devclaw/projects//workflow.yaml + * log/* → devclaw/log/* + * + * Flat project layout (early devclaw/ without prompts subdir): + * devclaw/projects//*.md → devclaw/projects//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 = { + "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(); + +/** + * 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 { + 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 { + 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//.md → devclaw/projects//prompts/.md + */ +async function migratePromptSubdirs(dataDir: string): Promise { + 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 { + 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 { + 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//* → devclaw/projects//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//config.yaml → devclaw/projects//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 { + try { await fs.access(p); return true; } catch { return false; } +} + +async function dirExists(p: string): Promise { + try { + const stat = await fs.stat(p); + return stat.isDirectory(); + } catch { return false; } +} + +async function safeCopy(src: string, dest: string): Promise { + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.copyFile(src, dest); +} + +async function rmEmptyDir(dir: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/lib/setup/workspace.ts b/lib/setup/workspace.ts index ce86b61..dbbf651 100644 --- a/lib/setup/workspace.ts +++ b/lib/setup/workspace.ts @@ -1,66 +1,77 @@ /** * 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, + 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 { + 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). */ export async function scaffoldWorkspace(workspacePath: string): Promise { - const filesWritten: string[] = []; + // Migrate old layout if detected + await migrateWorkspaceLayout(workspacePath); - // AGENTS.md (backup existing) + // AGENTS.md (backup existing — stays at workspace root) await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE); - filesWritten.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"); - // 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"); - } + // Ensure all data-dir defaults (workflow.yaml, prompts, etc.) + await ensureDefaultFiles(workspacePath); - // 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"); - } - - // log/ directory (audit.log created on first write) - const logDir = path.join(workspacePath, "log"); - await fs.mkdir(logDir, { recursive: true }); - - return filesWritten; + return ["AGENTS.md", "HEARTBEAT.md"]; } // --------------------------------------------------------------------------- diff --git a/lib/templates.ts b/lib/templates.ts index e15eaf9..8bf5d80 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -266,7 +266,7 @@ Workers call \`work_finish\` themselves — the label transition, state update, ### Prompt Instructions -Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/roles//.md\` in the workspace, falling back to \`projects/roles/default/.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//prompts/.md\` in the workspace, falling back to \`devclaw/prompts/.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project. ### Heartbeats @@ -285,3 +285,109 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically. `; + +export const WORKFLOW_YAML_TEMPLATE = `# DevClaw workflow configuration +# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides. + +roles: + developer: + models: + junior: anthropic/claude-haiku-4-5 + medior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + tester: + models: + junior: anthropic/claude-haiku-4-5 + medior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + architect: + models: + junior: anthropic/claude-sonnet-4-5 + senior: anthropic/claude-opus-4-6 + # Disable a role entirely: + # architect: false + +workflow: + initial: planning + states: + planning: + type: hold + label: Planning + color: "#95a5a6" + on: + APPROVE: todo + todo: + type: queue + role: developer + label: To Do + color: "#428bca" + priority: 1 + on: + PICKUP: doing + doing: + type: active + role: developer + label: Doing + color: "#f0ad4e" + on: + COMPLETE: + target: toTest + actions: [gitPull, detectPr] + BLOCKED: refining + toTest: + type: queue + role: tester + label: To Test + color: "#5bc0de" + priority: 2 + on: + PICKUP: testing + testing: + type: active + role: tester + label: Testing + color: "#9b59b6" + on: + PASS: + target: done + actions: [closeIssue] + FAIL: + target: toImprove + actions: [reopenIssue] + REFINE: refining + BLOCKED: refining + toImprove: + type: queue + role: developer + label: To Improve + color: "#d9534f" + priority: 3 + on: + PICKUP: doing + refining: + type: hold + label: Refining + color: "#f39c12" + on: + APPROVE: todo + done: + type: terminal + label: Done + color: "#5cb85c" + toDesign: + type: queue + role: architect + label: To Design + color: "#0075ca" + priority: 1 + on: + PICKUP: designing + designing: + type: active + role: architect + label: Designing + color: "#d4c5f9" + on: + COMPLETE: planning + BLOCKED: refining +`; diff --git a/lib/tools/design-task.test.ts b/lib/tools/design-task.test.ts index e153634..be6491b 100644 --- a/lib/tools/design-task.test.ts +++ b/lib/tools/design-task.test.ts @@ -31,9 +31,9 @@ describe("architect tiers", () => { assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5"); }); - it("should resolve architect model from config", () => { - const config = { models: { architect: { senior: "custom/model" } } }; - assert.strictEqual(resolveModel("architect", "senior", config), "custom/model"); + 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", () => { diff --git a/lib/tools/design-task.ts b/lib/tools/design-task.ts index 0b1dcce..37ec7ca 100644 --- a/lib/tools/design-task.ts +++ b/lib/tools/design-task.ts @@ -14,6 +14,7 @@ import { dispatchTask } from "../dispatch.js"; import { log as auditLog } from "../audit.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js"; +import { loadConfig } from "../config/index.js"; import { selectLevel } from "../model-selector.js"; import { resolveModel } from "../roles/index.js"; @@ -123,7 +124,9 @@ Example: const level = complexity === "complex" ? selectLevel(title, "system-wide " + description, role).level : selectLevel(title, description, role).level; - const model = resolveModel(role, level, pluginConfig); + const resolvedConfig = await loadConfig(workspaceDir, project.name); + const resolvedRole = resolvedConfig.roles[role]; + const model = resolveModel(role, level, resolvedRole); if (dryRun) { return jsonResult({ diff --git a/lib/tools/onboard.ts b/lib/tools/onboard.ts index d6296f6..b451687 100644 --- a/lib/tools/onboard.ts +++ b/lib/tools/onboard.ts @@ -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); + const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(); return jsonResult({ success: true, mode, configured, instructions, diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index a3219bb..1d83d75 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -16,18 +16,19 @@ import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; +import { DATA_DIR } from "../setup/migrate-layout.js"; /** * 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 { - 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 }); let created = false; for (const role of getAllRoleIds()) { - const filePath = path.join(projectDir, `${role}.md`); + const filePath = path.join(promptsDir, `${role}.md`); try { await fs.access(filePath); } catch { diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index ff3ce4b..63ca82e 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -14,7 +14,7 @@ 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: {