/** * config/loader.ts — Three-layer config loading. * * Resolution order: * 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW) * 2. Workspace: /projects/config.yaml * 3. Project: /projects//config.yaml * * Also supports legacy workflow.yaml files (merged into the workflow section). */ import fs from "node:fs/promises"; import path from "node:path"; import YAML from "yaml"; import { ROLE_REGISTRY } from "../roles/registry.js"; import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; import { mergeConfig } from "./merge.js"; import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, RoleOverride } from "./types.js"; /** * Load and resolve the full DevClaw config for a project. * * Merges: built-in → workspace config.yaml → project config.yaml. * Also picks up legacy workflow.yaml files if no workflow section in config.yaml. */ export async function loadConfig( workspaceDir: string, projectName?: string, ): Promise { const projectsDir = path.join(workspaceDir, "projects"); // Layer 1: built-in defaults const builtIn = buildDefaultConfig(); // Layer 2: workspace config.yaml let merged = builtIn; const workspaceConfig = await readConfigFile(projectsDir); if (workspaceConfig) { merged = mergeConfig(merged, workspaceConfig); } // Legacy: workspace workflow.yaml (only if no workflow in config.yaml) if (!workspaceConfig?.workflow) { const legacyWorkflow = await readWorkflowYaml(projectsDir); if (legacyWorkflow) { merged = mergeConfig(merged, { workflow: legacyWorkflow }); } } // Layer 3: project config.yaml if (projectName) { const projectDir = path.join(projectsDir, projectName); const projectConfig = await readConfigFile(projectDir); if (projectConfig) { merged = mergeConfig(merged, projectConfig); } // Legacy: project workflow.yaml if (!projectConfig?.workflow) { const legacyWorkflow = await readWorkflowYaml(projectDir); if (legacyWorkflow) { merged = mergeConfig(merged, { workflow: legacyWorkflow }); } } } return resolve(merged); } /** * Build the default config from the built-in ROLE_REGISTRY and DEFAULT_WORKFLOW. */ function buildDefaultConfig(): DevClawConfig { const roles: Record = {}; for (const [id, reg] of Object.entries(ROLE_REGISTRY)) { roles[id] = { levels: [...reg.levels], defaultLevel: reg.defaultLevel, models: { ...reg.models }, emoji: { ...reg.emoji }, completionResults: [...reg.completionResults], }; } return { roles, workflow: DEFAULT_WORKFLOW }; } /** * Resolve a merged DevClawConfig into a fully-typed ResolvedConfig. */ function resolve(config: DevClawConfig): ResolvedConfig { const roles: Record = {}; if (config.roles) { for (const [id, override] of Object.entries(config.roles)) { if (override === false) { // Disabled role — include with enabled: false for visibility const reg = ROLE_REGISTRY[id]; roles[id] = { levels: reg ? [...reg.levels] : [], defaultLevel: reg?.defaultLevel ?? "", models: reg ? { ...reg.models } : {}, emoji: reg ? { ...reg.emoji } : {}, completionResults: reg ? [...reg.completionResults] : [], enabled: false, }; continue; } const reg = ROLE_REGISTRY[id]; roles[id] = { levels: override.levels ?? (reg ? [...reg.levels] : []), defaultLevel: override.defaultLevel ?? reg?.defaultLevel ?? "", models: { ...(reg?.models ?? {}), ...(override.models ?? {}) }, emoji: { ...(reg?.emoji ?? {}), ...(override.emoji ?? {}) }, completionResults: override.completionResults ?? (reg ? [...reg.completionResults] : []), enabled: true, }; } } // Ensure all built-in roles exist even if not in config for (const [id, reg] of Object.entries(ROLE_REGISTRY)) { if (!roles[id]) { roles[id] = { levels: [...reg.levels], defaultLevel: reg.defaultLevel, models: { ...reg.models }, emoji: { ...reg.emoji }, completionResults: [...reg.completionResults], enabled: true, }; } } const workflow: WorkflowConfig = { initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial, states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states }, }; return { roles, workflow }; } // --------------------------------------------------------------------------- // File reading helpers // --------------------------------------------------------------------------- async function readConfigFile(dir: string): Promise { try { const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8"); return YAML.parse(content) as DevClawConfig; } catch { /* not found */ } return null; } async function readWorkflowYaml(dir: string): Promise | null> { try { const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); return YAML.parse(content) as Partial; } catch { /* not found */ } // Legacy JSON fallback try { const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8"); const parsed = JSON.parse(content) as | Partial | { workflow?: Partial }; return (parsed as any).workflow ?? parsed; } catch { /* not found */ } return null; }