Files
devclaw-gitea/lib/config/loader.ts
Lauren ten Hoor 0e24a68882 refactor: migrate role handling from tiers to roles module
- Removed the deprecated tiers.ts file and migrated all related functionality to roles/index.js.
- Updated tests and tools to reflect the new role structure, replacing references to "dev", "qa", and "architect" with "developer", "tester", and "architect".
- Adjusted workflow configurations and state management to accommodate the new role naming conventions.
- Enhanced project registration and health check tools to support dynamic role handling.
- Updated task creation, update, and completion processes to align with the new role definitions.
- Improved documentation and comments to clarify role responsibilities and usage.
2026-02-15 18:32:10 +08:00

171 lines
5.5 KiB
TypeScript

/**
* config/loader.ts — Three-layer config loading.
*
* Resolution order:
* 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
* 2. Workspace: <workspace>/projects/config.yaml
* 3. Project: <workspace>/projects/<project>/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<ResolvedConfig> {
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<string, RoleOverride> = {};
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
roles[id] = {
levels: [...reg.levels],
defaultLevel: reg.defaultLevel,
models: { ...reg.models },
emoji: { ...reg.emoji },
completionResults: [...reg.completionResults],
};
}
return { roles, workflow: DEFAULT_WORKFLOW };
}
/**
* Resolve a merged DevClawConfig into a fully-typed ResolvedConfig.
*/
function resolve(config: DevClawConfig): ResolvedConfig {
const roles: Record<string, ResolvedRoleConfig> = {};
if (config.roles) {
for (const [id, override] of Object.entries(config.roles)) {
if (override === false) {
// Disabled role — include with enabled: false for visibility
const reg = ROLE_REGISTRY[id];
roles[id] = {
levels: reg ? [...reg.levels] : [],
defaultLevel: reg?.defaultLevel ?? "",
models: reg ? { ...reg.models } : {},
emoji: reg ? { ...reg.emoji } : {},
completionResults: reg ? [...reg.completionResults] : [],
enabled: false,
};
continue;
}
const reg = ROLE_REGISTRY[id];
roles[id] = {
levels: override.levels ?? (reg ? [...reg.levels] : []),
defaultLevel: override.defaultLevel ?? reg?.defaultLevel ?? "",
models: { ...(reg?.models ?? {}), ...(override.models ?? {}) },
emoji: { ...(reg?.emoji ?? {}), ...(override.emoji ?? {}) },
completionResults: override.completionResults ?? (reg ? [...reg.completionResults] : []),
enabled: true,
};
}
}
// Ensure all built-in roles exist even if not in config
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
if (!roles[id]) {
roles[id] = {
levels: [...reg.levels],
defaultLevel: reg.defaultLevel,
models: { ...reg.models },
emoji: { ...reg.emoji },
completionResults: [...reg.completionResults],
enabled: true,
};
}
}
const workflow: WorkflowConfig = {
initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial,
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
};
return { roles, workflow };
}
// ---------------------------------------------------------------------------
// File reading helpers
// ---------------------------------------------------------------------------
async function readConfigFile(dir: string): Promise<DevClawConfig | null> {
try {
const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8");
return YAML.parse(content) as DevClawConfig;
} catch { /* not found */ }
return null;
}
async function readWorkflowYaml(dir: string): Promise<Partial<WorkflowConfig> | null> {
try {
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 {
const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8");
const parsed = JSON.parse(content) as
| Partial<WorkflowConfig>
| { workflow?: Partial<WorkflowConfig> };
return (parsed as any).workflow ?? parsed;
} catch { /* not found */ }
return null;
}