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/<project>/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.
This commit is contained in:
23
index.ts
23
index.ts
@@ -15,28 +15,6 @@ import { registerCli } from "./lib/cli.js";
|
|||||||
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
||||||
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
||||||
import { initRunCommand } from "./lib/run-command.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<string, unknown> {
|
|
||||||
const properties: Record<string, unknown> = {};
|
|
||||||
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
|
||||||
const levelProps: Record<string, unknown> = {};
|
|
||||||
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 = {
|
const plugin = {
|
||||||
id: "devclaw",
|
id: "devclaw",
|
||||||
@@ -46,7 +24,6 @@ const plugin = {
|
|||||||
configSchema: {
|
configSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
models: buildModelsSchema(),
|
|
||||||
projectExecution: {
|
projectExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: ["parallel", "sequential"],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
|
import { DATA_DIR } from "./setup/migrate-layout.js";
|
||||||
|
|
||||||
const MAX_LOG_LINES = 50;
|
const MAX_LOG_LINES = 50;
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export async function log(
|
|||||||
event: string,
|
event: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const filePath = join(workspaceDir, "log", "audit.log");
|
const filePath = join(workspaceDir, DATA_DIR, "log", "audit.log");
|
||||||
const entry = JSON.stringify({
|
const entry = JSON.stringify({
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ describe("parseDevClawSessionKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("loadRoleInstructions", () => {
|
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 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.mkdir(projectDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(projectDir, "developer.md"), "# Developer Instructions\nDo the thing.");
|
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 });
|
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 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
const promptsDir = path.join(tmpDir, "devclaw", "prompts");
|
||||||
await fs.mkdir(defaultDir, { recursive: true });
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(defaultDir, "tester.md"), "# Tester Default\nReview carefully.");
|
await fs.writeFile(path.join(promptsDir, "tester.md"), "# Tester Default\nReview carefully.");
|
||||||
|
|
||||||
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester");
|
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester");
|
||||||
assert.strictEqual(result, "# Tester Default\nReview carefully.");
|
assert.strictEqual(result, "# Tester Default\nReview carefully.");
|
||||||
@@ -92,16 +92,28 @@ describe("loadRoleInstructions", () => {
|
|||||||
|
|
||||||
it("should prefer project-specific over default", async () => {
|
it("should prefer project-specific over default", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
const projectDir = path.join(tmpDir, "projects", "roles", "my-project");
|
const projectPromptsDir = path.join(tmpDir, "devclaw", "projects", "my-project", "prompts");
|
||||||
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
const defaultPromptsDir = path.join(tmpDir, "devclaw", "prompts");
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(projectPromptsDir, { recursive: true });
|
||||||
await fs.mkdir(defaultDir, { recursive: true });
|
await fs.mkdir(defaultPromptsDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(projectDir, "developer.md"), "Project-specific instructions");
|
await fs.writeFile(path.join(projectPromptsDir, "developer.md"), "Project-specific instructions");
|
||||||
await fs.writeFile(path.join(defaultDir, "developer.md"), "Default instructions");
|
await fs.writeFile(path.join(defaultPromptsDir, "developer.md"), "Default instructions");
|
||||||
|
|
||||||
const result = await loadRoleInstructions(tmpDir, "my-project", "developer");
|
const result = await loadRoleInstructions(tmpDir, "my-project", "developer");
|
||||||
assert.strictEqual(result, "Project-specific instructions");
|
assert.strictEqual(result, "Project-specific instructions");
|
||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true });
|
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,6 +11,7 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { getSessionKeyRolePattern } from "./roles/index.js";
|
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.
|
* Parse a DevClaw subagent session key to extract project name and role.
|
||||||
@@ -44,18 +45,24 @@ export async function loadRoleInstructions(
|
|||||||
projectName: string,
|
projectName: string,
|
||||||
role: string,
|
role: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||||
try {
|
|
||||||
return await fs.readFile(projectFile, "utf-8");
|
// Project-specific: devclaw/projects/<project>/prompts/<role>.md
|
||||||
} catch {
|
const projectFile = path.join(dataDir, "projects", projectName, "prompts", `${role}.md`);
|
||||||
/* not found — try default */
|
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* not found */ }
|
||||||
}
|
|
||||||
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
// Fallback old path: projects/roles/<project>/<role>.md
|
||||||
try {
|
const oldProjectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
||||||
return await fs.readFile(defaultFile, "utf-8");
|
try { return await fs.readFile(oldProjectFile, "utf-8"); } catch { /* not found */ }
|
||||||
} catch {
|
|
||||||
/* not found */
|
// Default: devclaw/prompts/<role>.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/<role>.md
|
||||||
|
const oldDefaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
||||||
|
try { return await fs.readFile(oldDefaultFile, "utf-8"); } catch { /* not found */ }
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* config/ — Unified DevClaw configuration.
|
* 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 {
|
export type {
|
||||||
DevClawConfig,
|
DevClawConfig,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
*
|
*
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
|
* 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
|
||||||
* 2. Workspace: <workspace>/projects/config.yaml
|
* 2. Workspace: <workspace>/devclaw/workflow.yaml
|
||||||
* 3. Project: <workspace>/projects/<project>/config.yaml
|
* 3. Project: <workspace>/devclaw/projects/<project>/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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||||
import { mergeConfig } from "./merge.js";
|
import { mergeConfig } from "./merge.js";
|
||||||
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, RoleOverride } from "./types.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.
|
* Load and resolve the full DevClaw config for a project.
|
||||||
*
|
*
|
||||||
* Merges: built-in → workspace config.yaml → project config.yaml.
|
* Merges: built-in → workspace workflow.yaml → project workflow.yaml.
|
||||||
* Also picks up legacy workflow.yaml files if no workflow section in config.yaml.
|
|
||||||
*/
|
*/
|
||||||
export async function loadConfig(
|
export async function loadConfig(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
projectName?: string,
|
projectName?: string,
|
||||||
): Promise<ResolvedConfig> {
|
): Promise<ResolvedConfig> {
|
||||||
const projectsDir = path.join(workspaceDir, "projects");
|
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||||
|
const projectsDir = path.join(dataDir, "projects");
|
||||||
|
|
||||||
// Layer 1: built-in defaults
|
// Layer 1: built-in defaults
|
||||||
const builtIn = buildDefaultConfig();
|
const builtIn = buildDefaultConfig();
|
||||||
|
|
||||||
// Layer 2: workspace config.yaml
|
// Layer 2: workspace workflow.yaml (in devclaw/ data dir)
|
||||||
let merged = builtIn;
|
let merged = builtIn;
|
||||||
const workspaceConfig = await readConfigFile(projectsDir);
|
const workspaceConfig =
|
||||||
|
await readWorkflowFile(dataDir) ??
|
||||||
|
await readLegacyConfigFile(path.join(workspaceDir, "projects"));
|
||||||
if (workspaceConfig) {
|
if (workspaceConfig) {
|
||||||
merged = mergeConfig(merged, 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) {
|
if (!workspaceConfig?.workflow) {
|
||||||
const legacyWorkflow = await readWorkflowYaml(projectsDir);
|
const legacyWorkflow = await readLegacyWorkflowJson(projectsDir);
|
||||||
if (legacyWorkflow) {
|
if (legacyWorkflow) {
|
||||||
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 3: project config.yaml
|
// Layer 3: project workflow.yaml
|
||||||
if (projectName) {
|
if (projectName) {
|
||||||
const projectDir = path.join(projectsDir, projectName);
|
const projectDir = path.join(projectsDir, projectName);
|
||||||
const projectConfig = await readConfigFile(projectDir);
|
const projectConfig =
|
||||||
|
await readWorkflowFile(projectDir) ??
|
||||||
|
await readLegacyConfigFile(projectDir);
|
||||||
if (projectConfig) {
|
if (projectConfig) {
|
||||||
merged = mergeConfig(merged, projectConfig);
|
merged = mergeConfig(merged, projectConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: project workflow.yaml
|
|
||||||
if (!projectConfig?.workflow) {
|
if (!projectConfig?.workflow) {
|
||||||
const legacyWorkflow = await readWorkflowYaml(projectDir);
|
const legacyWorkflow = await readLegacyWorkflowJson(projectDir);
|
||||||
if (legacyWorkflow) {
|
if (legacyWorkflow) {
|
||||||
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,17 @@ function resolve(config: DevClawConfig): ResolvedConfig {
|
|||||||
// File reading helpers
|
// File reading helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function readConfigFile(dir: string): Promise<DevClawConfig | null> {
|
/** Read workflow.yaml (new primary config file). */
|
||||||
|
async function readWorkflowFile(dir: string): Promise<DevClawConfig | null> {
|
||||||
|
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<DevClawConfig | null> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8");
|
const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8");
|
||||||
return YAML.parse(content) as DevClawConfig;
|
return YAML.parse(content) as DevClawConfig;
|
||||||
@@ -151,13 +165,8 @@ async function readConfigFile(dir: string): Promise<DevClawConfig | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readWorkflowYaml(dir: string): Promise<Partial<WorkflowConfig> | null> {
|
/** Read legacy workflow.json (standalone workflow section only). */
|
||||||
try {
|
async function readLegacyWorkflowJson(dir: string): Promise<Partial<WorkflowConfig> | null> {
|
||||||
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
|
|
||||||
return YAML.parse(content) as Partial<WorkflowConfig>;
|
|
||||||
} catch { /* not found */ }
|
|
||||||
|
|
||||||
// Legacy JSON fallback
|
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8");
|
const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8");
|
||||||
const parsed = JSON.parse(content) as
|
const parsed = JSON.parse(content) as
|
||||||
@@ -165,6 +174,5 @@ async function readWorkflowYaml(dir: string): Promise<Partial<WorkflowConfig> |
|
|||||||
| { workflow?: Partial<WorkflowConfig> };
|
| { workflow?: Partial<WorkflowConfig> };
|
||||||
return (parsed as any).workflow ?? parsed;
|
return (parsed as any).workflow ?? parsed;
|
||||||
} catch { /* not found */ }
|
} catch { /* not found */ }
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* config/types.ts — Types for the unified DevClaw configuration.
|
* 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.
|
* Three-layer resolution: built-in → workspace → per-project.
|
||||||
*/
|
*/
|
||||||
import type { WorkflowConfig } from "../workflow.js";
|
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.
|
* Set to `false` to disable a role entirely for a project.
|
||||||
*/
|
*/
|
||||||
export type RoleOverride = {
|
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.
|
* All fields optional — missing fields inherit from the layer below.
|
||||||
*/
|
*/
|
||||||
export type DevClawConfig = {
|
export type DevClawConfig = {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export async function dispatchTask(
|
|||||||
|
|
||||||
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
const resolvedRole = resolvedConfig.roles[role];
|
const resolvedRole = resolvedConfig.roles[role];
|
||||||
const model = resolveModel(role, level, pluginConfig, resolvedRole);
|
const model = resolveModel(role, level, resolvedRole);
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
const existingSessionKey = getSessionForLevel(worker, level);
|
const existingSessionKey = getSessionForLevel(worker, level);
|
||||||
const sessionAction = existingSessionKey ? "send" : "spawn";
|
const sessionAction = existingSessionKey ? "send" : "spawn";
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { getAllDefaultModels } from "./roles/index.js";
|
|||||||
export function isPluginConfigured(
|
export function isPluginConfigured(
|
||||||
pluginConfig?: Record<string, unknown>,
|
pluginConfig?: Record<string, unknown>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const models = (pluginConfig as { models?: Record<string, string> })?.models;
|
// Models moved to workflow.yaml — check for any devclaw plugin config (heartbeat, notifications, etc.)
|
||||||
return !!models && Object.keys(models).length > 0;
|
return !!pluginConfig && Object.keys(pluginConfig).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasWorkspaceFiles(
|
export async function hasWorkspaceFiles(
|
||||||
@@ -37,30 +37,26 @@ export async function hasWorkspaceFiles(
|
|||||||
// Context templates
|
// Context templates
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function buildModelTable(pluginConfig?: Record<string, unknown>): string {
|
function buildModelTable(): string {
|
||||||
const cfg = (pluginConfig as { models?: Record<string, Record<string, string>> })?.models;
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
|
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
|
||||||
for (const [level, defaultModel] of Object.entries(levels)) {
|
for (const [level, model] of Object.entries(levels)) {
|
||||||
const model = cfg?.[role]?.[level] || defaultModel;
|
lines.push(` - **${role} ${level}**: ${model}`);
|
||||||
lines.push(
|
|
||||||
` - **${role} ${level}**: ${model} (default: ${defaultModel})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReconfigContext(
|
export function buildReconfigContext(): string {
|
||||||
pluginConfig?: Record<string, unknown>,
|
const modelTable = buildModelTable();
|
||||||
): string {
|
|
||||||
const modelTable = buildModelTable(pluginConfig);
|
|
||||||
return `# DevClaw Reconfiguration
|
return `# DevClaw Reconfiguration
|
||||||
|
|
||||||
The user wants to reconfigure DevClaw. Current model configuration:
|
The user wants to reconfigure DevClaw. Default model configuration:
|
||||||
|
|
||||||
${modelTable}
|
${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
|
## What can be changed
|
||||||
1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change
|
1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change
|
||||||
2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ describe("readProjects migration", () => {
|
|||||||
|
|
||||||
it("should read new format (workers map) correctly", async () => {
|
it("should read new format (workers map) correctly", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
const projDir = path.join(tmpDir, "projects");
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
await fs.mkdir(projDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
const newFormat = {
|
const newFormat = {
|
||||||
projects: {
|
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 data = await readProjects(tmpDir);
|
||||||
const project = data.projects["group-1"];
|
const project = data.projects["group-1"];
|
||||||
@@ -129,8 +129,8 @@ describe("readProjects migration", () => {
|
|||||||
|
|
||||||
it("should migrate old worker keys in new format", async () => {
|
it("should migrate old worker keys in new format", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
const projDir = path.join(tmpDir, "projects");
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
await fs.mkdir(projDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
// Workers map but with old role keys
|
// Workers map but with old role keys
|
||||||
const mixedFormat = {
|
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 data = await readProjects(tmpDir);
|
||||||
const project = data.projects["group-1"];
|
const project = data.projects["group-1"];
|
||||||
@@ -165,11 +165,7 @@ describe("readProjects migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getWorker", () => {
|
describe("getWorker", () => {
|
||||||
it("should return worker from workers map", async () => {
|
it("should return worker from workers map", () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
|
||||||
const projDir = path.join(tmpDir, "projects");
|
|
||||||
await fs.mkdir(projDir, { recursive: true });
|
|
||||||
|
|
||||||
const data: ProjectsData = {
|
const data: ProjectsData = {
|
||||||
projects: {
|
projects: {
|
||||||
"g1": {
|
"g1": {
|
||||||
@@ -189,11 +185,9 @@ describe("getWorker", () => {
|
|||||||
const worker = getWorker(data.projects["g1"], "developer");
|
const worker = getWorker(data.projects["g1"], "developer");
|
||||||
assert.strictEqual(worker.active, true);
|
assert.strictEqual(worker.active, true);
|
||||||
assert.strictEqual(worker.issueId, "5");
|
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 = {
|
const data: ProjectsData = {
|
||||||
projects: {
|
projects: {
|
||||||
"g1": {
|
"g1": {
|
||||||
@@ -217,8 +211,8 @@ describe("getWorker", () => {
|
|||||||
describe("writeProjects round-trip", () => {
|
describe("writeProjects round-trip", () => {
|
||||||
it("should preserve workers map through write/read cycle", async () => {
|
it("should preserve workers map through write/read cycle", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
const projDir = path.join(tmpDir, "projects");
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
await fs.mkdir(projDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
const data: ProjectsData = {
|
const data: ProjectsData = {
|
||||||
projects: {
|
projects: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { migrateProject } from "./migrations.js";
|
import { migrateProject } from "./migrations.js";
|
||||||
|
import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js";
|
||||||
|
|
||||||
export type WorkerState = {
|
export type WorkerState = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -66,10 +67,11 @@ export function getSessionForLevel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function projectsPath(workspaceDir: string): string {
|
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<ProjectsData> {
|
export async function readProjects(workspaceDir: string): Promise<ProjectsData> {
|
||||||
|
await ensureWorkspaceMigrated(workspaceDir);
|
||||||
const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8");
|
const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8");
|
||||||
const data = JSON.parse(raw) as ProjectsData;
|
const data = JSON.parse(raw) as ProjectsData;
|
||||||
|
|
||||||
|
|||||||
@@ -140,9 +140,9 @@ describe("models", () => {
|
|||||||
assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5");
|
assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve from config override", () => {
|
it("should resolve from resolved role config override", () => {
|
||||||
const config = { models: { developer: { junior: "custom/model" } } };
|
const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
assert.strictEqual(resolveModel("developer", "junior", config), "custom/model");
|
assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to default", () => {
|
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");
|
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve old config keys via aliases", () => {
|
it("should resolve via level aliases", () => {
|
||||||
// Old config uses "mid" key — should still resolve via alias
|
// "mid" alias maps to "medior" — should resolve to default medior model
|
||||||
const config = { models: { developer: { mid: "custom/old-config-model" } } };
|
assert.strictEqual(resolveModel("developer", "mid"), "anthropic/claude-sonnet-4-5");
|
||||||
assert.strictEqual(resolveModel("developer", "mid", config), "custom/old-config-model");
|
// With explicit override in resolved config
|
||||||
// Also works when requesting the canonical name
|
const resolvedRole = { models: { medior: "custom/old-config-model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
assert.strictEqual(resolveModel("developer", "medior", {}), "anthropic/claude-sonnet-4-5");
|
assert.strictEqual(resolveModel("developer", "mid", resolvedRole), "custom/old-config-model");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve old role name config keys", () => {
|
it("should resolve with resolved role overriding defaults selectively", () => {
|
||||||
// Old config uses "dev" role key — should still resolve via role alias
|
const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
const config = { models: { dev: { junior: "custom/model" } } };
|
assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model");
|
||||||
assert.strictEqual(resolveModel("developer", "junior", config), "custom/model");
|
// Levels not overridden fall through to registry defaults
|
||||||
});
|
assert.strictEqual(resolveModel("developer", "medior", resolvedRole), "anthropic/claude-sonnet-4-5");
|
||||||
|
|
||||||
it("should resolve old qa config keys", () => {
|
|
||||||
const config = { models: { qa: { reviewer: "custom/qa-model" } } };
|
|
||||||
assert.strictEqual(resolveModel("tester", "reviewer", config), "custom/qa-model");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -98,32 +98,21 @@ export function getAllDefaultModels(): Record<string, Record<string, string>> {
|
|||||||
* Resolve a level to a full model ID.
|
* Resolve a level to a full model ID.
|
||||||
*
|
*
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. Plugin config `models.<role>.<level>` in openclaw.json (highest precedence)
|
* 1. Resolved config from workflow.yaml (three-layer merge)
|
||||||
* 2. Resolved config from config.yaml (if provided)
|
* 2. Registry default model
|
||||||
* 3. Registry default model
|
* 3. Passthrough (treat level as raw model ID)
|
||||||
* 4. Passthrough (treat level as raw model ID)
|
|
||||||
*/
|
*/
|
||||||
export function resolveModel(
|
export function resolveModel(
|
||||||
role: string,
|
role: string,
|
||||||
level: string,
|
level: string,
|
||||||
pluginConfig?: Record<string, unknown>,
|
|
||||||
resolvedRole?: ResolvedRoleConfig,
|
resolvedRole?: ResolvedRoleConfig,
|
||||||
): string {
|
): string {
|
||||||
const canonical = _canonicalLevel(role, level);
|
const canonical = _canonicalLevel(role, level);
|
||||||
|
|
||||||
// 1. Plugin config override (openclaw.json) — check canonical role + old aliases
|
// 1. Resolved config (workflow.yaml — includes workspace + project overrides)
|
||||||
const models = (pluginConfig as { models?: Record<string, unknown> })?.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<string, string> | undefined;
|
|
||||||
if (roleModels?.[canonical]) return roleModels[canonical];
|
|
||||||
if (roleModels?.[level]) return roleModels[level];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Resolved config (config.yaml)
|
|
||||||
if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical];
|
if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical];
|
||||||
|
|
||||||
// 3. Built-in registry default
|
// 2. Built-in registry default
|
||||||
return getDefaultModel(role, canonical) ?? canonical;
|
return getDefaultModel(role, canonical) ?? canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readProjects } from "../projects.js";
|
import { readProjects } from "../projects.js";
|
||||||
import { log as auditLog } from "../audit.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 { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
||||||
import { projectTick } from "./tick.js";
|
import { projectTick } from "./tick.js";
|
||||||
import { createProvider } from "../providers/index.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.
|
* 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.
|
* Also checks the default workspace (agents.defaults.workspace) for projects.
|
||||||
*/
|
*/
|
||||||
function discoverAgents(config: {
|
function discoverAgents(config: {
|
||||||
@@ -131,7 +132,7 @@ function discoverAgents(config: {
|
|||||||
for (const a of config.agents?.list || []) {
|
for (const a of config.agents?.list || []) {
|
||||||
if (!a.workspace) continue;
|
if (!a.workspace) continue;
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(path.join(a.workspace, "projects", "projects.json"))) {
|
if (hasProjects(a.workspace)) {
|
||||||
agents.push({ agentId: a.id, workspace: a.workspace });
|
agents.push({ agentId: a.id, workspace: a.workspace });
|
||||||
seen.add(a.workspace);
|
seen.add(a.workspace);
|
||||||
}
|
}
|
||||||
@@ -142,7 +143,7 @@ function discoverAgents(config: {
|
|||||||
const defaultWorkspace = config.agents?.defaults?.workspace;
|
const defaultWorkspace = config.agents?.defaults?.workspace;
|
||||||
if (defaultWorkspace && !seen.has(defaultWorkspace)) {
|
if (defaultWorkspace && !seen.has(defaultWorkspace)) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(path.join(defaultWorkspace, "projects", "projects.json"))) {
|
if (hasProjects(defaultWorkspace)) {
|
||||||
agents.push({ agentId: "main", workspace: defaultWorkspace });
|
agents.push({ agentId: "main", workspace: defaultWorkspace });
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
@@ -151,6 +152,15 @@ function discoverAgents(config: {
|
|||||||
return agents;
|
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.
|
* Run one heartbeat tick for all agents.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* setup/config.ts — Plugin config writer (openclaw.json).
|
* 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 type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
||||||
|
|
||||||
type ModelConfig = Record<string, Record<string, string>>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
|
||||||
* - Subagent cleanup interval (30 days) to keep development sessions alive
|
* - Subagent cleanup interval (30 days) to keep development sessions alive
|
||||||
|
* - Heartbeat defaults
|
||||||
*
|
*
|
||||||
* Read-modify-write to preserve existing config.
|
* Read-modify-write to preserve existing config.
|
||||||
|
* Note: models are NOT stored here — they live in workflow.yaml.
|
||||||
*/
|
*/
|
||||||
export async function writePluginConfig(
|
export async function writePluginConfig(
|
||||||
api: OpenClawPluginApi,
|
api: OpenClawPluginApi,
|
||||||
models: ModelConfig,
|
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
projectExecution?: "parallel" | "sequential",
|
projectExecution?: "parallel" | "sequential",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
||||||
|
|
||||||
ensurePluginStructure(config);
|
ensurePluginStructure(config);
|
||||||
(config as any).plugins.entries.devclaw.config.models = models;
|
|
||||||
|
|
||||||
if (projectExecution) {
|
if (projectExecution) {
|
||||||
(config as any).plugins.entries.devclaw.config.projectExecution = 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);
|
ensureHeartbeatDefaults(config);
|
||||||
configureSubagentCleanup(config);
|
configureSubagentCleanup(config);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* setup/index.ts — DevClaw setup orchestrator.
|
* 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.
|
* 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 type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { getAllDefaultModels } from "../roles/index.js";
|
import { getAllDefaultModels } from "../roles/index.js";
|
||||||
import { migrateChannelBinding } from "../binding-manager.js";
|
import { migrateChannelBinding } from "../binding-manager.js";
|
||||||
import { createAgent, resolveWorkspacePath } from "./agent.js";
|
import { createAgent, resolveWorkspacePath } from "./agent.js";
|
||||||
import { writePluginConfig } from "./config.js";
|
import { writePluginConfig } from "./config.js";
|
||||||
import { scaffoldWorkspace } from "./workspace.js";
|
import { scaffoldWorkspace } from "./workspace.js";
|
||||||
|
import { DATA_DIR } from "./migrate-layout.js";
|
||||||
|
|
||||||
export type ModelConfig = Record<string, Record<string, string>>;
|
export type ModelConfig = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
@@ -49,8 +53,9 @@ export type SetupResult = {
|
|||||||
* Run the full DevClaw setup.
|
* Run the full DevClaw setup.
|
||||||
*
|
*
|
||||||
* 1. Create agent (optional) or resolve existing workspace
|
* 1. Create agent (optional) or resolve existing workspace
|
||||||
* 2. Merge model config and write to openclaw.json
|
* 2. Write plugin config to openclaw.json (heartbeat, tool restrictions — no models)
|
||||||
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
|
* 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> {
|
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -58,11 +63,13 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
|||||||
const { agentId, workspacePath, agentCreated, bindingMigrated } =
|
const { agentId, workspacePath, agentCreated, bindingMigrated } =
|
||||||
await resolveOrCreateAgent(opts, warnings);
|
await resolveOrCreateAgent(opts, warnings);
|
||||||
|
|
||||||
const models = buildModelConfig(opts.models);
|
await writePluginConfig(opts.api, agentId, opts.projectExecution);
|
||||||
await writePluginConfig(opts.api, models, agentId, opts.projectExecution);
|
|
||||||
|
|
||||||
const filesWritten = await scaffoldWorkspace(workspacePath);
|
const filesWritten = await scaffoldWorkspace(workspacePath);
|
||||||
|
|
||||||
|
const models = buildModelConfig(opts.models);
|
||||||
|
await writeModelsToWorkflow(workspacePath, models);
|
||||||
|
|
||||||
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
|
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,3 +138,32 @@ function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
|
|||||||
|
|
||||||
return result;
|
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<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");
|
||||||
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,66 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* setup/workspace.ts — Workspace file scaffolding.
|
* 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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
AGENTS_MD_TEMPLATE,
|
AGENTS_MD_TEMPLATE,
|
||||||
HEARTBEAT_MD_TEMPLATE,
|
HEARTBEAT_MD_TEMPLATE,
|
||||||
DEFAULT_DEV_INSTRUCTIONS,
|
WORKFLOW_YAML_TEMPLATE,
|
||||||
DEFAULT_QA_INSTRUCTIONS,
|
DEFAULT_ROLE_INSTRUCTIONS,
|
||||||
DEFAULT_ARCHITECT_INSTRUCTIONS,
|
|
||||||
} from "../templates.js";
|
} 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.
|
* Write all workspace files for a DevClaw agent.
|
||||||
* Returns the list of files that were written (skips files that already exist).
|
* Returns the list of files that were written (skips files that already exist).
|
||||||
*/
|
*/
|
||||||
export async function scaffoldWorkspace(workspacePath: string): Promise<string[]> {
|
export async function scaffoldWorkspace(workspacePath: string): Promise<string[]> {
|
||||||
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);
|
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);
|
await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE);
|
||||||
filesWritten.push("HEARTBEAT.md");
|
|
||||||
|
|
||||||
// projects/projects.json
|
// Ensure all data-dir defaults (workflow.yaml, prompts, etc.)
|
||||||
const projectsDir = path.join(workspacePath, "projects");
|
await ensureDefaultFiles(workspacePath);
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// projects/roles/default/ (fallback role instructions)
|
return ["AGENTS.md", "HEARTBEAT.md"];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
108
lib/templates.ts
108
lib/templates.ts
@@ -266,7 +266,7 @@ Workers call \`work_finish\` themselves — the label transition, state update,
|
|||||||
|
|
||||||
### Prompt Instructions
|
### 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
|
### 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.
|
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/<project>/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
|
||||||
|
`;
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ describe("architect tiers", () => {
|
|||||||
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
|
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve architect model from config", () => {
|
it("should resolve architect model from resolved role config", () => {
|
||||||
const config = { models: { architect: { senior: "custom/model" } } };
|
const resolvedRole = { models: { senior: "custom/model" }, levels: ["junior", "senior"], defaultLevel: "junior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
assert.strictEqual(resolveModel("architect", "senior", config), "custom/model");
|
assert.strictEqual(resolveModel("architect", "senior", resolvedRole), "custom/model");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have architect emoji", () => {
|
it("should have architect emoji", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { dispatchTask } from "../dispatch.js";
|
|||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js";
|
import { loadWorkflow, getActiveLabel, getQueueLabels } from "../workflow.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
import { selectLevel } from "../model-selector.js";
|
import { selectLevel } from "../model-selector.js";
|
||||||
import { resolveModel } from "../roles/index.js";
|
import { resolveModel } from "../roles/index.js";
|
||||||
|
|
||||||
@@ -123,7 +124,9 @@ Example:
|
|||||||
const level = complexity === "complex"
|
const level = complexity === "complex"
|
||||||
? selectLevel(title, "system-wide " + description, role).level
|
? selectLevel(title, "system-wide " + description, role).level
|
||||||
: selectLevel(title, 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) {
|
if (dryRun) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function createOnboardTool(api: OpenClawPluginApi) {
|
|||||||
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
||||||
: configured && hasWorkspace ? "reconfigure" : "first-run";
|
: 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({
|
return jsonResult({
|
||||||
success: true, mode, configured, instructions,
|
success: true, mode, configured, instructions,
|
||||||
|
|||||||
@@ -16,18 +16,19 @@ import { createProvider } from "../providers/index.js";
|
|||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.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.
|
* Scaffold project-specific prompt files for all registered roles.
|
||||||
* Returns true if files were created, false if they already existed.
|
* Returns true if files were created, false if they already existed.
|
||||||
*/
|
*/
|
||||||
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
||||||
const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
|
const promptsDir = path.join(workspaceDir, DATA_DIR, "projects", projectName, "prompts");
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
|
||||||
let created = false;
|
let created = false;
|
||||||
for (const role of getAllRoleIds()) {
|
for (const role of getAllRoleIds()) {
|
||||||
const filePath = path.join(projectDir, `${role}.md`);
|
const filePath = path.join(promptsDir, `${role}.md`);
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath);
|
await fs.access(filePath);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
name: "setup",
|
name: "setup",
|
||||||
label: "Setup",
|
label: "Setup",
|
||||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model 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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
Reference in New Issue
Block a user