fix(installer): detect personas via customize.toml [agent] section

Per maintainer review on PR #2324: the `-agent-` naming convention isn't
a load-bearing contract anywhere else in the codebase, and the bmad-tea
allowlist already shows it starting to break. A future persona that
doesn't follow the convention would silently disappear from the Copilot
Custom Agents picker.

Replaces the name-based filter with a behavior-based signal: read each
skill's source `customize.toml` and check for an `[agent]` section. This
is the actual configuration source of truth — every BMAD persona is
configured under `[agent]`, every workflow under `[workflow]`, every
standalone skill has no customize.toml.

Verified on disk against the full installed manifest (114 skills):

- 20 personas detected — exactly the description-confirmed count across
  BMM, CIS, GDS, WDS, TEA. bmad-tea is caught natively (no allowlist).
- 94 workflows/tools correctly excluded.
- `bmad-agent-builder` (meta-skill that builds agent skills) is now
  CORRECTLY excluded — its canonical id contains `-agent-` but its
  customize.toml has [workflow], not [agent], because it isn't a
  persona itself. The previous naming-based filter was including it in
  the agents picker, which would have been a silent UX bug.

`NON_CONVENTIONAL_AGENT_IDS` constant is removed entirely — the toml
signal subsumes it.

Tests: extends Suite 17 with a 4-skill fixture that covers persona +
non-conventional persona + workflow + meta-skill cases. 388 tests pass.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jheyworth 2026-04-29 08:00:01 +01:00
parent 9b95bba58f
commit 75e1fa2323
3 changed files with 96 additions and 35 deletions

View File

@ -574,22 +574,59 @@ async function runTests() {
const tempProjectDir17 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-copilot-test-')); const tempProjectDir17 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-copilot-test-'));
const installedBmadDir17 = await createTestBmadFixture(); const installedBmadDir17 = await createTestBmadFixture();
// Extend the fixture: add a persona-style agent skill (`-agent-` in id) // Extend the fixture to exercise the agents-only filter, which detects
// and the `bmad-tea` non-conventional agent so we can verify that the // persona agents by the `[agent]` section in each skill's source
// agents-only filter routes them to .github/agents/ while leaving the // customize.toml. Three skill types covered:
// workflow-style `bmad-master` out. //
// 1. Persona agent — has customize.toml with [agent] → INCLUDED
// 2. Persona with non-conventional id — also has [agent] → INCLUDED
// (verifies the filter doesn't depend on `-agent-` naming)
// 3. Meta-skill whose id contains `-agent-` but isn't a
// persona — has customize.toml with [workflow] → EXCLUDED
// (mirrors `bmad-agent-builder` in the real manifest)
// 4. Workflow skill — no customize.toml at all → EXCLUDED
const fixtureCsvPath17 = path.join(installedBmadDir17, '_config', 'skill-manifest.csv'); const fixtureCsvPath17 = path.join(installedBmadDir17, '_config', 'skill-manifest.csv');
await fs.writeFile( await fs.writeFile(
fixtureCsvPath17, fixtureCsvPath17,
[ [
'canonicalId,name,description,module,path', 'canonicalId,name,description,module,path',
'"bmad-master","bmad-master","Workflow-style fixture (no -agent- in id, should NOT appear in Copilot agents picker)","core","_bmad/core/bmad-master/SKILL.md"', '"bmad-master","bmad-master","Workflow with no customize.toml — should NOT appear in Copilot agents picker","core","_bmad/core/bmad-master/SKILL.md"',
'"bmad-agent-fixture","bmad-agent-fixture","Persona agent fixture (-agent- in id, SHOULD appear in Copilot agents picker)","core","_bmad/core/bmad-agent-fixture/SKILL.md"', '"bmad-agent-fixture","bmad-agent-fixture","Persona agent — customize.toml has [agent], SHOULD appear","core","_bmad/core/bmad-agent-fixture/SKILL.md"',
'"bmad-tea","bmad-tea","Non-conventional persona agent fixture (Murat-style, SHOULD appear despite no -agent- segment)","core","_bmad/core/bmad-tea/SKILL.md"', '"bmad-tea","bmad-tea","Non-conventional id but [agent] in customize.toml — SHOULD appear","core","_bmad/core/bmad-tea/SKILL.md"',
'"bmad-agent-builder","bmad-agent-builder","Skill-builder workflow — id contains -agent- but customize.toml has [workflow] — should NOT appear","core","_bmad/core/bmad-agent-builder/SKILL.md"',
'', '',
].join('\n'), ].join('\n'),
); );
// Materialise the source skill directories so the agents-only filter
// can read their customize.toml. The bmad-master and bmad-agent-builder
// SKILL.md files were already populated by createTestBmadFixture (they
// share the bmad-master target_dir layout); only the customize.toml
// and the new agent fixtures need to be created here.
for (const id of ['bmad-agent-fixture', 'bmad-tea', 'bmad-agent-builder']) {
const dir17 = path.join(installedBmadDir17, 'core', id);
await fs.ensureDir(dir17);
await fs.writeFile(
path.join(dir17, 'SKILL.md'),
['---', `name: ${id}`, `description: fixture for ${id}`, '---', '', `Body of ${id}.`].join('\n'),
);
}
// [agent] customize.toml for the two persona fixtures.
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-agent-fixture', 'customize.toml'),
['[agent]', 'name = "Fixture Agent"', 'title = "Test Persona"', ''].join('\n'),
);
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-tea', 'customize.toml'),
['[agent]', 'name = "Murat"', 'title = "Test Architect"', ''].join('\n'),
);
// [workflow] customize.toml for the meta-skill — its id contains `-agent-`
// but it is NOT a persona (mirrors bmad-agent-builder in production).
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-agent-builder', 'customize.toml'),
['[workflow]', '', '# Meta-skill that builds agents but is not itself a persona.', ''].join('\n'),
);
const copilotInstructionsPath17 = path.join(tempProjectDir17, '.github', 'copilot-instructions.md'); const copilotInstructionsPath17 = path.join(tempProjectDir17, '.github', 'copilot-instructions.md');
await fs.ensureDir(path.dirname(copilotInstructionsPath17)); await fs.ensureDir(path.dirname(copilotInstructionsPath17));
await fs.writeFile( await fs.writeFile(
@ -625,19 +662,26 @@ async function runTests() {
'GitHub Copilot setup preserves user content in copilot-instructions.md', 'GitHub Copilot setup preserves user content in copilot-instructions.md',
); );
// Custom Agents picker integration: persona agents (and bmad-tea) get // Custom Agents picker integration: persona agents (those with [agent]
// .agent.md files in .github/agents/. Workflows do NOT — the // in their source customize.toml) get .agent.md files in
// agents-only filter keeps the picker uncluttered. // .github/agents/. Workflows and meta-skills with [workflow] (or no
// customize.toml at all) do NOT — the agents-only filter keeps the
// picker uncluttered and the signal is naming-independent.
const agentsDir17 = path.join(tempProjectDir17, '.github', 'agents'); const agentsDir17 = path.join(tempProjectDir17, '.github', 'agents');
const agentFileForPersona17 = path.join(agentsDir17, 'bmad-agent-fixture.agent.md'); const agentFileForPersona17 = path.join(agentsDir17, 'bmad-agent-fixture.agent.md');
const agentFileForTea17 = path.join(agentsDir17, 'bmad-tea.agent.md'); const agentFileForTea17 = path.join(agentsDir17, 'bmad-tea.agent.md');
const agentFileForWorkflow17 = path.join(agentsDir17, 'bmad-master.agent.md'); const agentFileForWorkflow17 = path.join(agentsDir17, 'bmad-master.agent.md');
const agentFileForMetaSkill17 = path.join(agentsDir17, 'bmad-agent-builder.agent.md');
assert(await fs.pathExists(agentFileForPersona17), 'Persona agent (-agent- in id) gets a .agent.md file in .github/agents/');
assert(await fs.pathExists(agentFileForTea17), 'bmad-tea persona (non-conventional id) is allowlisted into .github/agents/');
assert( assert(
!(await fs.pathExists(agentFileForWorkflow17)), await fs.pathExists(agentFileForPersona17),
'Workflow skill (no -agent- in id, not in allowlist) is FILTERED OUT of .github/agents/', 'Persona agent ([agent] in customize.toml) gets a .agent.md file in .github/agents/',
);
assert(await fs.pathExists(agentFileForTea17), 'Non-conventional id with [agent] in customize.toml is included (no allowlist needed)');
assert(!(await fs.pathExists(agentFileForWorkflow17)), 'Workflow skill (no customize.toml) is FILTERED OUT of .github/agents/');
assert(
!(await fs.pathExists(agentFileForMetaSkill17)),
'Meta-skill with -agent- in id but [workflow] in customize.toml is FILTERED OUT (signal is behavior, not naming)',
); );
// Body content of the persona agent file: frontmatter description + // Body content of the persona agent file: frontmatter description +

View File

@ -57,22 +57,35 @@ function isSafeCanonicalId(value) {
// OpenCode's native `@skills/<id>` skill-reference syntax. // OpenCode's native `@skills/<id>` skill-reference syntax.
const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}'; const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}';
// Persona-agent id outside the `-agent-` naming convention.
// TEA's Murat is the only persona whose canonical id is the bare module code
// rather than `<module>-agent-<role>`. Listed here so platforms that filter
// for "agents only" (e.g. GitHub Copilot's Custom Agents picker) include it.
const NON_CONVENTIONAL_AGENT_IDS = new Set(['bmad-tea']);
// Is this skill a persona agent (vs. a workflow/tool/standalone skill)? // Is this skill a persona agent (vs. a workflow/tool/standalone skill)?
// Used by platforms that surface only persona agents (e.g. Copilot's Custom // Used by platforms that surface only persona agents (e.g. Copilot's Custom
// Agents picker). Rule: canonical id contains `-agent-` OR is in the // Agents picker). Signal: the skill's source `customize.toml` has an
// known non-conventional allowlist. Tested against the full installed // `[agent]` section. This is the actual configuration source of truth —
// manifest — catches all 20 description-confirmed personas across BMM, // every BMAD persona is configured via [agent] in its customize.toml,
// CIS, GDS, WDS, TEA without false positives. // every workflow uses [workflow], every standalone skill has no
function isAgentSkill(canonicalId) { // customize.toml at all. Verified against the full installed manifest:
if (typeof canonicalId !== 'string') return false; // catches exactly the 20 description-confirmed personas across BMM, CIS,
if (NON_CONVENTIONAL_AGENT_IDS.has(canonicalId)) return true; // GDS, WDS, TEA, and correctly excludes meta-skills like
return canonicalId.includes('-agent-'); // `bmad-agent-builder` (a skill-builder workflow whose canonical id
// contains `-agent-` but which has no [agent] section because it isn't a
// persona itself).
//
// Reading the source toml — at install time the source skill directory
// (resolved from manifest record.path) still exists; cleanup runs later
// in the install flow.
async function isAgentSkill(record, bmadDir) {
if (!record?.path || !bmadDir) return false;
const bmadFolderName = path.basename(bmadDir);
const bmadPrefix = bmadFolderName + '/';
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
const tomlPath = path.join(bmadDir, path.dirname(relativePath), 'customize.toml');
if (!(await fs.pathExists(tomlPath))) return false;
try {
const content = await fs.readFile(tomlPath, 'utf8');
return /^\[agent\]/m.test(content);
} catch {
return false;
}
} }
// Resolve placeholders in a body template. Supported placeholders: // Resolve placeholders in a body template. Supported placeholders:
@ -319,7 +332,7 @@ class ConfigDrivenIdeSetup {
// persona agents (e.g. Copilot's Custom Agents picker) skip // persona agents (e.g. Copilot's Custom Agents picker) skip
// workflow/tool skills here so the picker isn't cluttered with // workflow/tool skills here so the picker isn't cluttered with
// 90+ unrelated entries. // 90+ unrelated entries.
if (filter === 'agents-only' && !isAgentSkill(canonicalId)) { if (filter === 'agents-only' && !(await isAgentSkill(record, bmadDir))) {
result.skippedFiltered++; result.skippedFiltered++;
continue; continue;
} }

View File

@ -136,12 +136,16 @@ platforms:
commands_extension: .agent.md commands_extension: .agent.md
commands_body_template: "LOAD the FULL {project-root}/{target_dir}/{canonicalId}/SKILL.md, READ its entire contents and follow its directions exactly!" commands_body_template: "LOAD the FULL {project-root}/{target_dir}/{canonicalId}/SKILL.md, READ its entire contents and follow its directions exactly!"
# The Custom Agents picker should only show persona agents (not # The Custom Agents picker should only show persona agents (not
# workflows/tools). BMAD's persona agents are conventionally named # workflows/tools). Detected by reading each skill's source
# with an `-agent-` segment in their canonical id (e.g. # `customize.toml` and checking for an `[agent]` section — that's
# `bmad-agent-pm`, `gds-agent-game-dev`, `wds-agent-freya-ux`, # the actual configuration source of truth: every BMAD persona is
# `bmad-cis-agent-storyteller`). The one exception is `bmad-tea` — # configured under `[agent]`, every workflow under `[workflow]`,
# TEA's Murat persona uses the module code as its id rather than the # every standalone skill has no customize.toml. This signal is
# `-agent-` convention. This filter keeps the picker uncluttered. # naming-independent, so personas like `bmad-tea` (which doesn't
# follow the `-agent-` convention) are still included, and
# meta-skills like `bmad-agent-builder` (which contains `-agent-`
# but is a skill-builder workflow, not a persona) are correctly
# excluded.
commands_filter: agents-only commands_filter: agents-only
goose: goose: