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>
379 lines
12 KiB
JavaScript
379 lines
12 KiB
JavaScript
// node:test suite for scripts/profile-loader.mjs
|
|
//
|
|
// Run: node --test scripts/profile-loader.test.mjs
|
|
//
|
|
// Covers: YAML parser subset, profile validation, agent cross-checks,
|
|
// listProfiles, loadProfile happy path, error paths.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
parseYaml,
|
|
parseScalar,
|
|
parseInlineList,
|
|
stripLineComment,
|
|
validateProfile,
|
|
loadProfile,
|
|
listProfiles,
|
|
} from './profile-loader.mjs';
|
|
|
|
// =====================================================================
|
|
// YAML parser tests
|
|
// =====================================================================
|
|
|
|
test('parseScalar: numbers, bools, null, strings', () => {
|
|
assert.equal(parseScalar('42'), 42);
|
|
assert.equal(parseScalar('-7'), -7);
|
|
assert.equal(parseScalar('3.14'), 3.14);
|
|
assert.equal(parseScalar('true'), true);
|
|
assert.equal(parseScalar('false'), false);
|
|
assert.equal(parseScalar('null'), null);
|
|
assert.equal(parseScalar('~'), null);
|
|
assert.equal(parseScalar('hello'), 'hello');
|
|
assert.equal(parseScalar('"quoted with spaces"'), 'quoted with spaces');
|
|
assert.equal(parseScalar("'single quoted'"), 'single quoted');
|
|
});
|
|
|
|
test('parseScalar: empty string returns null', () => {
|
|
assert.equal(parseScalar(''), null);
|
|
assert.equal(parseScalar(' '), null);
|
|
});
|
|
|
|
test('parseInlineList: basic', () => {
|
|
assert.deepEqual(parseInlineList('[]'), []);
|
|
assert.deepEqual(parseInlineList('[a, b, c]'), ['a', 'b', 'c']);
|
|
assert.deepEqual(parseInlineList('[1, 2, 3]'), [1, 2, 3]);
|
|
assert.deepEqual(parseInlineList('["x y", "z"]'), ['x y', 'z']);
|
|
});
|
|
|
|
test('parseInlineList: commas inside quoted strings are preserved', () => {
|
|
assert.deepEqual(parseInlineList('["a, b", "c"]'), ['a, b', 'c']);
|
|
});
|
|
|
|
test('stripLineComment: line-leading hash', () => {
|
|
assert.equal(stripLineComment('# comment'), '');
|
|
assert.equal(stripLineComment(' # indented'), '');
|
|
});
|
|
|
|
test('stripLineComment: trailing comment after value', () => {
|
|
assert.equal(stripLineComment('key: value # explanation'), 'key: value');
|
|
assert.equal(stripLineComment('key: 42 # number'), 'key: 42');
|
|
});
|
|
|
|
test('stripLineComment: hash inside quoted string is preserved', () => {
|
|
assert.equal(stripLineComment('key: "value # not a comment"'), 'key: "value # not a comment"');
|
|
});
|
|
|
|
test('parseYaml: simple flat mapping', () => {
|
|
const result = parseYaml('name: foo\nversion: 1\nactive: true');
|
|
assert.deepEqual(result, { name: 'foo', version: 1, active: true });
|
|
});
|
|
|
|
test('parseYaml: nested mapping', () => {
|
|
const text = `
|
|
axes:
|
|
depth: deep
|
|
domain: security
|
|
`;
|
|
const result = parseYaml(text);
|
|
assert.deepEqual(result, { axes: { depth: 'deep', domain: 'security' } });
|
|
});
|
|
|
|
test('parseYaml: block-style list', () => {
|
|
const text = `
|
|
agents:
|
|
- architecture-mapper
|
|
- risk-assessor
|
|
- task-finder
|
|
`;
|
|
const result = parseYaml(text);
|
|
assert.deepEqual(result, { agents: ['architecture-mapper', 'risk-assessor', 'task-finder'] });
|
|
});
|
|
|
|
test('parseYaml: inline list as scalar value', () => {
|
|
const text = 'keywords: [a, b, c]';
|
|
const result = parseYaml(text);
|
|
assert.deepEqual(result, { keywords: ['a', 'b', 'c'] });
|
|
});
|
|
|
|
test('parseYaml: full profile-shaped structure', () => {
|
|
const text = `
|
|
name: test
|
|
description: "A test profile"
|
|
version: 1
|
|
axes:
|
|
depth: standard
|
|
domain: general
|
|
triggers:
|
|
keywords: ["foo", "bar"]
|
|
nfr_signals: []
|
|
agents:
|
|
exploration:
|
|
- architecture-mapper
|
|
- task-finder
|
|
review:
|
|
- plan-critic
|
|
adversarial:
|
|
depth: deep
|
|
iterations: 2
|
|
blockers_only: false
|
|
`;
|
|
const result = parseYaml(text);
|
|
assert.equal(result.name, 'test');
|
|
assert.equal(result.description, 'A test profile');
|
|
assert.equal(result.version, 1);
|
|
assert.deepEqual(result.axes, { depth: 'standard', domain: 'general' });
|
|
assert.deepEqual(result.triggers.keywords, ['foo', 'bar']);
|
|
assert.deepEqual(result.triggers.nfr_signals, []);
|
|
assert.deepEqual(result.agents.exploration, ['architecture-mapper', 'task-finder']);
|
|
assert.deepEqual(result.agents.review, ['plan-critic']);
|
|
assert.equal(result.adversarial.depth, 'deep');
|
|
assert.equal(result.adversarial.iterations, 2);
|
|
assert.equal(result.adversarial.blockers_only, false);
|
|
});
|
|
|
|
test('parseYaml: ignores blank lines and comments', () => {
|
|
const text = `
|
|
# Header comment
|
|
|
|
name: foo
|
|
|
|
# Mid comment
|
|
version: 1
|
|
`;
|
|
const result = parseYaml(text);
|
|
assert.deepEqual(result, { name: 'foo', version: 1 });
|
|
});
|
|
|
|
test('parseYaml: throws on missing colon', () => {
|
|
assert.throws(() => parseYaml('name foo'), /Expected 'key: value'/);
|
|
});
|
|
|
|
// =====================================================================
|
|
// Helpers: build a minimal plugin tree under tmpdir
|
|
// =====================================================================
|
|
|
|
async function makeTempPluginTree(profiles, agentNames) {
|
|
const root = await mkdtemp(join(tmpdir(), 'profile-loader-test-'));
|
|
const profilesDir = join(root, 'profiles');
|
|
const agentsDir = join(root, 'agents');
|
|
await mkdir(profilesDir, { recursive: true });
|
|
await mkdir(agentsDir, { recursive: true });
|
|
for (const [name, content] of Object.entries(profiles)) {
|
|
await writeFile(join(profilesDir, `${name}.yaml`), content, 'utf8');
|
|
}
|
|
for (const a of agentNames) {
|
|
await writeFile(join(agentsDir, `${a}.md`), '# stub', 'utf8');
|
|
}
|
|
return { root, profilesDir, agentsDir };
|
|
}
|
|
|
|
// =====================================================================
|
|
// validateProfile tests
|
|
// =====================================================================
|
|
|
|
test('validateProfile: passes on minimal valid profile', async () => {
|
|
const { profilesDir, agentsDir, root } = await makeTempPluginTree(
|
|
{},
|
|
['agent-a', 'agent-b', 'agent-c']
|
|
);
|
|
try {
|
|
const profile = {
|
|
name: 'minimal',
|
|
description: 'Minimal valid profile',
|
|
version: 1,
|
|
agents: {
|
|
exploration: ['agent-a', 'agent-b'],
|
|
review: ['agent-c'],
|
|
},
|
|
};
|
|
await validateProfile(profile, { agentsDir });
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('validateProfile: rejects missing required fields', async () => {
|
|
const { agentsDir, root } = await makeTempPluginTree({}, []);
|
|
try {
|
|
await assert.rejects(
|
|
validateProfile({ name: 'x' }, { agentsDir }),
|
|
/Missing required field: description/
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('validateProfile: rejects unknown agent name', async () => {
|
|
const { agentsDir, root } = await makeTempPluginTree({}, ['real-agent']);
|
|
try {
|
|
const profile = {
|
|
name: 'bad',
|
|
description: 'has ghost agent',
|
|
version: 1,
|
|
agents: {
|
|
exploration: ['real-agent', 'ghost-agent'],
|
|
review: ['real-agent'],
|
|
},
|
|
};
|
|
await assert.rejects(
|
|
validateProfile(profile, { agentsDir }),
|
|
/Unknown agent referenced: ghost-agent/
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('validateProfile: rejects unsupported version', async () => {
|
|
const { agentsDir, root } = await makeTempPluginTree({}, ['a']);
|
|
try {
|
|
const profile = {
|
|
name: 'x',
|
|
description: 'old',
|
|
version: 99,
|
|
agents: { exploration: ['a'], review: ['a'] },
|
|
};
|
|
await assert.rejects(
|
|
validateProfile(profile, { agentsDir }),
|
|
/Unsupported profile version 99/
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('validateProfile: rejects adversarial.depth not in enum', async () => {
|
|
const { agentsDir, root } = await makeTempPluginTree({}, ['a']);
|
|
try {
|
|
const profile = {
|
|
name: 'x',
|
|
description: 'bad depth',
|
|
version: 1,
|
|
agents: { exploration: ['a'], review: ['a'] },
|
|
adversarial: { depth: 'extreme', iterations: 1, blockers_only: false },
|
|
};
|
|
await assert.rejects(
|
|
validateProfile(profile, { agentsDir }),
|
|
/adversarial\.depth must be one of/
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('validateProfile: rejects when agents key is array not mapping', async () => {
|
|
const { agentsDir, root } = await makeTempPluginTree({}, []);
|
|
try {
|
|
const profile = {
|
|
name: 'x',
|
|
description: 'wrong shape',
|
|
version: 1,
|
|
agents: ['a', 'b'],
|
|
};
|
|
await assert.rejects(
|
|
validateProfile(profile, { agentsDir }),
|
|
/agents must be a mapping/
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// listProfiles + loadProfile
|
|
// =====================================================================
|
|
|
|
test('listProfiles: returns sorted basenames without extension', async () => {
|
|
const { profilesDir, root } = await makeTempPluginTree(
|
|
{
|
|
'zebra': 'name: z\ndescription: z\nversion: 1\nagents:\n exploration: []\n review: []',
|
|
'alpha': 'name: a\ndescription: a\nversion: 1\nagents:\n exploration: []\n review: []',
|
|
'middle': 'name: m\ndescription: m\nversion: 1\nagents:\n exploration: []\n review: []',
|
|
},
|
|
[]
|
|
);
|
|
try {
|
|
const names = await listProfiles({ profilesDir });
|
|
assert.deepEqual(names, ['alpha', 'middle', 'zebra']);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('listProfiles: empty when directory missing', async () => {
|
|
const names = await listProfiles({ profilesDir: '/tmp/does-not-exist-xyz' });
|
|
assert.deepEqual(names, []);
|
|
});
|
|
|
|
test('loadProfile: parses + validates an actual file', async () => {
|
|
const yaml = `
|
|
name: t
|
|
description: "Test"
|
|
version: 1
|
|
axes:
|
|
depth: standard
|
|
domain: general
|
|
agents:
|
|
exploration:
|
|
- agent-a
|
|
review:
|
|
- agent-b
|
|
`;
|
|
const { profilesDir, agentsDir, root } = await makeTempPluginTree(
|
|
{ 't': yaml },
|
|
['agent-a', 'agent-b']
|
|
);
|
|
try {
|
|
const p = await loadProfile('t', { profilesDir, agentsDir });
|
|
assert.equal(p.name, 't');
|
|
assert.deepEqual(p.agents.exploration, ['agent-a']);
|
|
assert.deepEqual(p.agents.review, ['agent-b']);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('loadProfile: throws helpful message when name missing', async () => {
|
|
const { profilesDir, agentsDir, root } = await makeTempPluginTree(
|
|
{
|
|
'real': 'name: r\ndescription: r\nversion: 1\nagents:\n exploration: []\n review: []',
|
|
},
|
|
[]
|
|
);
|
|
try {
|
|
await assert.rejects(
|
|
loadProfile('not-real', { profilesDir, agentsDir }),
|
|
/Profile 'not-real' not found.*Available: real/
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// Integration: built-in default.yaml
|
|
// =====================================================================
|
|
|
|
test('built-in default.yaml: parses and validates', async () => {
|
|
// No path overrides — use the actual plugin profiles/ + agents/ dirs.
|
|
const profile = await loadProfile('default');
|
|
assert.equal(profile.name, 'default');
|
|
assert.equal(profile.version, 1);
|
|
assert.ok(Array.isArray(profile.agents.exploration));
|
|
assert.ok(Array.isArray(profile.agents.review));
|
|
// Specific agents should be the current Phase 5/9 set.
|
|
assert.ok(profile.agents.exploration.includes('architecture-mapper'));
|
|
assert.ok(profile.agents.exploration.includes('task-finder'));
|
|
assert.ok(profile.agents.review.includes('plan-critic'));
|
|
assert.ok(profile.agents.review.includes('scope-guardian'));
|
|
});
|
|
|
|
test('listProfiles: includes default', async () => {
|
|
const names = await listProfiles();
|
|
assert.ok(names.includes('default'), `Expected default in ${names.join(', ')}`);
|
|
});
|