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:
@@ -1,37 +1,39 @@
|
||||
/**
|
||||
* setup/config.ts — Plugin config writer (openclaw.json).
|
||||
*
|
||||
* Handles: model level config, tool restrictions, subagent cleanup.
|
||||
* Handles: tool restrictions, subagent cleanup, heartbeat defaults.
|
||||
* Models are stored in workflow.yaml (not openclaw.json).
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
||||
|
||||
type ModelConfig = Record<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
|
||||
* - Subagent cleanup interval (30 days) to keep development sessions alive
|
||||
* - Heartbeat defaults
|
||||
*
|
||||
* Read-modify-write to preserve existing config.
|
||||
* Note: models are NOT stored here — they live in workflow.yaml.
|
||||
*/
|
||||
export async function writePluginConfig(
|
||||
api: OpenClawPluginApi,
|
||||
models: ModelConfig,
|
||||
agentId?: string,
|
||||
projectExecution?: "parallel" | "sequential",
|
||||
): Promise<void> {
|
||||
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
||||
|
||||
ensurePluginStructure(config);
|
||||
(config as any).plugins.entries.devclaw.config.models = models;
|
||||
|
||||
if (projectExecution) {
|
||||
(config as any).plugins.entries.devclaw.config.projectExecution = projectExecution;
|
||||
}
|
||||
|
||||
// Clean up legacy models from openclaw.json (moved to workflow.yaml)
|
||||
delete (config as any).plugins.entries.devclaw.config.models;
|
||||
|
||||
ensureHeartbeatDefaults(config);
|
||||
configureSubagentCleanup(config);
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* setup/index.ts — DevClaw setup orchestrator.
|
||||
*
|
||||
* Coordinates: agent creation → model config → workspace scaffolding.
|
||||
* Coordinates: agent creation → plugin config → workspace scaffolding → model config.
|
||||
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { getAllDefaultModels } from "../roles/index.js";
|
||||
import { migrateChannelBinding } from "../binding-manager.js";
|
||||
import { createAgent, resolveWorkspacePath } from "./agent.js";
|
||||
import { writePluginConfig } from "./config.js";
|
||||
import { scaffoldWorkspace } from "./workspace.js";
|
||||
import { DATA_DIR } from "./migrate-layout.js";
|
||||
|
||||
export type ModelConfig = Record<string, Record<string, string>>;
|
||||
|
||||
@@ -49,8 +53,9 @@ export type SetupResult = {
|
||||
* Run the full DevClaw setup.
|
||||
*
|
||||
* 1. Create agent (optional) or resolve existing workspace
|
||||
* 2. Merge model config and write to openclaw.json
|
||||
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
|
||||
* 2. Write plugin config to openclaw.json (heartbeat, tool restrictions — no models)
|
||||
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, workflow.yaml, prompts)
|
||||
* 4. Write model config to workflow.yaml (single source of truth)
|
||||
*/
|
||||
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
const warnings: string[] = [];
|
||||
@@ -58,11 +63,13 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
const { agentId, workspacePath, agentCreated, bindingMigrated } =
|
||||
await resolveOrCreateAgent(opts, warnings);
|
||||
|
||||
const models = buildModelConfig(opts.models);
|
||||
await writePluginConfig(opts.api, models, agentId, opts.projectExecution);
|
||||
await writePluginConfig(opts.api, agentId, opts.projectExecution);
|
||||
|
||||
const filesWritten = await scaffoldWorkspace(workspacePath);
|
||||
|
||||
const models = buildModelConfig(opts.models);
|
||||
await writeModelsToWorkflow(workspacePath, models);
|
||||
|
||||
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
|
||||
}
|
||||
|
||||
@@ -131,3 +138,32 @@ function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write model configuration to workflow.yaml (single source of truth).
|
||||
* Reads the existing workflow.yaml, merges model overrides into the roles section, and writes back.
|
||||
*/
|
||||
async function writeModelsToWorkflow(workspacePath: string, models: ModelConfig): Promise<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.
|
||||
*
|
||||
* Writes AGENTS.md, HEARTBEAT.md, default role instructions, and projects.json.
|
||||
* Writes AGENTS.md, HEARTBEAT.md, default role prompts, and projects.json.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
AGENTS_MD_TEMPLATE,
|
||||
HEARTBEAT_MD_TEMPLATE,
|
||||
DEFAULT_DEV_INSTRUCTIONS,
|
||||
DEFAULT_QA_INSTRUCTIONS,
|
||||
DEFAULT_ARCHITECT_INSTRUCTIONS,
|
||||
WORKFLOW_YAML_TEMPLATE,
|
||||
DEFAULT_ROLE_INSTRUCTIONS,
|
||||
} from "../templates.js";
|
||||
import { getAllRoleIds } from "../roles/index.js";
|
||||
import { migrateWorkspaceLayout, DATA_DIR } from "./migrate-layout.js";
|
||||
|
||||
/**
|
||||
* Ensure default data files exist in the workspace.
|
||||
* Only creates files that are missing — never overwrites existing ones.
|
||||
* Called automatically after migration (via ensureWorkspaceMigrated).
|
||||
*/
|
||||
export async function ensureDefaultFiles(workspacePath: string): Promise<void> {
|
||||
const dataDir = path.join(workspacePath, DATA_DIR);
|
||||
|
||||
// devclaw/workflow.yaml
|
||||
const workflowPath = path.join(dataDir, "workflow.yaml");
|
||||
if (!await fileExists(workflowPath)) {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await fs.writeFile(workflowPath, WORKFLOW_YAML_TEMPLATE, "utf-8");
|
||||
}
|
||||
|
||||
// devclaw/projects.json
|
||||
const projectsJsonPath = path.join(dataDir, "projects.json");
|
||||
if (!await fileExists(projectsJsonPath)) {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
// devclaw/projects/ directory
|
||||
await fs.mkdir(path.join(dataDir, "projects"), { recursive: true });
|
||||
|
||||
// devclaw/prompts/ — default role instructions
|
||||
const promptsDir = path.join(dataDir, "prompts");
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
for (const role of getAllRoleIds()) {
|
||||
const rolePath = path.join(promptsDir, `${role}.md`);
|
||||
if (!await fileExists(rolePath)) {
|
||||
const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`;
|
||||
await fs.writeFile(rolePath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// devclaw/log/ directory (audit.log created on first write)
|
||||
await fs.mkdir(path.join(dataDir, "log"), { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all workspace files for a DevClaw agent.
|
||||
* Returns the list of files that were written (skips files that already exist).
|
||||
*/
|
||||
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);
|
||||
filesWritten.push("AGENTS.md");
|
||||
|
||||
// HEARTBEAT.md
|
||||
// HEARTBEAT.md (stays at workspace root)
|
||||
await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE);
|
||||
filesWritten.push("HEARTBEAT.md");
|
||||
|
||||
// projects/projects.json
|
||||
const projectsDir = path.join(workspacePath, "projects");
|
||||
await fs.mkdir(projectsDir, { recursive: true });
|
||||
const projectsJsonPath = path.join(projectsDir, "projects.json");
|
||||
if (!await fileExists(projectsJsonPath)) {
|
||||
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
|
||||
filesWritten.push("projects/projects.json");
|
||||
}
|
||||
// Ensure all data-dir defaults (workflow.yaml, prompts, etc.)
|
||||
await ensureDefaultFiles(workspacePath);
|
||||
|
||||
// projects/roles/default/ (fallback role instructions)
|
||||
const defaultRolesDir = path.join(projectsDir, "roles", "default");
|
||||
await fs.mkdir(defaultRolesDir, { recursive: true });
|
||||
const devRolePath = path.join(defaultRolesDir, "dev.md");
|
||||
if (!await fileExists(devRolePath)) {
|
||||
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
|
||||
filesWritten.push("projects/roles/default/dev.md");
|
||||
}
|
||||
const qaRolePath = path.join(defaultRolesDir, "qa.md");
|
||||
if (!await fileExists(qaRolePath)) {
|
||||
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
||||
filesWritten.push("projects/roles/default/qa.md");
|
||||
}
|
||||
const architectRolePath = path.join(defaultRolesDir, "architect.md");
|
||||
if (!await fileExists(architectRolePath)) {
|
||||
await fs.writeFile(architectRolePath, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8");
|
||||
filesWritten.push("projects/roles/default/architect.md");
|
||||
}
|
||||
|
||||
// log/ directory (audit.log created on first write)
|
||||
const logDir = path.join(workspacePath, "log");
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
|
||||
return filesWritten;
|
||||
return ["AGENTS.md", "HEARTBEAT.md"];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user