From 73b7f45140abb6bdb418e34389736666f467f28f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 14 Jun 2026 14:20:21 -0400 Subject: [PATCH] feat(#1173): wire agent converters into descriptor-driven install path (#1227) Extends `dispatchKindEntry` in `runtime-artifact-layout.cts` to route agents-kind entries through a converter when the descriptor carries a non-null `converter` field. Adds `stageAgentsForRuntimeWithConverter` to `install-profiles.cts`, expands `VALID_CONVERTER_NAMES` with the 9 agent converter names, and adds a fail-first behavioral test suite (9 tests) proving the new wiring end-to-end. Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/gen-capability-registry.cjs | 14 +- src/install-profiles.cts | 54 ++++ src/runtime-artifact-layout.cts | 41 ++- tests/capability-registry.test.cjs | 15 +- ...-1173-agent-converters-descriptor.test.cjs | 300 ++++++++++++++++++ 5 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 tests/feat-1173-agent-converters-descriptor.test.cjs diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index aaee3d66a..832af73ef 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -546,8 +546,10 @@ function validateFeatureBody(cap) { } // ADR-857 phase 5e: Closed ConverterName enum — complete set used across 16 runtime descriptors, -// all exported by bin/install.js. Any ArtifactKind with a non-null converter must use one of these. +// all exported by bin/install.js (commands/skills) and src/runtime-artifact-conversion.cts (agents). +// Any ArtifactKind with a non-null converter must use one of these. const VALID_CONVERTER_NAMES = new Set([ + // commands / skills converters (pre-existing) 'convertClaudeCommandToAntigravitySkill', 'convertClaudeCommandToAugmentSkill', 'convertClaudeCommandToClineSkill', @@ -563,6 +565,16 @@ const VALID_CONVERTER_NAMES = new Set([ 'convertClaudeCommandToOpencodeSkill', 'convertClaudeCommandToTraeSkill', 'convertClaudeCommandToWindsurfSkill', + // agent converters (#1173 — descriptor-driven agent conversion wiring) + 'convertClaudeAgentToCopilotAgent', + 'convertClaudeAgentToAntigravityAgent', + 'convertClaudeAgentToCursorAgent', + 'convertClaudeAgentToWindsurfAgent', + 'convertClaudeAgentToAugmentAgent', + 'convertClaudeAgentToTraeAgent', + 'convertClaudeAgentToCodebuddyAgent', + 'convertClaudeAgentToClineAgent', + 'convertClaudeAgentToCodexAgent', ]); // C3: Validate role:runtime body diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 72f2bc9f8..ceaf81868 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -530,6 +530,59 @@ function stageSkillsForRuntimeAsSkills( return stageDir; } +/** + * Stage a converted copy of the agents directory for a given runtime. + * + * Analogous to `stageCommandsForRuntimeFlat` but for agent `.md` files. Each + * source `.md` is passed through `converter` and written as a flat `${name}.md` + * file in the staging directory. Agent filenames are kept verbatim (no prefix + * added here — the prefix is already embedded in agent stems, e.g. `gsd-planner.md`). + * + * This is used by the descriptor-driven `dispatchKindEntry` when an `agents` kind + * entry carries a non-null converter (ADR-457 / #1173). When `converter` is null, + * `agentsKind` falls back to the existing raw-copy path (`stageAgentsForProfile`). + * + * For the `full` profile (`skills === '*'`), all `.md` files are staged. + * For tiered profiles, only agents whose full stem is in `resolvedProfile.agents` + * are staged (mirrors `stageAgentsForProfile` behaviour). + * + * @param srcAgentsDir source agents directory (e.g. agents/) + * @param resolvedProfile profile filter from resolveProfile() + * @param converter (content: string) → string pure per-file converter + */ +function stageAgentsForRuntimeWithConverter( + srcAgentsDir: string, + resolvedProfile: ResolvedProfile, + converter: (content: string) => string, +): string { + if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir; + + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-agents-')); + try { + const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md')) continue; + // For tiered profiles, gate by agent stem (full filename without extension). + if (resolvedProfile.skills !== '*') { + const stem = entry.name.slice(0, -3); + if (!(resolvedProfile.agents instanceof Set && resolvedProfile.agents.has(stem))) { + continue; + } + } + const content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8'); + const converted = converter(content); + fs.writeFileSync(path.join(stageDir, entry.name), converted, 'utf8'); + } + } catch (err) { + try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } + throw err; + } + STAGED_DIRS.add(stageDir); + ensureExitCleanup(); + return stageDir; +} + /** * Stage converted command files as flat `.md` files. * @@ -757,6 +810,7 @@ export = { mostRestrictiveProfile, stageSkillsForProfile, stageAgentsForProfile, + stageAgentsForRuntimeWithConverter, stageSkillsForRuntimeAsSkills, stageCommandsForRuntimeFlat, STAGED_DIRS, diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 926fb2e9a..6791af886 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -21,6 +21,7 @@ import installProfiles = require('./install-profiles.cjs'); const { stageSkillsForProfile, stageAgentsForProfile, + stageAgentsForRuntimeWithConverter, stageSkillsForRuntimeAsSkills, stageCommandsForRuntimeFlat, } = installProfiles; @@ -192,6 +193,41 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art }; } +/** + * Build a converted-agents kind descriptor for runtimes whose agent `.md` files + * need runtime-specific frontmatter/body conversion (e.g. Copilot, Cursor, Codex). + * + * Unlike `agentsKind` (which raw-copies source files), this kind applies + * `converterName` from Runtime Artifact Conversion exports to each agent file + * during staging, writing flat `${name}.md` files to the staged directory. + * + * Agent filenames are preserved verbatim (the prefix is already embedded in the + * agent stem — e.g. `gsd-planner.md`). + * + * Mirrors the `convertedCommandsKind` pattern (#785). + * + * @param destSubpath destination subpath within configDir (e.g. 'agents') + * @param prefix filename prefix (informational; not applied here) + * @param converterName name of converter function in Runtime Artifact Conversion exports + * @param configDir runtime config dir (for .gsd-source marker resolution) + */ +function convertedAgentsKind( + destSubpath: string, + prefix: string, + converterName: string, + configDir: string, +): ArtifactKind { + return { + kind: 'agents', + destSubpath, + prefix, + stage: (resolved) => { + const converter = conversionExports[converterName] as (content: string) => string; + return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter); + }, + }; +} + function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind { return { kind: 'kimi-agents', @@ -401,7 +437,10 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi return convertedCommandsKind(destSubpath, prefix, converter, configDir); case 'agents': - return agentsKind(destSubpath, prefix, configDir); + if (converter == null) { + return agentsKind(destSubpath, prefix, configDir); + } + return convertedAgentsKind(destSubpath, prefix, converter, configDir); case 'skills': if (converter == null) { diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 70846abf3..ecd22a0eb 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -3817,13 +3817,14 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => { // ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ───────────────── describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { - test('VALID_CONVERTER_NAMES has exactly 15 entries', () => { + test('VALID_CONVERTER_NAMES has exactly 24 entries (15 command/skill + 9 agent converters added in #1173)', () => { assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set'); - assert.strictEqual(VALID_CONVERTER_NAMES.size, 15, 'VALID_CONVERTER_NAMES must have exactly 15 entries, got: ' + VALID_CONVERTER_NAMES.size); + assert.strictEqual(VALID_CONVERTER_NAMES.size, 24, 'VALID_CONVERTER_NAMES must have exactly 24 entries, got: ' + VALID_CONVERTER_NAMES.size); }); test('VALID_CONVERTER_NAMES contains all expected converter names', () => { const expected = [ + // command/skill converters (pre-existing) 'convertClaudeCommandToAntigravitySkill', 'convertClaudeCommandToAugmentSkill', 'convertClaudeCommandToClineSkill', @@ -3839,6 +3840,16 @@ describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { 'convertClaudeCommandToOpencodeSkill', 'convertClaudeCommandToTraeSkill', 'convertClaudeCommandToWindsurfSkill', + // agent converters (#1173 — descriptor-driven agent conversion wiring) + 'convertClaudeAgentToCopilotAgent', + 'convertClaudeAgentToAntigravityAgent', + 'convertClaudeAgentToCursorAgent', + 'convertClaudeAgentToWindsurfAgent', + 'convertClaudeAgentToAugmentAgent', + 'convertClaudeAgentToTraeAgent', + 'convertClaudeAgentToCodebuddyAgent', + 'convertClaudeAgentToClineAgent', + 'convertClaudeAgentToCodexAgent', ]; for (const name of expected) { assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"'); diff --git a/tests/feat-1173-agent-converters-descriptor.test.cjs b/tests/feat-1173-agent-converters-descriptor.test.cjs new file mode 100644 index 000000000..047a60ecc --- /dev/null +++ b/tests/feat-1173-agent-converters-descriptor.test.cjs @@ -0,0 +1,300 @@ +'use strict'; + +/** + * feat-1173: Descriptor-driven agent converter wiring. + * + * Verifies that the descriptor-driven install path (dispatchKindEntry) applies + * per-runtime agent conversion when the 'agents' kind entry has a non-null + * converter — instead of silently raw-copying. + * + * Behavioral assertions: invoke the staging/dispatch seam, inspect the staged + * output files. NOT source-grep. + * + * TDD flow (REGRESSION-MUST-FAIL-FIRST rule): + * Before the fix, dispatchKindEntry ignores the converter for agents kind and + * raw-copies. The tests below prove conversion is applied by asserting that + * the staged .md contains runtime-specific frontmatter transformations absent + * in the raw source. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); + +const { + resolveRuntimeArtifactLayoutFromRegistry, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs')); + +const { + cleanupStagedSkills, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + +const { cleanup } = require('./helpers.cjs'); + +// ─── fixtures ──────────────────────────────────────────────────────────────── + +/** + * Minimal Claude agent source with comma-separated tools (Claude format). + * Copilot conversion turns tools into a JSON array (CONV-04/05). + * Codex conversion adds block. + * Cursor/Windsurf/Augment/Trae/Codebuddy/Cline conversion strips color field. + */ +const CLAUDE_AGENT_SOURCE = `--- +name: gsd-planner +description: A GSD planning agent. +tools: Bash, Read, Write +color: blue +--- + +# GSD Planner + +This agent plans GSD phases using ~/.claude/skills. +`; + +/** + * Create a temp agents directory with a .gsd-source marker pointing back to it + * (so findAgentsSourceRoot finds the fixture dir, not the real agents/ dir). + * The .gsd-source convention expects the marker to point at a commands/gsd dir; + * agents/ is resolved as a sibling of commands/. So we set up: + * / + * commands/gsd/ (empty, satisfies the sibling check) + * agents/ + * gsd-planner.md + * .gsd-source <- points to /commands/gsd + */ +function makeFixtureRoot(agentFiles) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-root-')); + const commandsDir = path.join(root, 'commands', 'gsd'); + const agentsDir = path.join(root, 'agents'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(agentsDir, { recursive: true }); + // .gsd-source marker must point to commands/gsd so that agentsSourceRoot resolves to agents/ + fs.writeFileSync(path.join(root, '.gsd-source'), commandsDir + '\n', 'utf8'); + for (const { name, content } of agentFiles) { + fs.writeFileSync(path.join(agentsDir, name), content, 'utf8'); + } + return root; +} + +function makeSyntheticRegistry(converterName) { + return { + runtimes: { + testruntime: { + runtime: { + artifactLayout: { + global: [ + { + kind: 'agents', + destSubpath: 'agents', + prefix: 'gsd-', + nesting: 'flat', + recursive: false, + converter: converterName, + }, + ], + local: [], + }, + }, + }, + }, + }; +} + +// ─── stageAgentsForRuntimeWithConverter unit tests ──────────────────────────── + +describe('feat-1173: stageAgentsForRuntimeWithConverter', () => { + test('is exported from install-profiles', () => { + const installProfiles = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + assert.strictEqual( + typeof installProfiles.stageAgentsForRuntimeWithConverter, + 'function', + 'stageAgentsForRuntimeWithConverter must be exported', + ); + }); + + test('applies converter to each staged agent file', (t) => { + const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + + const agentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-agents-')); + fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), CLAUDE_AGENT_SOURCE, 'utf8'); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), CLAUDE_AGENT_SOURCE.replace('gsd-planner', 'gsd-executor'), 'utf8'); + + t.after(() => { + cleanup(agentsDir); + cleanupStagedSkills(); + }); + + const calls = []; + const converter = (content) => { + calls.push(content); + return content.replace('~/.claude/', '~/.copilot/'); + }; + + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = stageAgentsForRuntimeWithConverter(agentsDir, resolvedProfile, converter); + + assert.strictEqual(calls.length, 2, 'converter called for each agent file'); + const stagedFiles = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(stagedFiles, ['gsd-executor.md', 'gsd-planner.md']); + + // Converter replaced ~/.claude/ with ~/.copilot/ in all staged files + for (const file of stagedFiles) { + const content = fs.readFileSync(path.join(stagedDir, file), 'utf8'); + assert.ok(!content.includes('~/.claude/'), `${file}: converter must have replaced ~/.claude/`); + assert.ok(content.includes('~/.copilot/'), `${file}: converter must have injected ~/.copilot/`); + } + }); + + test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => { + const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + const ghost = path.join(os.tmpdir(), 'gsd-1173-no-exist-' + Date.now()); + const converter = (c) => c; + const result = stageAgentsForRuntimeWithConverter(ghost, { name: 'full', skills: '*', agents: new Set() }, converter); + assert.strictEqual(result, ghost, 'must return srcAgentsDir unchanged for non-existent dir'); + }); + + test('only copies .md files (ignores non-.md)', (t) => { + const { stageAgentsForRuntimeWithConverter } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs')); + const agentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1173-agents-')); + fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), CLAUDE_AGENT_SOURCE, 'utf8'); + fs.writeFileSync(path.join(agentsDir, 'README.txt'), 'not an agent', 'utf8'); + + t.after(() => { + cleanup(agentsDir); + cleanupStagedSkills(); + }); + + const converter = (c) => c; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = stageAgentsForRuntimeWithConverter(agentsDir, resolvedProfile, converter); + const stagedFiles = fs.readdirSync(stagedDir); + assert.deepStrictEqual(stagedFiles, ['gsd-planner.md'], 'only .md files should be staged'); + }); +}); + +// ─── dispatchKindEntry wiring tests ────────────────────────────────────────── + +describe('feat-1173: dispatchKindEntry agents converter wiring', () => { + test('agents kind with convertClaudeAgentToCopilotAgent converter applies copilot conversion', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry('convertClaudeAgentToCopilotAgent'); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + assert.strictEqual(layout.kinds.length, 1); + const agentKind = layout.kinds[0]; + assert.strictEqual(agentKind.kind, 'agents'); + + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + const stagedFile = path.join(stagedDir, 'gsd-planner.md'); + assert.ok(fs.existsSync(stagedFile), `staged file must exist: ${stagedFile}`); + + const stagedContent = fs.readFileSync(stagedFile, 'utf8'); + + // Copilot CONV-04/05: tools converted from "Bash, Read, Write" to JSON array "['bash', 'read', 'write']" + // Raw copy would keep the original comma-separated "tools: Bash, Read, Write" line. + assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); + assert.ok( + stagedContent.includes("tools: ['") || stagedContent.includes('tools: ['), + `Copilot conversion must produce JSON array tools. Got:\n${stagedContent.slice(0, 300)}`, + ); + }); + + test('agents kind with convertClaudeAgentToCodexAgent converter applies codex conversion', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry('convertClaudeAgentToCodexAgent'); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + const agentKind = layout.kinds[0]; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); + + // Codex conversion adds block + assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); + assert.ok( + stagedContent.includes(''), + `Codex conversion must add . Got:\n${stagedContent.slice(0, 300)}`, + ); + }); + + test('agents kind with convertClaudeAgentToCursorAgent converter applies cursor conversion', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry('convertClaudeAgentToCursorAgent'); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + const agentKind = layout.kinds[0]; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); + + // Cursor conversion strips color field and rewrites ~/.claude/ paths + assert.notStrictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter must have transformed the content'); + assert.ok( + !stagedContent.includes('color:'), + `Cursor agent conversion should strip the color field. Got:\n${stagedContent.slice(0, 300)}`, + ); + }); + + test('agents kind with converter=null still raw-copies (backward compat for claude)', (t) => { + const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); + t.after(() => { + cleanup(fixtureRoot); + cleanupStagedSkills(); + }); + + const registry = makeSyntheticRegistry(null); + const layout = resolveRuntimeArtifactLayoutFromRegistry( + registry, 'testruntime', fixtureRoot, 'global', + ); + + const agentKind = layout.kinds[0]; + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const stagedDir = agentKind.stage(resolvedProfile); + + // converter=null: stageAgentsForProfile with skills='*' returns srcAgentsDir unchanged + // (no staging dir is created; the source dir IS the staged dir, a passthrough) + const stagedContent = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); + assert.strictEqual(stagedContent, CLAUDE_AGENT_SOURCE, 'converter=null must raw-copy the agent content'); + }); +}); + +// ─── real registry: claude agents kind has converter=null ──────────────────── + +describe('feat-1173: real registry claude agents kind has converter=null (backward compat)', () => { + test('claude local artifacts layout has agents entry with converter=null', () => { + const registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); + const claudeDesc = registry.runtimes?.claude?.runtime?.artifactLayout?.local ?? []; + const agentsEntry = claudeDesc.find((e) => e.kind === 'agents'); + assert.ok(agentsEntry, 'claude local artifactLayout must have an agents entry'); + assert.strictEqual(agentsEntry.converter, null, 'claude agents entry must have converter=null'); + }); +});