* 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:
5
.changeset/quiet-lynxes-filter.md
Normal file
5
.changeset/quiet-lynxes-filter.md
Normal 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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user