Adds the profile recommendation step to /ultrabrief-local Phase 4. The brief stays universal (same questions, same template); the new step is purely a processing-decision layer that records which profile downstream commands should apply. What lands: - agents/profile-recommender.md — new sonnet agent that scores available profiles against the finalized brief (keyword + NFR-signal matching, axis bumps, hallucination gate that forbids inventing profile names). Emits a fenced JSON block with ranked entries. - templates/ultrabrief-template.md — frontmatter gains recommended_profile, profile_match, profile_rationale (default values applied when only `default` is available — true at M1). - commands/ultrabrief-local.md — Phase 4 gains Step 4h with explicit branches: short-circuit when only `default` exists; AskUserQuestion confirmation when top score ≥ 0.7; explicit fallback message when below threshold; manual selection sub-question on user override. Persists the three frontmatter fields to brief.md after user confirmation. JSON parser failure falls back to `default` with `profile_match: fallback` rather than blocking — silent fallback is the worst outcome, but a *visible* fallback is acceptable. - scripts/profile-loader.mjs — adds selectRecommendation(ranked, opts) + RECOMMENDATION_THRESHOLD=0.7 export. Single source of truth for the threshold logic so the command spec and the helper agree. - scripts/profile-loader.test.mjs — 10 new tests for selectRecommendation (default-only, empty/malformed input, above/below threshold, custom threshold, max-by-score, missing fields). Total now 36/36. - README.md / CLAUDE.md / marketplace landing — docs reflect M0 + M1 shipped, M2 + M3 still pending. In practice nothing changes for users at M1 because only `default` is available — Step 4h takes the short-circuit path and writes `profile_match: default-only`. M2 ships the additional profiles that make the recommender meaningful. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
477 lines
16 KiB
JavaScript
477 lines
16 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,
|
|
selectRecommendation,
|
|
RECOMMENDATION_THRESHOLD,
|
|
} 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(', ')}`);
|
|
});
|
|
|
|
// =====================================================================
|
|
// selectRecommendation tests (M1)
|
|
// =====================================================================
|
|
|
|
test('selectRecommendation: only-default short-circuit', () => {
|
|
const result = selectRecommendation([], { availableProfiles: ['default'] });
|
|
assert.equal(result.profile, 'default');
|
|
assert.equal(result.match, 'default-only');
|
|
assert.equal(result.source, 'default-only');
|
|
});
|
|
|
|
test('selectRecommendation: empty ranked input falls back', () => {
|
|
const result = selectRecommendation([]);
|
|
assert.equal(result.profile, 'default');
|
|
assert.equal(result.match, 'fallback');
|
|
assert.equal(result.source, 'fallback');
|
|
assert.match(result.rationale, /no ranked profiles/);
|
|
});
|
|
|
|
test('selectRecommendation: malformed ranked input falls back', () => {
|
|
const result = selectRecommendation([null, { not_a_profile: true }]);
|
|
assert.equal(result.profile, 'default');
|
|
assert.equal(result.source, 'fallback');
|
|
});
|
|
|
|
test('selectRecommendation: top score above threshold returns recommendation', () => {
|
|
const ranked = [
|
|
{ name: 'security-deep', score: 0.91, match_quality: 'exact', rationale: 'OWASP + JWT in Intent.' },
|
|
{ name: 'default', score: 0.30, match_quality: 'fallback', rationale: 'No triggers.' },
|
|
];
|
|
const result = selectRecommendation(ranked);
|
|
assert.equal(result.profile, 'security-deep');
|
|
assert.equal(result.match, 'exact');
|
|
assert.equal(result.source, 'recommended');
|
|
assert.equal(result.rationale, 'OWASP + JWT in Intent.');
|
|
});
|
|
|
|
test('selectRecommendation: top score below threshold falls back to default', () => {
|
|
const ranked = [
|
|
{ name: 'feature', score: 0.55, match_quality: 'partial', rationale: 'Some keyword hits.' },
|
|
{ name: 'default', score: 0.30, match_quality: 'fallback', rationale: 'Baseline.' },
|
|
];
|
|
const result = selectRecommendation(ranked);
|
|
assert.equal(result.profile, 'default');
|
|
assert.equal(result.match, 'fallback');
|
|
assert.equal(result.source, 'fallback');
|
|
// Rationale should reference both the score and the top entry's rationale
|
|
assert.match(result.rationale, /0\.55/);
|
|
assert.match(result.rationale, /Some keyword hits/);
|
|
});
|
|
|
|
test('selectRecommendation: respects custom threshold', () => {
|
|
const ranked = [
|
|
{ name: 'feature', score: 0.55, match_quality: 'partial', rationale: 'Match.' },
|
|
];
|
|
// With low threshold the same entry IS the recommendation
|
|
const result = selectRecommendation(ranked, { threshold: 0.5 });
|
|
assert.equal(result.profile, 'feature');
|
|
assert.equal(result.source, 'recommended');
|
|
});
|
|
|
|
test('selectRecommendation: highest-score wins regardless of input order', () => {
|
|
const ranked = [
|
|
{ name: 'a', score: 0.40, match_quality: 'partial', rationale: 'Low.' },
|
|
{ name: 'b', score: 0.95, match_quality: 'exact', rationale: 'High.' },
|
|
{ name: 'c', score: 0.72, match_quality: 'partial', rationale: 'Mid.' },
|
|
];
|
|
const result = selectRecommendation(ranked);
|
|
assert.equal(result.profile, 'b');
|
|
assert.equal(result.source, 'recommended');
|
|
});
|
|
|
|
test('selectRecommendation: missing score treated as 0', () => {
|
|
const ranked = [
|
|
{ name: 'a', match_quality: 'fallback', rationale: 'No score.' },
|
|
];
|
|
const result = selectRecommendation(ranked);
|
|
// Top entry has effective score 0 → falls back
|
|
assert.equal(result.profile, 'default');
|
|
assert.equal(result.source, 'fallback');
|
|
});
|
|
|
|
test('selectRecommendation: missing rationale gets a synthetic one', () => {
|
|
const ranked = [
|
|
{ name: 'security-deep', score: 0.85, match_quality: 'exact' },
|
|
];
|
|
const result = selectRecommendation(ranked);
|
|
assert.equal(result.profile, 'security-deep');
|
|
assert.match(result.rationale, /Top-ranked/);
|
|
});
|
|
|
|
test('RECOMMENDATION_THRESHOLD: matches plan default', () => {
|
|
// Sanity check that the export agrees with the documented threshold.
|
|
assert.equal(RECOMMENDATION_THRESHOLD, 0.7);
|
|
});
|