diff --git a/.changeset/quiet-lynxes-filter.md b/.changeset/quiet-lynxes-filter.md new file mode 100644 index 000000000..d678c22c5 --- /dev/null +++ b/.changeset/quiet-lynxes-filter.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4532 +--- +**Non-Copilot artifacts no longer include Copilot-only tool guidance** — the shared conversion pipeline filters audience-specific notes from commands, skills, and workflow assets while preserving runtime-neutral fallbacks. (#4482) diff --git a/bin/install.js b/bin/install.js index 519ad4c06..c3f5473d1 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1292,6 +1292,7 @@ const removeKimiHooksToml = hooksSurface.removeKimiHooksToml; // callers continue to work and there is a single implementation. (All call // sites are below this line, so the const binding has no TDZ hazard.) const processAttribution = runtimeArtifactConversion.processAttribution; +const filterRuntimeNotesForTarget = runtimeArtifactConversion.filterRuntimeNotesForTarget; // computePathPrefix: implementation lives in runtimeArtifactConversion // (ADR-1508 / #1511 Phase 2 — single owner). Re-bound here so install.js call // sites continue to work. #2876 retired the sibling @@ -2415,7 +2416,7 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) { * @param {boolean} [isGlobal=false] - Whether this is a global install */ function convertClaudeToAntigravityContent(content, isGlobal = false) { - let c = content; + let c = filterRuntimeNotesForTarget(content, 'antigravity'); if (isGlobal) { // #3738: global skills install under ~/.gemini/config/skills (the dir AGY // scans for global discovery), so skills-path references must divert there @@ -2551,7 +2552,7 @@ function convertSlashCommandsToCursorSkillMentions(content) { } function convertClaudeToCursorMarkdown(content) { - let converted = convertSlashCommandsToCursorSkillMentions(content); + let converted = convertSlashCommandsToCursorSkillMentions(filterRuntimeNotesForTarget(content, 'cursor')); // Replace tool name references in body text converted = converted.replace(/\bBash\(/g, 'Shell('); converted = converted.replace(/\bEdit\(/g, 'StrReplace('); @@ -2672,7 +2673,7 @@ function convertSlashCommandsToTraeSkillMentions(content) { } function convertClaudeToTraeMarkdown(content) { - let converted = convertSlashCommandsToTraeSkillMentions(content); + let converted = convertSlashCommandsToTraeSkillMentions(filterRuntimeNotesForTarget(content, 'trae')); converted = converted.replace(/\bBash\(/g, 'Shell('); converted = converted.replace(/\bEdit\(/g, 'StrReplace('); // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task" @@ -2798,7 +2799,7 @@ function convertSlashCommandsToCodebuddySkillMentions(content) { } function convertClaudeToCodebuddyMarkdown(content) { - let converted = convertSlashCommandsToCodebuddySkillMentions(content); + let converted = convertSlashCommandsToCodebuddySkillMentions(filterRuntimeNotesForTarget(content, 'codebuddy')); // CodeBuddy uses the same tool names as Claude Code (Bash, Edit, Read, Write, etc.) // No tool name conversion needed converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); @@ -2890,7 +2891,7 @@ function convertClaudeAgentToCodebuddyAgent(content) { // ── Cline converters ──────────────────────────────────────────────────────── function convertClaudeToCliineMarkdown(content) { - let converted = content; + let converted = filterRuntimeNotesForTarget(content, 'cline'); // Cline uses the same tool names as Claude Code — no tool name conversion needed converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.clinerules`'); converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules'); @@ -3813,7 +3814,7 @@ function rewriteBareGsdToolsCommandsForCodex(content) { } function convertClaudeToCodexMarkdown(content) { - let converted = convertSlashCommandsToCodexSkillMentions(content); + let converted = convertSlashCommandsToCodexSkillMentions(filterRuntimeNotesForTarget(content, 'codex')); converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); // Remove /clear references — Codex has no equivalent command // Handle backtick-wrapped: `\/clear` then: → (removed) @@ -7194,7 +7195,7 @@ function neutralizeAgentReferences(content, instructionFile) { function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null } = {}) { // Replace tool name references in content (applies to all files) - let convertedContent = content; + let convertedContent = filterRuntimeNotesForTarget(content, 'opencode'); convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question'); convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill'); convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite'); @@ -7356,7 +7357,7 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve // tests/runtime-converters.test.cjs (#2093). function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverride = null } = {}) { // Replace tool name references in content (applies to all files) - let convertedContent = content; + let convertedContent = filterRuntimeNotesForTarget(content, 'kilo'); convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question'); convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill'); convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite'); @@ -7910,6 +7911,8 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = composeWorkflow(content, { sourcePath: srcPath }); } + content = filterRuntimeNotesForTarget(content, runtime); + if (!dispatch.mdSkipGenericRewrite) { const globalClaudeRegex = /~\/\.claude\//g; const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 3dce8375b..df46a4a5c 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -1072,7 +1072,7 @@ function buildKimiAgentArtifacts({ * @param {boolean} [isGlobal=false] - Whether this is a global install */ function convertClaudeToAntigravityContent(content, isGlobal = false) { - let c = content; + let c = filterRuntimeNotesForTarget(content, 'antigravity'); if (isGlobal) { // #3738: global skills install under ~/.gemini/config/skills (the dir AGY // scans for global discovery), so skills-path references must divert there @@ -1173,7 +1173,7 @@ function convertSlashCommandsToCursorSkillMentions(content) { } function convertClaudeToCursorMarkdown(content) { - let converted = convertSlashCommandsToCursorSkillMentions(content); + let converted = convertSlashCommandsToCursorSkillMentions(filterRuntimeNotesForTarget(content, 'cursor')); // Replace tool name references in body text converted = converted.replace(/\bBash\(/g, 'Shell('); converted = converted.replace(/\bEdit\(/g, 'StrReplace('); @@ -1279,7 +1279,7 @@ function convertSlashCommandsToWindsurfSkillMentions(content) { } function convertClaudeToWindsurfMarkdown(content) { - let converted = convertSlashCommandsToWindsurfSkillMentions(content); + let converted = convertSlashCommandsToWindsurfSkillMentions(filterRuntimeNotesForTarget(content, 'windsurf')); // Replace tool name references in body text converted = converted.replace(/\bBash\(/g, 'Shell('); converted = converted.replace(/\bEdit\(/g, 'StrReplace('); @@ -1451,7 +1451,7 @@ function convertSlashCommandsToAugmentSkillMentions(content) { } function convertClaudeToAugmentMarkdown(content) { - let converted = convertSlashCommandsToAugmentSkillMentions(content); + let converted = convertSlashCommandsToAugmentSkillMentions(filterRuntimeNotesForTarget(content, 'augment')); converted = converted.replace(/\bBash\(/g, 'launch-process('); converted = converted.replace(/\bEdit\(/g, 'str-replace-editor('); converted = converted.replace(/\bRead\(/g, 'view('); @@ -1535,7 +1535,7 @@ function convertSlashCommandsToTraeSkillMentions(content) { } function convertClaudeToTraeMarkdown(content) { - let converted = convertSlashCommandsToTraeSkillMentions(content); + let converted = convertSlashCommandsToTraeSkillMentions(filterRuntimeNotesForTarget(content, 'trae')); converted = converted.replace(/\bBash\(/g, 'Shell('); converted = converted.replace(/\bEdit\(/g, 'StrReplace('); // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task" @@ -1645,7 +1645,7 @@ function convertSlashCommandsToCodebuddySkillMentions(content) { } function convertClaudeToCodebuddyMarkdown(content) { - let converted = convertSlashCommandsToCodebuddySkillMentions(content); + let converted = convertSlashCommandsToCodebuddySkillMentions(filterRuntimeNotesForTarget(content, 'codebuddy')); // CodeBuddy uses the same tool names as Claude Code (Bash, Edit, Read, Write, etc.) // No tool name conversion needed converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); @@ -1723,7 +1723,7 @@ function convertClaudeCommandToCodebuddyCommand(content, commandName) { // ── Cline converters ──────────────────────────────────────────────────────── function convertClaudeToCliineMarkdown(content) { - let converted = content; + let converted = filterRuntimeNotesForTarget(content, 'cline'); // Cline uses the same tool names as Claude Code — no tool name conversion needed converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.clinerules`'); converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules'); @@ -1817,7 +1817,7 @@ function rewriteBareGsdToolsCommandsForCodex(content) { } function convertClaudeToCodexMarkdown(content) { - let converted = convertSlashCommandsToCodexSkillMentions(content); + let converted = convertSlashCommandsToCodexSkillMentions(filterRuntimeNotesForTarget(content, 'codex')); converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); // Remove /clear references — Codex has no equivalent command // Handle backtick-wrapped: `\/clear` then: → (removed) @@ -2012,9 +2012,29 @@ function frontmatterScalar(key: string, value: string): string { : `${key} ${value}`; } +const RUNTIME_NOTE_AUDIENCE_BY_HEADING = new Map([ + ['copilot (vs code)', 'copilot'], +]); + +function filterRuntimeNotesForTarget(content: string, targetRuntime: string): string { + return content.replace(/([\s\S]*?)<\/runtime_note>/g, + (whole, declaredAudience: string | undefined, inner: string) => { + if (declaredAudience && declaredAudience.toLowerCase() !== targetRuntime) return ''; + const remaining = inner.replace( + /(?:^|\n)[ \t]*\*\*([^*\n]+):\*\*[^\n]*(?:\n(?![ \t]*\n)[^\n]*)*/g, + (section, heading: string) => { + const audience = RUNTIME_NOTE_AUDIENCE_BY_HEADING.get(heading.trim().toLowerCase()); + return audience && audience !== targetRuntime ? '' : section; + }, + ); + const body = remaining.trim(); + return body ? `\n${body}\n` : ''; + }); +} + function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null, variant = null } = {}) { // Replace tool name references in content (applies to all files) - let convertedContent = content; + let convertedContent = filterRuntimeNotesForTarget(content, 'opencode'); convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question'); convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill'); convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite'); @@ -2192,7 +2212,7 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve // (#2093). function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverride = null } = {}) { // Replace tool name references in content (applies to all files) - let convertedContent = content; + let convertedContent = filterRuntimeNotesForTarget(content, 'kilo'); convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question'); convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill'); convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite'); @@ -3151,6 +3171,8 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a const dirName = getDirName(runtime); const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); + content = filterRuntimeNotesForTarget(content, runtime); + // #1521: stamp runtime identity + use_worktrees=false for every non-Claude runtime // before brand-specific path rewrites, so the replace operates on the pristine // source line and is idempotent regardless of subsequent path substitutions. @@ -3862,6 +3884,7 @@ export = { neutralizeAgentReferences, convertClaudeCommandToOpencodeSkill, convertClaudeCommandToKiloSkill, + filterRuntimeNotesForTarget, // #2087 — opencode/kilo command-frontmatter converters, exported so the // layout-driven `convertedCommandsKind` can resolve them by name (routes the // opencode/kilo command install through the engine instead of the bespoke path). diff --git a/tests/emitted-attribution.test.cjs b/tests/emitted-attribution.test.cjs index d901a944a..494438e05 100644 --- a/tests/emitted-attribution.test.cjs +++ b/tests/emitted-attribution.test.cjs @@ -618,17 +618,18 @@ test('a ripple names the unexplained path and not the explained one', () => { assert.ok(!r.ok); }); -test('a converter change fails without an ack and passes with one', () => { +test('an unregistered converter change fails without an ack and passes with one', () => { // #2723 AC: "simulate a legitimate converter change: assert it fails without an ack - // entry and passes with one." A converter edit moves emitted bytes for files whose - // sources nobody touched — ADR-2264's "~5% git cannot review". + // entry and passes with one." Registered transforms are now first-class provenance + // (covered above), so use a converter path the rule does not declare to retain this + // guard for ADR-2264's "~5% git cannot review" rather than contradicting that model. const moved = {}; const base = {}; for (let i = 0; i < 25; i++) { base[`skills/gsd-cmd-${i}/SKILL.md`] = `h${i}`; moved[`skills/gsd-cmd-${i}/SKILL.md`] = `x${i}`; } - const changedPaths = ['src/runtime-artifact-conversion.cts']; + const changedPaths = ['src/unregistered-runtime-converter.cts']; const without = diffEmitted({ baseline: mf(base), current: mf(moved), changedPaths }); assert.equal(without.unattributable.length, 25); diff --git a/tests/emitted-provenance.test.cjs b/tests/emitted-provenance.test.cjs index fe448515d..18e92ece9 100644 --- a/tests/emitted-provenance.test.cjs +++ b/tests/emitted-provenance.test.cjs @@ -42,6 +42,7 @@ const { HOOKS_WINDOWS_SHIM_SRC, KIMI_ROOT_AGENT_SRC, AGENT_TRANSFORM_SRCS, + RUNTIME_NOTE_FILTER_TRANSFORM_SRCS, stripSkillPrefix, matchRules, attributeEmittedPath, @@ -359,8 +360,22 @@ test('agents-verbatim is reclassified to derived with the same transforms, sourc assert.deepEqual(got.transforms, AGENT_TRANSFORM_SRCS); }); +test('#4482 runtime-note-filtered command and skill surfaces declare their converter transform', () => { + const command = attributeEmittedPath('commands/gsd-plan-phase.md', 'opencode'); + const skill = attributeEmittedPath('skills/gsd-plan-phase/SKILL.md', 'opencode'); + const workflow = attributeEmittedPath('gsd-core/workflows/mvp-phase.md', 'opencode'); + assert.deepEqual(command.transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS); + assert.deepEqual(skill.transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS); + assert.deepEqual(workflow.transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS); + + assert.deepEqual(attributeEmittedPath('commands/gsd-plan-phase.md', 'kilo').transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS); + assert.deepEqual(attributeEmittedPath('skills/gsd-plan-phase/SKILL.md', 'cursor').transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS); + assert.deepEqual(attributeEmittedPath('gsd-core/workflows/mvp-phase.md', 'claude-local').transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS); + assert.deepEqual(attributeEmittedPath('gsd-core/workflows/mvp-phase.md', 'copilot').transforms, []); +}); + test('a rule with no transforms field still returns an empty array, never undefined', () => { - const got = attributeEmittedPath('gsd-core/workflows/plan-phase.md', 'claude'); + const got = attributeEmittedPath('agents/gsd-planner.agent.md', 'copilot'); assert.deepEqual(got.transforms, [], 'absence of transforms must be a stable empty array, not undefined'); }); diff --git a/tests/helpers/emitted-provenance.cjs b/tests/helpers/emitted-provenance.cjs index d982b5ad2..513692955 100644 --- a/tests/helpers/emitted-provenance.cjs +++ b/tests/helpers/emitted-provenance.cjs @@ -182,6 +182,18 @@ const ZCODE_BODY_TRANSFORM_SRCS = [ 'bin/install.js', ]; +// #4482: every non-Copilot runtime filters audience-specific notes through the +// conversion module and the published installer copy path. +const RUNTIME_NOTE_FILTER_TRANSFORM_SRCS = [ + 'src/runtime-artifact-conversion.cts', + 'bin/install.js', +]; +const RUNTIME_NOTE_FILTERED_FAMILIES = new Set( + MANIFEST_FAMILIES + .filter(({ runtime }) => runtime !== 'copilot') + .map(({ name }) => name), +); + /** * A `sources` entry ending in `/` is a PREFIX, not a file: it means "any repo path * under this directory legitimately explains this emitted path". Used where an @@ -266,13 +278,22 @@ const PROVENANCE_RULES = [ // ── Verbatim engine payload ──────────────────────────────────────────────── { id: 'gsd-core-verbatim', - kind: 'identity', + // Markdown payloads pass through copyWithPathReplacement's audience + // filter for non-Copilot runtimes. Non-Markdown payloads remain byte-for- + // byte copies, but one rule has one kind; the match-specific transform + // list below keeps the causal attribution precise. + kind: 'derived', roots: ['gsd-core'], // Enumerated subdirs, NOT `.+`: a new gsd-core/ must fail totality // loudly rather than being absorbed silently. Also keeps this mutually // exclusive with the two synthesized gsd-core top-level files below. pattern: /^(workflows|references|templates|contexts|bin)\/.+$/, sources: (m) => [`gsd-core/${m[0]}`], + transforms: (m, ctx) => ( + m[0].endsWith('.md') && RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime) + ? RUNTIME_NOTE_FILTER_TRANSFORM_SRCS + : [] + ), }, { id: 'gsd-core-commands-corpus', @@ -525,6 +546,7 @@ const PROVENANCE_RULES = [ // ZCODE_BODY_TRANSFORM_SRCS), so the converter change explains theirs too. transforms: (_m, ctx) => { if (ctx.runtime === 'antigravity') return ANTIGRAVITY_SKILL_TRANSFORM_SRCS; + if (RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime)) return RUNTIME_NOTE_FILTER_TRANSFORM_SRCS; if (ctx.runtime === 'zcode') return ZCODE_BODY_TRANSFORM_SRCS; return []; }, @@ -540,7 +562,11 @@ const PROVENANCE_RULES = [ sources: (m) => [`${COMMANDS_SRC}/${stripSkillPrefix(m[2])}.md`], // #4002: zcode's nested router children pass through the same rewrite pass // as its flat skills — see ZCODE_BODY_TRANSFORM_SRCS. - transforms: (_m, ctx) => (ctx.runtime === 'zcode' ? ZCODE_BODY_TRANSFORM_SRCS : []), + transforms: (_m, ctx) => { + if (RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime)) return RUNTIME_NOTE_FILTER_TRANSFORM_SRCS; + if (ctx.runtime === 'zcode') return ZCODE_BODY_TRANSFORM_SRCS; + return []; + }, }, { id: 'flat-commands-from-commands', @@ -548,9 +574,14 @@ const PROVENANCE_RULES = [ roots: ['commands', 'command'], pattern: /^gsd-([^/]+)\.md$/, sources: (m) => [`${COMMANDS_SRC}/${m[1]}.md`], + // #4482: OpenCode command bodies pass through its command converter. // #4002: zcode command bodies pass through _applyRuntimeRewrites with // converter: null — see ZCODE_BODY_TRANSFORM_SRCS above. - transforms: (_m, ctx) => (ctx.runtime === 'zcode' ? ZCODE_BODY_TRANSFORM_SRCS : []), + transforms: (_m, ctx) => { + if (RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime)) return RUNTIME_NOTE_FILTER_TRANSFORM_SRCS; + if (ctx.runtime === 'zcode') return ZCODE_BODY_TRANSFORM_SRCS; + return []; + }, }, // ── Descriptor-declared native plugin / extension ───────────────────────── @@ -920,6 +951,7 @@ module.exports = { SKILLS_ROOTS, KIMI_ROOT_AGENT_SRC, AGENT_TRANSFORM_SRCS, + RUNTIME_NOTE_FILTER_TRANSFORM_SRCS, SOURCE_PREFIX_SUFFIX, HOOKS_ROOTS, COMMANDS_SRC, diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index 7a422a50e..88cb76557 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -11,6 +11,8 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); process.env.GSD_TEST_MODE = '1'; const { @@ -73,6 +75,70 @@ const flatRuntimeSuites = [ }, ]; +describe('#4482: OpenCode conversion strips Copilot-only runtime notes', () => { + const convert = (body) => liveConversion.convertClaudeToOpencodeFrontmatter( + ['---', 'name: gsd-test', 'description: test', '---', body].join('\n'), + ); + + test('removes a Copilot-only note without leaving an empty wrapper', () => { + const out = convert([ + '', + '**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`.', + '', + '', + 'Keep me.', + ].join('\n')); + + assert.ok(!out.includes('vscode_askquestions')); + assert.ok(!out.includes('')); + assert.ok(out.includes('Keep me.')); + }); + + test('preserves runtime-neutral fallback text in a mixed note', () => { + const out = convert([ + '', + '**Copilot (VS Code):** Use `vscode_askquestions` instead of `AskUserQuestion`.', + '', + '**TEXT_MODE fallback:** Present a numbered list when interactive tools are unavailable.', + '', + ].join('\n')); + + assert.ok(!out.includes('vscode_askquestions')); + assert.match(out, /\n\*\*TEXT_MODE fallback:\*\*/); + assert.ok(out.includes('')); + }); + + test('leaves unrelated runtime notes intact', () => { + const note = '\n**OpenCode:** Keep this runtime-specific guidance.\n'; + assert.ok(convert(note).includes(note)); + }); + + test('the shared audience filter covers every non-Copilot runtime and preserves Copilot', () => { + const note = '\n**Copilot (VS Code):** Use `vscode_askquestions`.\n\n**TEXT_MODE fallback:** Keep me.\n'; + const runtimes = [ + 'antigravity', 'augment', 'claude', 'cline', 'codebuddy', 'codex', 'cursor', 'hermes', + 'kilo', 'kimi', 'kimi-code', 'opencode', 'pi', 'qwen', 'trae', 'windsurf', 'zcode', + ]; + for (const runtime of runtimes) { + const out = liveConversion.filterRuntimeNotesForTarget(note, runtime); + assert.ok(!out.includes('vscode_askquestions'), `${runtime} must not receive the Copilot note`); + assert.ok(out.includes('TEXT_MODE fallback'), `${runtime} must retain neutral fallback guidance`); + } + assert.strictEqual(liveConversion.filterRuntimeNotesForTarget(note, 'copilot'), note); + }); + + test('a real OpenCode install filters mvp-phase workflow assets too', (t) => { + const { runMinimalInstall } = require('./helpers/install-shared.cjs'); + const { cleanup } = require('./helpers.cjs'); + const { configDir, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' }); + t.after(() => cleanup(root)); + const installed = fs.readFileSync(path.join(configDir, 'gsd-core', 'workflows', 'mvp-phase.md'), 'utf8'); + assert.ok(!installed.includes('vscode_askquestions')); + assert.ok(installed.includes('TEXT_MODE fallback')); + assert.ok(installed.includes('')); + }); +}); + for (const { label, convert, configDir } of flatRuntimeSuites) { describe(`${label} agent conversion (isAgent: true)`, () => { test('keeps name: field for agents', () => { @@ -273,6 +339,19 @@ describe('convertClaudeToKiloFrontmatter output parity: bin/install.js vs runtim }); }); +describe('convertClaudeToOpencodeFrontmatter output parity: bin/install.js vs runtime-artifact-conversion.cjs (#4482)', () => { + const { convertClaudeToOpencodeFrontmatter: convertViaConversionModule } = + require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + const input = `${SAMPLE_COMMAND}\n\n\n**Copilot (VS Code):** Use vscode_askquestions.\n`; + + test('published and module converters filter runtime notes identically', () => { + assert.equal( + convertClaudeToOpencodeFrontmatter(input), + convertViaConversionModule(input), + ); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // DEFECT.GENERATIVE-FIX output-parity guard: convertClaudeCommandToTraeSkill is // defined TWICE — once in bin/install.js (dead for the live skills-install