aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
258 lines (216 loc) • 7.94 kB
JavaScript
/**
* BehaviorRegistry - Discovers, loads, validates, and merges behavior YAML files
*
* Three-tier discovery (later tiers override earlier):
* 1. agentic/code/behaviors/ — framework-shipped (read-only)
* 2. .aiwg/behaviors/ — project-local overrides
* 3. ~/.config/aiwg/behaviors/ — user-level overrides
*
* @implements #515
* @tests @test/unit/ralph-external/behavior-registry.test.mjs
*/
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import YAML from 'yaml';
const REQUIRED_FIELDS = ['name', 'version', 'agentTypes', 'directives', 'tools'];
export class BehaviorRegistry {
/**
* @param {object} options
* @param {string} options.projectRoot - Project root directory
* @param {string} [options.frameworkRoot] - AIWG framework root (defaults to projectRoot)
*/
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.frameworkRoot = options.frameworkRoot || this.projectRoot;
this._behaviors = new Map(); // name -> Behavior
this._loaded = false;
// Discovery tier paths (priority order, later wins)
this._tiers = [
{ name: 'framework', path: join(this.frameworkRoot, 'agentic', 'code', 'behaviors') },
{ name: 'project', path: join(this.projectRoot, '.aiwg', 'behaviors') },
{ name: 'user', path: join(homedir(), '.config', 'aiwg', 'behaviors') },
];
}
/**
* Discover all behavior YAML files across all tiers.
* Returns array of file paths found.
*/
async discover() {
const discovered = [];
for (const tier of this._tiers) {
try {
const entries = await readdir(tier.path);
const yamlFiles = entries.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
for (const file of yamlFiles) {
discovered.push({ tier: tier.name, path: join(tier.path, file), file });
}
} catch (err) {
// Directory doesn't exist — skip silently
if (err.code !== 'ENOENT') throw err;
}
}
return discovered;
}
/**
* Load a single behavior by name.
* Searches all tiers; last-found (highest tier) wins.
*/
async load(name) {
const discovered = await this.discover();
const matching = discovered.filter(d => {
const baseName = d.file.replace(/\.(yaml|yml)$/, '');
return baseName === name;
});
if (matching.length === 0) {
throw new Error(`Behavior '${name}' not found in any discovery tier`);
}
// Last match wins (highest tier)
const entry = matching[matching.length - 1];
const raw = await readFile(entry.path, 'utf-8');
const parsed = YAML.parse(raw);
const validation = this.validate(parsed);
if (!validation.valid) {
throw new Error(`Invalid behavior '${name}': ${validation.errors.join(', ')}`);
}
parsed._tier = entry.tier;
parsed._path = entry.path;
return parsed;
}
/**
* Load all discovered behaviors. Later tiers override earlier ones.
*/
async loadAll() {
const discovered = await this.discover();
const byName = new Map();
for (const entry of discovered) {
try {
const raw = await readFile(entry.path, 'utf-8');
const parsed = YAML.parse(raw);
const validation = this.validate(parsed);
if (!validation.valid) {
console.warn(`Skipping invalid behavior at ${entry.path}: ${validation.errors.join(', ')}`);
continue;
}
parsed._tier = entry.tier;
parsed._path = entry.path;
if (byName.has(parsed.name)) {
console.warn(
`Behavior '${parsed.name}' from ${entry.tier} tier overrides ${byName.get(parsed.name)._tier} tier`
);
}
byName.set(parsed.name, parsed);
} catch (err) {
console.warn(`Failed to load behavior at ${entry.path}: ${err.message}`);
}
}
this._behaviors = byName;
this._loaded = true;
return byName;
}
/**
* Get directives applicable to a given agent type.
*/
getDirectives(agentType) {
const directives = [];
for (const behavior of this._behaviors.values()) {
if (!this._matchesAgentType(behavior, agentType)) continue;
for (const directive of behavior.directives || []) {
directives.push({ ...directive, _behaviorName: behavior.name });
}
}
return directives;
}
/**
* Get toolset applicable to a given agent type.
*/
getToolset(agentType) {
const tools = [];
for (const behavior of this._behaviors.values()) {
if (!this._matchesAgentType(behavior, agentType)) continue;
for (const tool of behavior.tools || []) {
tools.push({ ...tool, _behaviorName: behavior.name });
}
}
return tools;
}
/**
* Validate a parsed behavior YAML against the required schema.
*/
validate(behaviorYaml) {
const errors = [];
if (!behaviorYaml || typeof behaviorYaml !== 'object') {
return { valid: false, errors: ['Behavior must be a non-null object'] };
}
for (const field of REQUIRED_FIELDS) {
if (behaviorYaml[field] === undefined || behaviorYaml[field] === null) {
errors.push(`Missing required field: '${field}'`);
}
}
if (behaviorYaml.agentTypes && !Array.isArray(behaviorYaml.agentTypes)) {
errors.push("'agentTypes' must be an array");
}
if (behaviorYaml.directives && !Array.isArray(behaviorYaml.directives)) {
errors.push("'directives' must be an array");
} else if (Array.isArray(behaviorYaml.directives)) {
for (const [i, d] of behaviorYaml.directives.entries()) {
if (!d.id) errors.push(`Directive at index ${i} missing 'id'`);
if (!d.rule) errors.push(`Directive at index ${i} missing 'rule'`);
}
}
if (behaviorYaml.tools && !Array.isArray(behaviorYaml.tools)) {
errors.push("'tools' must be an array");
} else if (Array.isArray(behaviorYaml.tools)) {
for (const [i, t] of behaviorYaml.tools.entries()) {
if (!t.tool) errors.push(`Tool at index ${i} missing 'tool'`);
if (!t.description) errors.push(`Tool at index ${i} missing 'description'`);
}
}
return { valid: errors.length === 0, errors };
}
/**
* Merge multiple behaviors. Returns combined directives and toolset.
* Directive collisions: last wins (with warning).
* Toolset collisions: error.
*/
merge(behaviors) {
const directiveMap = new Map(); // id -> directive
const toolMap = new Map(); // tool name -> tool
const warnings = [];
for (const behavior of behaviors) {
for (const directive of behavior.directives || []) {
if (directiveMap.has(directive.id)) {
warnings.push(
`Directive '${directive.id}' from '${behavior.name}' overrides previous definition`
);
}
directiveMap.set(directive.id, { ...directive, _behaviorName: behavior.name });
}
for (const tool of behavior.tools || []) {
if (toolMap.has(tool.tool)) {
throw new Error(
`Tool name collision: '${tool.tool}' defined in both ` +
`'${toolMap.get(tool.tool)._behaviorName}' and '${behavior.name}'`
);
}
toolMap.set(tool.tool, { ...tool, _behaviorName: behavior.name });
}
}
return {
directives: Array.from(directiveMap.values()),
toolset: Array.from(toolMap.values()),
warnings,
};
}
/**
* Get discovery tier info (for CLI display).
*/
getTierInfo() {
return this._tiers.map(t => ({ name: t.name, path: t.path }));
}
// --- Private ---
_matchesAgentType(behavior, agentType) {
if (!behavior.agentTypes || !Array.isArray(behavior.agentTypes)) return false;
return behavior.agentTypes.includes(agentType) || behavior.agentTypes.includes('*');
}
}