fix(#4482): strip Copilot notes from OpenCode artifacts (#4532)

* fix(#4482): strip Copilot notes from OpenCode artifacts

* chore: add changeset for #4532

* fix(#4482): filter runtime notes across emitted surfaces

* fix(#4482): make runtime note filtering runtime-neutral

* test(#4482): keep ack fixture outside registered provenance

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-10 16:29:08 -03:00
committed by GitHub
parent 1e47560e34
commit 93ba63aeff
7 changed files with 184 additions and 26 deletions

View File

@@ -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)

View File

@@ -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;

View File

@@ -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<string, string>([
['copilot (vs code)', 'copilot'],
]);
function filterRuntimeNotesForTarget(content: string, targetRuntime: string): string {
return content.replace(/<runtime_note(?:\s+runtime=["']([^"']+)["'])?>([\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 ? `<runtime_note>\n${body}\n</runtime_note>` : '';
});
}
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).

View File

@@ -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);

View File

@@ -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');
});

View File

@@ -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/<subdir> 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,

View File

@@ -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([
'<runtime_note>',
'**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`.',
'</runtime_note>',
'',
'<objective>Keep me.</objective>',
].join('\n'));
assert.ok(!out.includes('vscode_askquestions'));
assert.ok(!out.includes('<runtime_note>'));
assert.ok(out.includes('<objective>Keep me.</objective>'));
});
test('preserves runtime-neutral fallback text in a mixed note', () => {
const out = convert([
'<runtime_note>',
'**Copilot (VS Code):** Use `vscode_askquestions` instead of `AskUserQuestion`.',
'',
'**TEXT_MODE fallback:** Present a numbered list when interactive tools are unavailable.',
'</runtime_note>',
].join('\n'));
assert.ok(!out.includes('vscode_askquestions'));
assert.match(out, /<runtime_note>\n\*\*TEXT_MODE fallback:\*\*/);
assert.ok(out.includes('</runtime_note>'));
});
test('leaves unrelated runtime notes intact', () => {
const note = '<runtime_note>\n**OpenCode:** Keep this runtime-specific guidance.\n</runtime_note>';
assert.ok(convert(note).includes(note));
});
test('the shared audience filter covers every non-Copilot runtime and preserves Copilot', () => {
const note = '<runtime_note>\n**Copilot (VS Code):** Use `vscode_askquestions`.\n\n**TEXT_MODE fallback:** Keep me.\n</runtime_note>';
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('<runtime_note>'));
});
});
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<runtime_note>\n**Copilot (VS Code):** Use vscode_askquestions.\n</runtime_note>`;
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