Introduces a profile-loader infrastructure for runtime-instantiable ultraplan variants (depth × domain × goal axes). M0 ships only the `default` profile, which mirrors the current hardcoded Phase 5/9 agent set — so existing flows are unaffected. What lands: - profiles/default.yaml — schema v1, lists current 8 exploration agents + 2 review agents, captures today's adversarial regime - scripts/profile-loader.mjs — null-deps Node loader with limited-subset YAML parser, listProfiles(), loadProfile(), validateProfile() that cross-checks every referenced agent exists in agents/ - scripts/profile-loader.test.mjs — 26 node:test cases (parser, validation, loader, integration with built-in default.yaml) - commands/ultraplan-local.md — Phase 1 gains a "Resolve the profile" step (--profile flag → brief.recommended_profile → default fallback) and prints profile + source in the mode report. Phase 5/9 unchanged. - README.md, CLAUDE.md, marketplace README — documentation of the M0 foundation, the universal-brief design principle, and the M1/M2/M3 milestones to come. M1 (next) wires profile recommendation into ultrabrief Phase 4. M2 ships the additional built-in profiles (quick, bugfix, feature, refactor, security-deep, research-heavy) and replaces the hardcoded Phase 5 agent table with profile-driven selection. M3 adds user-extensible profiles. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
451 lines
14 KiB
JavaScript
451 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// profile-loader.mjs — Load and validate ultraplan-local profile YAML files.
|
|
//
|
|
// Profiles describe which agents, skills, and review regime ultraplan-local
|
|
// should use. They live in two locations:
|
|
// - Built-in: plugins/ultraplan-local/profiles/*.yaml
|
|
// - User (M3): .claude/ultraplan-profiles/ and ~/.claude/ultraplan-profiles/
|
|
//
|
|
// M0 ships with built-in discovery only.
|
|
//
|
|
// The YAML parser here is intentionally a *limited* subset — enough for the
|
|
// profile schema, no more. Supports:
|
|
// - Top-level scalars (string, number, bool, null)
|
|
// - Nested mappings via 2-space indent
|
|
// - Block-style lists ('- item')
|
|
// - Inline lists ('[a, b, c]')
|
|
// - Quoted strings ("..." or '...')
|
|
// - Line comments (# at line start, or after whitespace)
|
|
// NOT supported: anchors/aliases, multi-line strings, flow mappings,
|
|
// tagged values. If you need those, the schema has drifted — fix the schema.
|
|
//
|
|
// Pure Node stdlib. No npm dependencies.
|
|
//
|
|
// CLI:
|
|
// node scripts/profile-loader.mjs list
|
|
// node scripts/profile-loader.mjs load <name>
|
|
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import { join, dirname, basename } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { argv, exit, stdout, stderr } from 'node:process';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PLUGIN_ROOT = join(__dirname, '..');
|
|
const PROFILES_DIR = join(PLUGIN_ROOT, 'profiles');
|
|
const AGENTS_DIR = join(PLUGIN_ROOT, 'agents');
|
|
|
|
// === Required profile fields (validated by validateProfile) ===
|
|
export const REQUIRED_FIELDS = ['name', 'description', 'version', 'agents'];
|
|
export const REQUIRED_AGENT_KEYS = ['exploration', 'review'];
|
|
|
|
// =====================================================================
|
|
// YAML parser (limited subset)
|
|
// =====================================================================
|
|
|
|
/**
|
|
* Strip a line-trailing comment, respecting quoted strings.
|
|
* Returns the line with any '# ...' suffix removed (when '#' is preceded by
|
|
* whitespace or starts the line) and trailing whitespace trimmed.
|
|
*/
|
|
export function stripLineComment(line) {
|
|
let inQuote = null;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (inQuote) {
|
|
if (ch === '\\') { i++; continue; }
|
|
if (ch === inQuote) inQuote = null;
|
|
} else if (ch === '"' || ch === "'") {
|
|
inQuote = ch;
|
|
} else if (ch === '#') {
|
|
const prev = i === 0 ? ' ' : line[i - 1];
|
|
if (prev === ' ' || prev === '\t' || i === 0) {
|
|
return line.slice(0, i).replace(/\s+$/, '');
|
|
}
|
|
}
|
|
}
|
|
return line.replace(/\s+$/, '');
|
|
}
|
|
|
|
/**
|
|
* Pre-process YAML text into an array of {indent, content} entries.
|
|
* Strips comments and blank lines. Preserves indent for structural parsing.
|
|
*/
|
|
function preprocessLines(text) {
|
|
const out = [];
|
|
for (const raw of text.split('\n')) {
|
|
const stripped = stripLineComment(raw);
|
|
if (stripped.trim() === '') continue;
|
|
const indent = stripped.length - stripped.trimStart().length;
|
|
out.push({ indent, content: stripped.trim() });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Parse a scalar (right-hand side of `key: value` or list item).
|
|
* Recognises quoted strings, bools, null, numbers, inline lists, otherwise
|
|
* returns the raw string.
|
|
*/
|
|
export function parseScalar(text) {
|
|
const t = text.trim();
|
|
if (t === '') return null;
|
|
if (t === 'null' || t === '~') return null;
|
|
if (t === 'true') return true;
|
|
if (t === 'false') return false;
|
|
if (t.startsWith('[') && t.endsWith(']')) return parseInlineList(t);
|
|
if (t.startsWith('"') && t.endsWith('"')) return unescapeString(t.slice(1, -1));
|
|
if (t.startsWith("'") && t.endsWith("'")) return t.slice(1, -1);
|
|
// Number? (integer or simple float, no scientific notation needed)
|
|
if (/^-?\d+$/.test(t)) return parseInt(t, 10);
|
|
if (/^-?\d+\.\d+$/.test(t)) return parseFloat(t);
|
|
return t;
|
|
}
|
|
|
|
function unescapeString(s) {
|
|
return s
|
|
.replace(/\\n/g, '\n')
|
|
.replace(/\\t/g, '\t')
|
|
.replace(/\\"/g, '"')
|
|
.replace(/\\\\/g, '\\');
|
|
}
|
|
|
|
/**
|
|
* Parse an inline list like `[a, b, "c d", 1, 2]`.
|
|
* Naive split on commas — does not support nested lists/mappings (not needed).
|
|
*/
|
|
export function parseInlineList(text) {
|
|
const inner = text.slice(1, -1).trim();
|
|
if (inner === '') return [];
|
|
const items = [];
|
|
let depth = 0;
|
|
let inQuote = null;
|
|
let buf = '';
|
|
for (let i = 0; i < inner.length; i++) {
|
|
const ch = inner[i];
|
|
if (inQuote) {
|
|
buf += ch;
|
|
if (ch === '\\') { buf += inner[++i] ?? ''; continue; }
|
|
if (ch === inQuote) inQuote = null;
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") { inQuote = ch; buf += ch; continue; }
|
|
if (ch === '[') depth++;
|
|
if (ch === ']') depth--;
|
|
if (ch === ',' && depth === 0) {
|
|
items.push(parseScalar(buf));
|
|
buf = '';
|
|
continue;
|
|
}
|
|
buf += ch;
|
|
}
|
|
if (buf.trim() !== '') items.push(parseScalar(buf));
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Parse a block-style list at the given indent. Returns [items, nextIdx].
|
|
* List items are simple scalars only (sufficient for profile schema).
|
|
*/
|
|
function parseList(lines, idx, indent) {
|
|
const items = [];
|
|
while (idx < lines.length && lines[idx].indent === indent && lines[idx].content.startsWith('- ')) {
|
|
const itemText = lines[idx].content.slice(2).trim();
|
|
items.push(parseScalar(itemText));
|
|
idx++;
|
|
}
|
|
return [items, idx];
|
|
}
|
|
|
|
/**
|
|
* Parse a mapping at the given indent. Returns [obj, nextIdx].
|
|
* A mapping key may be followed by a scalar (same line), a nested mapping,
|
|
* or a block-list (indented more on subsequent lines).
|
|
*/
|
|
function parseMapping(lines, idx, indent) {
|
|
const result = {};
|
|
while (idx < lines.length && lines[idx].indent === indent) {
|
|
const { content } = lines[idx];
|
|
if (content.startsWith('- ')) break; // not a mapping line
|
|
const colonIdx = findUnquotedColon(content);
|
|
if (colonIdx === -1) {
|
|
throw new Error(`Expected 'key: value' at indent ${indent}, got: ${content}`);
|
|
}
|
|
const key = content.slice(0, colonIdx).trim();
|
|
const rhs = content.slice(colonIdx + 1).trim();
|
|
idx++;
|
|
if (rhs === '') {
|
|
// Look for nested mapping or list at deeper indent
|
|
if (idx < lines.length && lines[idx].indent > indent) {
|
|
const childIndent = lines[idx].indent;
|
|
if (lines[idx].content.startsWith('- ')) {
|
|
const [list, nextIdx] = parseList(lines, idx, childIndent);
|
|
result[key] = list;
|
|
idx = nextIdx;
|
|
} else {
|
|
const [obj, nextIdx] = parseMapping(lines, idx, childIndent);
|
|
result[key] = obj;
|
|
idx = nextIdx;
|
|
}
|
|
} else {
|
|
result[key] = null;
|
|
}
|
|
} else {
|
|
result[key] = parseScalar(rhs);
|
|
}
|
|
}
|
|
return [result, idx];
|
|
}
|
|
|
|
function findUnquotedColon(s) {
|
|
let inQuote = null;
|
|
for (let i = 0; i < s.length; i++) {
|
|
const ch = s[i];
|
|
if (inQuote) {
|
|
if (ch === '\\') { i++; continue; }
|
|
if (ch === inQuote) inQuote = null;
|
|
} else if (ch === '"' || ch === "'") {
|
|
inQuote = ch;
|
|
} else if (ch === ':') {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Public: parse a YAML document into a plain JS object.
|
|
* Throws on malformed input.
|
|
*/
|
|
export function parseYaml(text) {
|
|
const lines = preprocessLines(text);
|
|
if (lines.length === 0) return {};
|
|
const baseIndent = lines[0].indent;
|
|
if (lines[0].content.startsWith('- ')) {
|
|
const [list] = parseList(lines, 0, baseIndent);
|
|
return list;
|
|
}
|
|
const [obj] = parseMapping(lines, 0, baseIndent);
|
|
return obj;
|
|
}
|
|
|
|
// =====================================================================
|
|
// Profile loader
|
|
// =====================================================================
|
|
|
|
/**
|
|
* List all built-in profiles by name (filename minus .yaml).
|
|
* Sorted alphabetically.
|
|
*/
|
|
export async function listProfiles({ profilesDir = PROFILES_DIR } = {}) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(profilesDir);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return [];
|
|
throw err;
|
|
}
|
|
const names = entries
|
|
.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'))
|
|
.map((f) => f.replace(/\.ya?ml$/, ''))
|
|
.sort();
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Load and validate a profile by name.
|
|
* Throws if the file is missing, malformed, or fails validation.
|
|
*/
|
|
export async function loadProfile(name, opts = {}) {
|
|
const profilesDir = opts.profilesDir ?? PROFILES_DIR;
|
|
const agentsDir = opts.agentsDir ?? AGENTS_DIR;
|
|
const path = await resolveProfilePath(name, profilesDir);
|
|
if (!path) {
|
|
const available = await listProfiles({ profilesDir });
|
|
throw new Error(
|
|
`Profile '${name}' not found in ${profilesDir}. Available: ${available.join(', ') || '(none)'}.`
|
|
);
|
|
}
|
|
const text = await readFile(path, 'utf8');
|
|
let parsed;
|
|
try {
|
|
parsed = parseYaml(text);
|
|
} catch (err) {
|
|
throw new Error(`Failed to parse profile '${name}' (${path}): ${err.message}`);
|
|
}
|
|
await validateProfile(parsed, { agentsDir, sourcePath: path });
|
|
return parsed;
|
|
}
|
|
|
|
async function resolveProfilePath(name, profilesDir) {
|
|
const candidates = [join(profilesDir, `${name}.yaml`), join(profilesDir, `${name}.yml`)];
|
|
for (const c of candidates) {
|
|
try {
|
|
const s = await stat(c);
|
|
if (s.isFile()) return c;
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate a parsed profile object against the v1 schema.
|
|
* Throws an Error with all validation issues bundled.
|
|
*/
|
|
export async function validateProfile(profile, opts = {}) {
|
|
const agentsDir = opts.agentsDir ?? AGENTS_DIR;
|
|
const issues = [];
|
|
|
|
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
throw new Error('Profile must be a YAML mapping at the top level.');
|
|
}
|
|
|
|
for (const field of REQUIRED_FIELDS) {
|
|
if (!(field in profile)) issues.push(`Missing required field: ${field}`);
|
|
}
|
|
|
|
if ('version' in profile && profile.version !== 1) {
|
|
issues.push(`Unsupported profile version ${profile.version} (expected 1).`);
|
|
}
|
|
|
|
if ('agents' in profile) {
|
|
const agents = profile.agents;
|
|
if (!agents || typeof agents !== 'object' || Array.isArray(agents)) {
|
|
issues.push('agents must be a mapping with exploration and review keys.');
|
|
} else {
|
|
for (const k of REQUIRED_AGENT_KEYS) {
|
|
if (!(k in agents)) issues.push(`agents.${k} is required.`);
|
|
else if (!Array.isArray(agents[k])) issues.push(`agents.${k} must be a list of agent names.`);
|
|
}
|
|
// Cross-check: every agent name must exist in agents/<name>.md
|
|
const allAgents = [
|
|
...(Array.isArray(agents.exploration) ? agents.exploration : []),
|
|
...(Array.isArray(agents.review) ? agents.review : []),
|
|
];
|
|
const missing = await missingAgents(allAgents, agentsDir);
|
|
for (const m of missing) issues.push(`Unknown agent referenced: ${m} (no ${agentsDir}/${m}.md).`);
|
|
}
|
|
}
|
|
|
|
if ('axes' in profile && profile.axes !== null) {
|
|
if (typeof profile.axes !== 'object' || Array.isArray(profile.axes)) {
|
|
issues.push('axes must be a mapping (or omitted).');
|
|
}
|
|
}
|
|
|
|
if ('triggers' in profile && profile.triggers !== null) {
|
|
if (typeof profile.triggers !== 'object' || Array.isArray(profile.triggers)) {
|
|
issues.push('triggers must be a mapping (or omitted).');
|
|
} else {
|
|
for (const k of ['keywords', 'nfr_signals']) {
|
|
if (k in profile.triggers && !Array.isArray(profile.triggers[k])) {
|
|
issues.push(`triggers.${k} must be a list.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ('skills' in profile && profile.skills !== null) {
|
|
if (typeof profile.skills !== 'object' || Array.isArray(profile.skills)) {
|
|
issues.push('skills must be a mapping (or omitted).');
|
|
} else {
|
|
for (const k of ['catalog_filter', 'prefer_layer']) {
|
|
if (k in profile.skills && !Array.isArray(profile.skills[k])) {
|
|
issues.push(`skills.${k} must be a list.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ('adversarial' in profile && profile.adversarial !== null) {
|
|
const adv = profile.adversarial;
|
|
if (typeof adv !== 'object' || Array.isArray(adv)) {
|
|
issues.push('adversarial must be a mapping (or omitted).');
|
|
} else {
|
|
if ('depth' in adv && !['light', 'standard', 'deep'].includes(adv.depth)) {
|
|
issues.push(`adversarial.depth must be one of light|standard|deep, got: ${adv.depth}`);
|
|
}
|
|
if ('iterations' in adv && (typeof adv.iterations !== 'number' || adv.iterations < 1)) {
|
|
issues.push('adversarial.iterations must be a positive number.');
|
|
}
|
|
if ('blockers_only' in adv && typeof adv.blockers_only !== 'boolean') {
|
|
issues.push('adversarial.blockers_only must be a boolean.');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (issues.length > 0) {
|
|
const src = opts.sourcePath ? ` (${opts.sourcePath})` : '';
|
|
throw new Error(`Profile validation failed${src}:\n - ${issues.join('\n - ')}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function missingAgents(names, agentsDir) {
|
|
const missing = [];
|
|
for (const n of names) {
|
|
if (typeof n !== 'string') {
|
|
missing.push(String(n));
|
|
continue;
|
|
}
|
|
try {
|
|
const s = await stat(join(agentsDir, `${n}.md`));
|
|
if (!s.isFile()) missing.push(n);
|
|
} catch {
|
|
missing.push(n);
|
|
}
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
// =====================================================================
|
|
// CLI
|
|
// =====================================================================
|
|
|
|
const USAGE = `Usage:
|
|
node scripts/profile-loader.mjs list
|
|
node scripts/profile-loader.mjs load <name>
|
|
node scripts/profile-loader.mjs validate <path-to-yaml>
|
|
`;
|
|
|
|
async function main() {
|
|
const args = argv.slice(2);
|
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
stdout.write(USAGE);
|
|
return;
|
|
}
|
|
const cmd = args[0];
|
|
if (cmd === 'list') {
|
|
const names = await listProfiles();
|
|
for (const n of names) stdout.write(n + '\n');
|
|
return;
|
|
}
|
|
if (cmd === 'load') {
|
|
const name = args[1];
|
|
if (!name) { stderr.write('load requires <name>\n'); exit(2); }
|
|
const profile = await loadProfile(name);
|
|
stdout.write(JSON.stringify(profile, null, 2) + '\n');
|
|
return;
|
|
}
|
|
if (cmd === 'validate') {
|
|
const path = args[1];
|
|
if (!path) { stderr.write('validate requires <path>\n'); exit(2); }
|
|
const text = await readFile(path, 'utf8');
|
|
const parsed = parseYaml(text);
|
|
await validateProfile(parsed, { sourcePath: path });
|
|
stdout.write(`OK: ${basename(path)} validates against profile schema v1\n`);
|
|
return;
|
|
}
|
|
stderr.write(USAGE);
|
|
exit(2);
|
|
}
|
|
|
|
// Run CLI only if invoked directly, not when imported.
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main().catch((err) => {
|
|
stderr.write(`Error: ${err.message}\n`);
|
|
exit(1);
|
|
});
|
|
}
|