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.
This commit is contained in:
Lauren ten Hoor
2026-02-15 18:32:10 +08:00
parent 6a99752e5f
commit 0e24a68882
44 changed files with 1162 additions and 762 deletions

14
lib/config/index.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* config/ — Unified DevClaw configuration.
*
* Single config.yaml per workspace/project combining roles, models, and workflow.
*/
export type {
DevClawConfig,
RoleOverride,
ResolvedConfig,
ResolvedRoleConfig,
} from "./types.js";
export { loadConfig } from "./loader.js";
export { mergeConfig } from "./merge.js";

170
lib/config/loader.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* 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;
}

82
lib/config/merge.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* config/merge.ts — Deep merge for DevClaw config layers.
*
* Merge semantics:
* - Objects: recursively merge (sparse override)
* - Arrays: replace entirely (no merging array elements)
* - `false` for a role: marks it as disabled
* - Primitives: override
*/
import type { DevClawConfig, RoleOverride } from "./types.js";
/**
* Merge a config overlay on top of a base config.
* Returns a new config — does not mutate inputs.
*/
export function mergeConfig(
base: DevClawConfig,
overlay: DevClawConfig,
): DevClawConfig {
const merged: DevClawConfig = {};
// Merge roles
if (base.roles || overlay.roles) {
merged.roles = { ...base.roles };
if (overlay.roles) {
for (const [roleId, overrideValue] of Object.entries(overlay.roles)) {
if (overrideValue === false) {
// Disable role
merged.roles[roleId] = false;
} else if (merged.roles[roleId] === false) {
// Re-enable with override
merged.roles[roleId] = overrideValue;
} else {
// Merge role override on top of base role
const baseRole = merged.roles[roleId];
merged.roles[roleId] = mergeRoleOverride(
typeof baseRole === "object" ? baseRole : {},
overrideValue,
);
}
}
}
}
// Merge workflow
if (base.workflow || overlay.workflow) {
merged.workflow = {
initial: overlay.workflow?.initial ?? base.workflow?.initial,
states: {
...base.workflow?.states,
...overlay.workflow?.states,
},
};
// Clean up undefined initial
if (merged.workflow.initial === undefined) {
delete merged.workflow.initial;
}
}
return merged;
}
function mergeRoleOverride(
base: RoleOverride,
overlay: RoleOverride,
): RoleOverride {
return {
...base,
...overlay,
// Models: merge (don't replace)
models: base.models || overlay.models
? { ...base.models, ...overlay.models }
: undefined,
// Emoji: merge (don't replace)
emoji: base.emoji || overlay.emoji
? { ...base.emoji, ...overlay.emoji }
: undefined,
// Arrays replace entirely
...(overlay.levels ? { levels: overlay.levels } : {}),
...(overlay.completionResults ? { completionResults: overlay.completionResults } : {}),
};
}

49
lib/config/types.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* config/types.ts — Types for the unified DevClaw configuration.
*
* A single config.yaml combines roles, models, and workflow.
* Three-layer resolution: built-in → workspace → per-project.
*/
import type { WorkflowConfig } from "../workflow.js";
/**
* Role override in config.yaml. All fields optional — only override what you need.
* Set to `false` to disable a role entirely for a project.
*/
export type RoleOverride = {
levels?: string[];
defaultLevel?: string;
models?: Record<string, string>;
emoji?: Record<string, string>;
completionResults?: string[];
};
/**
* The full config.yaml shape.
* All fields optional — missing fields inherit from the layer below.
*/
export type DevClawConfig = {
roles?: Record<string, RoleOverride | false>;
workflow?: Partial<WorkflowConfig>;
};
/**
* Fully resolved config — all fields guaranteed present.
* Built by merging three layers over the built-in defaults.
*/
export type ResolvedConfig = {
roles: Record<string, ResolvedRoleConfig>;
workflow: WorkflowConfig;
};
/**
* Fully resolved role config — all fields present.
*/
export type ResolvedRoleConfig = {
levels: string[];
defaultLevel: string;
models: Record<string, string>;
emoji: Record<string, string>;
completionResults: string[];
enabled: boolean;
};