/** * Runtime Converter Tests — OpenCode + Kilo + Gemini * * Tests for small runtime-specific conversion functions from install.js. * Larger runtime test suites (Copilot, Codex, Antigravity) have their own files. * * OpenCode/Kilo: flat-runtime frontmatter converters (agent + command modes) * model: inherit is NOT added (runtime uses its configured default model) * but mode: subagent IS added (required by both runtimes' agents). * Gemini: convertClaudeToGeminiAgent (frontmatter + tool mapping + body escaping) */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); process.env.GSD_TEST_MODE = '1'; const { convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, convertClaudeToGeminiAgent, convertClaudeAgentToAntigravityAgent, convertClaudeCommandToOpencodeSkill, convertClaudeCommandToKiloSkill, neutralizeAgentReferences, } = require('../bin/install.js'); // Sample Claude agent frontmatter (matches actual GSD agent format) const SAMPLE_AGENT = `--- name: gsd-executor description: Executes GSD plans with atomic commits tools: Read, Write, Edit, Bash, Grep, Glob color: yellow skills: - gsd-executor-workflow # hooks: # PostToolUse: # - matcher: "Write|Edit" # hooks: # - type: command # command: "npx eslint --fix $FILE 2>/dev/null || true" --- You are a GSD plan executor. `; // Sample Claude command frontmatter (for comparison — commands work differently) const SAMPLE_COMMAND = `--- name: gsd-execute-phase description: Execute all plans in a phase allowed-tools: - Read - Write - Bash --- Execute the phase plan.`; const flatRuntimeSuites = [ { label: 'OpenCode', convert: convertClaudeToOpencodeFrontmatter, configDir: '.config/opencode', }, { label: 'Kilo', convert: convertClaudeToKiloFrontmatter, configDir: '.config/kilo', }, ]; for (const { label, convert, configDir } of flatRuntimeSuites) { describe(`${label} agent conversion (isAgent: true)`, () => { test('keeps name: field for agents', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(frontmatter.includes('name: gsd-executor'), 'name: should be preserved for agents'); }); test('does not add model: inherit', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('model: inherit'), 'model: inherit should NOT be added'); }); test('adds mode: subagent', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(frontmatter.includes('mode: subagent'), 'mode: subagent should be added'); }); test('strips tools: field', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('tools:'), 'tools: should be stripped for agents'); assert.ok(!frontmatter.includes('read: true'), 'tools object should not be generated'); if (label === 'Kilo') { assert.ok(frontmatter.includes('permission:'), 'Kilo agents should emit permission block'); assert.ok(frontmatter.includes('read: allow'), 'Read should map to read: allow'); assert.ok(frontmatter.includes('edit: allow'), 'Write/Edit should map to edit: allow'); assert.ok(frontmatter.includes('bash: allow'), 'Bash should map to bash: allow'); assert.ok(frontmatter.includes('grep: allow'), 'Grep should map to grep: allow'); assert.ok(frontmatter.includes('glob: allow'), 'Glob should map to glob: allow'); assert.ok(frontmatter.includes('task: deny'), 'unspecified permissions should be denied'); } else { assert.ok(!frontmatter.includes('permission:'), 'OpenCode agents should not emit permission block'); } }); test('strips skills: array', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('skills:'), 'skills: should be stripped'); assert.ok(!frontmatter.includes('gsd-executor-workflow'), 'skill entries should be stripped'); }); test('strips color: field', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('color:'), 'color: should be stripped for agents'); }); test('strips commented hooks block', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('# hooks:'), 'commented hooks should be stripped'); assert.ok(!frontmatter.includes('PostToolUse'), 'hook content should be stripped'); }); test('keeps description: field', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(frontmatter.includes('description: Executes GSD plans'), 'description should be kept'); }); test('preserves body content', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); assert.ok(result.includes(''), 'body should be preserved'); assert.ok(result.includes('You are a GSD plan executor.'), 'body content should be intact'); }); test('applies body text replacements', () => { const agentWithClaudePaths = `--- name: test-agent description: Test tools: Read --- Read ~/.claude/agent-memory/ for context. Use $HOME/.claude/skills/ for reference. Check .claude/skills/ and .claude/agents/ locally. Use ./.claude/hooks/gsd-statusline.js during local testing. Fallback skills live in .agents/skills/.`; const result = convert(agentWithClaudePaths, { isAgent: true }); assert.ok(result.includes(`~/${configDir}/agent-memory/`), '~/.claude should be replaced'); assert.ok(result.includes(`$HOME/${configDir}/skills/`), '$HOME/.claude should be replaced'); if (label === 'Kilo') { assert.ok(result.includes('.kilo/skills/'), '.claude/skills should be replaced for Kilo'); assert.ok(result.includes('.kilo/agents/'), '.claude/agents should be replaced for Kilo'); assert.ok(result.includes('./.kilo/hooks/'), './.claude should be replaced for Kilo'); assert.ok(result.includes('Fallback skills live in .kilo/skills/.'), '.agents/skills should be rewritten to Kilo skills dir'); assert.ok(!result.includes('.kilo/skill/'), 'singular Kilo skill dir should not be emitted'); } }); }); describe(`${label} command conversion (isAgent: false, default)`, () => { test('strips name: field for commands', () => { const result = convert(SAMPLE_COMMAND); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('name:'), 'name: should be stripped for commands'); }); test('does not add model: or mode: for commands', () => { const result = convert(SAMPLE_COMMAND); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('model:'), 'model: should not be added for commands'); assert.ok(!frontmatter.includes('mode:'), 'mode: should not be added for commands'); }); test('keeps description: for commands', () => { const result = convert(SAMPLE_COMMAND); const frontmatter = result.split('---')[1]; assert.ok(frontmatter.includes('description:'), 'description should be kept'); }); }); // ─── #2256: model_overrides support for OpenCode/Kilo agents ──────────────── // Only test OpenCode — Kilo uses the same converter but model override injection // is wired only for OpenCode at the call site in install(). if (label === 'OpenCode') { describe('OpenCode agent model override (modelOverride option) (#2256)', () => { test('adds model: field when modelOverride is provided', () => { const result = convert(SAMPLE_AGENT, { isAgent: true, modelOverride: 'gpt-5.3-codex' }); const frontmatter = result.split('---')[1]; assert.ok(frontmatter.includes('model: gpt-5.3-codex'), 'model: field must be added with override value'); }); test('does not add model: field when modelOverride is null', () => { const result = convert(SAMPLE_AGENT, { isAgent: true, modelOverride: null }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('model:'), 'model: field must be absent when no override'); }); test('does not add model: field when modelOverride is omitted', () => { const result = convert(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('model:'), 'model: field must be absent when option omitted'); }); test('model: field appears after mode: subagent', () => { const result = convert(SAMPLE_AGENT, { isAgent: true, modelOverride: 'o4-mini' }); const frontmatter = result.split('---')[1]; const modeIdx = frontmatter.indexOf('mode: subagent'); const modelIdx = frontmatter.indexOf('model: o4-mini'); assert.ok(modeIdx !== -1, 'mode: subagent must be present'); assert.ok(modelIdx !== -1, 'model: field must be present'); assert.ok(modelIdx > modeIdx, 'model: must appear after mode: subagent'); }); test('model override does not affect command conversion', () => { // modelOverride has no effect when isAgent is false (commands) const result = convert(SAMPLE_COMMAND, { modelOverride: 'gpt-5.4' }); const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('model:'), 'model: must not appear in command output'); }); }); } } // ───────────────────────────────────────────────────────────────────────────── // Gemini CLI agent conversion (merged from gemini-config.test.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('convertClaudeToGeminiAgent', () => { test('drops unsupported skills frontmatter while keeping converted tools', () => { const input = `--- name: gsd-codebase-mapper description: Explores codebase and writes structured analysis documents. tools: Read, Bash, Grep, Glob, Write color: cyan skills: - gsd-mapper-workflow --- Use \${PHASE} in shell examples. `; const result = convertClaudeToGeminiAgent(input); const frontmatter = result.split('---')[1] || ''; assert.ok(frontmatter.includes('name: gsd-codebase-mapper'), 'keeps name'); assert.ok(frontmatter.includes('description: Explores codebase and writes structured analysis documents.'), 'keeps description'); assert.ok(frontmatter.includes('tools:'), 'adds Gemini tools array'); assert.ok(frontmatter.includes(' - read_file'), 'maps Read -> read_file'); assert.ok(frontmatter.includes(' - run_shell_command'), 'maps Bash -> run_shell_command'); assert.ok(frontmatter.includes(' - search_file_content'), 'maps Grep -> search_file_content'); assert.ok(frontmatter.includes(' - glob'), 'maps Glob -> glob'); assert.ok(frontmatter.includes(' - write_file'), 'maps Write -> write_file'); assert.ok(!frontmatter.includes('color:'), 'drops unsupported color field'); assert.ok(!frontmatter.includes('skills:'), 'drops unsupported skills field'); assert.ok(!frontmatter.includes('gsd-mapper-workflow'), 'drops skills list items'); assert.ok(result.includes('$PHASE'), 'escapes ${PHASE} shell variable for Gemini'); assert.ok(!result.includes('${PHASE}'), 'removes Gemini template-string pattern'); }); test('excludes Claude-only agent interaction tools from Gemini frontmatter', () => { const input = `--- name: gsd-debug-session-manager description: Manages debug sessions. tools: Read, Task, Agent, AskUserQuestion --- Coordinate debugger agents. Offer choices via AskUserQuestion when user input is needed. `; const result = convertClaudeToGeminiAgent(input); const frontmatter = result.split('---')[1] || ''; assert.ok(frontmatter.includes(' - read_file'), 'maps Read -> read_file'); assert.ok(!frontmatter.includes(' - ask_user'), 'does not emit invalid Gemini ask_user tool'); assert.ok(!frontmatter.includes(' - task'), 'does not emit invalid Gemini task tool'); assert.ok(!frontmatter.includes(' - agent'), 'does not emit invalid Gemini agent tool'); assert.ok(!frontmatter.includes('Task'), 'does not preserve Claude-only Task tool'); assert.ok(!frontmatter.includes('Agent'), 'does not preserve Claude-only Agent tool'); assert.ok(!frontmatter.includes('AskUserQuestion'), 'does not preserve Claude-only AskUserQuestion tool'); assert.ok(!result.includes('AskUserQuestion'), 'does not leave Claude-only tool references in the body'); assert.ok(result.includes('conversational prompting'), 'uses runtime-neutral body wording for user prompts'); }); describe('#1394 regression: excludes Skill/SlashCommand from Gemini frontmatter', () => { // Skill/SlashCommand are Claude-only tools with no Gemini built-in equivalent. // Without explicit exclusion they hit the lowercase fallback and emit an // invalid 'skill'/'slashcommand' tool name, which fails Gemini frontmatter // validation (tools.N: Invalid tool name) and aborts the entire agent load — // previously killing 22 of 34 GSD agents on Gemini. // Agent-level assertion against the live install path (criterion 2/3). // Asserts the emitted frontmatter rather than the internal converter so the // test exercises the public, install-path-exported API (convertGeminiToolName // is an internal helper, deliberately not exported per #1559). The Skill, // SlashCommand, and AskUserQuestion inputs all exercise the exclusion if-block; // Read/WebFetch exercise the mapped-tool path that must survive. test('Skill/SlashCommand/AskUserQuestion are dropped from emitted Gemini frontmatter', () => { const input = `--- name: gsd-planner description: Creates executable phase plans. tools: Read, Write, Bash, Glob, Grep, Skill, WebFetch, SlashCommand, AskUserQuestion --- Plan the phase. `; const result = convertClaudeToGeminiAgent(input); const frontmatter = result.split('---')[1] || ''; // Mapped tools still convert (the exclusion must not break the happy path). assert.ok(frontmatter.includes(' - read_file'), 'maps Read -> read_file'); assert.ok(frontmatter.includes(' - web_fetch'), 'maps WebFetch -> web_fetch'); // Claude-only tools with no Gemini equivalent are excluded, not lowercased // into invalid names that would fail frontmatter validation (#1394 / #3362). assert.ok(!frontmatter.includes(' - skill'), 'does not emit invalid Gemini skill tool'); assert.ok(!frontmatter.includes(' - slashcommand'), 'does not emit invalid Gemini slashcommand tool'); assert.ok(!frontmatter.includes(' - ask_user'), 'AskUserQuestion remains excluded'); assert.ok(!frontmatter.includes(' - askuserquestion'), 'AskUserQuestion is not lowercased into an invalid tool'); }); // Antigravity reuses convertGeminiToolName (it runs on the Gemini backend), // so the exclusion intentionally applies there too. Antigravity surfaces GSD // skills through the skill surface (SKILL.md), not the agent tools: allowlist, // so dropping the invalid 'skill' tool name does not remove skill access — // this locks that cross-runtime behavior (criterion 4). test('Antigravity conversion also excludes Skill/SlashCommand (shared Gemini backend)', () => { const input = `--- name: gsd-planner description: Creates executable phase plans. tools: Read, Write, Bash, Skill, WebFetch, SlashCommand --- Plan the phase.`; const result = convertClaudeAgentToAntigravityAgent(input); const toolsLine = result.split('\n').find(l => l.startsWith('tools:')) || ''; assert.ok(toolsLine.includes('read_file'), 'maps Read -> read_file'); assert.ok(toolsLine.includes('web_fetch'), 'maps WebFetch -> web_fetch'); assert.ok(!/\bskill\b/.test(toolsLine), 'no invalid skill tool in Antigravity frontmatter'); assert.ok(!/\bslashcommand\b/.test(toolsLine), 'no invalid slashcommand tool in Antigravity frontmatter'); }); }); }); // ─── neutralizeAgentReferences (#766) ───────────────────────────────────────── describe('neutralizeAgentReferences', () => { test('replaces standalone Claude with "the agent"', () => { const input = 'Claude handles these decisions. Claude should read the file.'; const result = neutralizeAgentReferences(input, 'AGENTS.md'); assert.ok(!result.includes('Claude handles'), 'standalone Claude replaced'); assert.ok(result.includes('the agent handles'), 'replaced with "the agent"'); }); test('preserves Claude Code (product name)', () => { const input = 'This is a Claude Code bug. Use Claude Code settings.'; const result = neutralizeAgentReferences(input, 'AGENTS.md'); assert.ok(result.includes('Claude Code bug'), 'Claude Code preserved'); assert.ok(result.includes('Claude Code settings'), 'Claude Code preserved'); }); test('preserves Claude model names', () => { const input = 'Use Claude Opus for planning. Claude Sonnet for execution. Claude Haiku for research.'; const result = neutralizeAgentReferences(input, 'AGENTS.md'); assert.ok(result.includes('Claude Opus'), 'Opus preserved'); assert.ok(result.includes('Claude Sonnet'), 'Sonnet preserved'); assert.ok(result.includes('Claude Haiku'), 'Haiku preserved'); }); test('replaces CLAUDE.md with runtime instruction file', () => { const input = 'Read CLAUDE.md for project instructions. Check ./CLAUDE.md if exists.'; const result = neutralizeAgentReferences(input, 'AGENTS.md'); assert.ok(result.includes('AGENTS.md'), 'CLAUDE.md -> AGENTS.md'); assert.ok(!result.includes('CLAUDE.md'), 'no CLAUDE.md remains'); }); test('uses different instruction file per runtime', () => { const input = 'Read CLAUDE.md for instructions.'; assert.ok(neutralizeAgentReferences(input, 'GEMINI.md').includes('GEMINI.md')); assert.ok(neutralizeAgentReferences(input, 'copilot-instructions.md').includes('copilot-instructions.md')); assert.ok(neutralizeAgentReferences(input, 'AGENTS.md').includes('AGENTS.md')); }); test('removes AGENTS.md load-blocking instruction', () => { const input = 'Do NOT load full `AGENTS.md` files — they contain agent definitions.'; const result = neutralizeAgentReferences(input, 'AGENTS.md'); assert.ok(!result.includes('Do NOT load full'), 'blocking instruction removed'); }); test('preserves claude- prefixes (CSS classes, package names)', () => { const input = 'The claude-ctx session and claude-code package.'; const result = neutralizeAgentReferences(input, 'AGENTS.md'); assert.ok(result.includes('claude-ctx'), 'claude- prefix preserved'); assert.ok(result.includes('claude-code'), 'claude-code preserved'); }); }); // ─── OpenCode-family skill converters (SKILL.md) — #784 ────────────────────── const SKILL_SAMPLE_COMMAND = `--- description: Show available GSD commands and usage guide argument-hint: "[topic]" allowed-tools: - Read - Bash --- Run \`/gsd:help\` to see the guide. AskUserQuestion when unsure. `; const SKILL_BETA_COMMAND = `--- description: "[BETA] Offload plan phase to the cloud and import back." --- Body for /gsd:ultraplan-phase. `; describe('convertClaudeCommandToOpencodeSkill / convertClaudeCommandToKiloSkill (#784)', () => { const cases = [ { label: 'opencode', convert: convertClaudeCommandToOpencodeSkill }, { label: 'kilo', convert: convertClaudeCommandToKiloSkill }, ]; for (const { label, convert } of cases) { describe(`${label} skill conversion`, () => { test('emits SKILL.md frontmatter with name matching the skill dir', () => { const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); assert.ok(out.startsWith('---\n'), 'opens with frontmatter'); assert.match(out, /^name: gsd-help$/m, 'name equals the skill name'); }); test('preserves the description from the source command', () => { const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); assert.match(out, /^description: "Show available GSD commands and usage guide"$/m); }); test('drops the command tools/permission block (skills inherit perms)', () => { const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); const fmEnd = out.indexOf('\n---', 4); const fm = out.slice(0, fmEnd); assert.ok(!/tools:/.test(fm), 'no tools block in skill frontmatter'); assert.ok(!/permission:/.test(fm), 'no permission block in skill frontmatter'); }); test('rewrites /gsd: colon refs to hyphen form in the body', () => { const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); assert.ok(!/\/gsd:/.test(out), 'no /gsd: colon refs remain'); assert.match(out, /\/gsd-help/, 'colon ref rewritten to hyphen form'); }); test('quotes descriptions with leading YAML flow indicators ([BETA])', () => { const out = convert(SKILL_BETA_COMMAND, 'gsd-ultraplan-phase'); assert.match(out, /^description: "\[BETA\] /m, 'leading [BETA] safely quoted'); }); test('falls back to a synthetic description when none present', () => { const out = convert('Body only, no frontmatter.', 'gsd-mystery'); assert.match(out, /^name: gsd-mystery$/m); assert.match(out, /^description: "Run GSD workflow gsd-mystery\."$/m); }); }); } }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/feat-1173-agent-converters-descriptor.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:feat-1173-agent-converters-descriptor (consolidation epic #1969 B3 #1972)", () => { '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'); }); test('scope threads isGlobal to a scope-aware converter (global vs local differ)', (t) => { // The plumbing kept by #1173 (option a): convertedAgentsKind / dispatchKindEntry // pass the install scope to the converter as isGlobal. A scope-aware converter // (copilot) must therefore produce different output for global vs local. This // proves the thread is live via a synthetic descriptor — no real runtime // declares a converted agents kind yet (declarations deferred to the ADR-1235 // §0 parity follow-up). const fixtureRoot = makeFixtureRoot([{ name: 'gsd-planner.md', content: CLAUDE_AGENT_SOURCE }]); t.after(() => { cleanup(fixtureRoot); cleanupStagedSkills(); }); const agentsEntry = { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: 'convertClaudeAgentToCopilotAgent', }; const registry = { runtimes: { testruntime: { runtime: { artifactLayout: { global: [agentsEntry], local: [agentsEntry] } } } }, }; const profile = { name: 'full', skills: '*', agents: new Set() }; const stageFor = (scope) => { const layout = resolveRuntimeArtifactLayoutFromRegistry(registry, 'testruntime', fixtureRoot, scope); const agentKind = layout.kinds.find((k) => k.kind === 'agents'); assert.ok(agentKind, `${scope} layout must include an agents kind`); return fs.readFileSync(path.join(agentKind.stage(profile), 'gsd-planner.md'), 'utf8'); }; const globalOut = stageFor('global'); const localOut = stageFor('local'); assert.notStrictEqual( globalOut, localOut, 'scope-aware converter output must differ by scope — proves isGlobal is threaded from the descriptor scope', ); }); }); // ─── 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'); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/fix-1515-codex-runtime-default.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:fix-1515-codex-runtime-default (consolidation epic #1969 B3 #1972)", () => { 'use strict'; /** * Regression tests for bug #1515: Codex install with runtime-neutral * .planning/config.json resolves runtime as 'claude' and enables worktree * isolation (unsafe for Codex). * * Root causes: * A) config-get reads in workflows lacked --raw → output JSON-quoted → * every comparison like [ "$RUNTIME" = "codex" ] failed silently. * B) The conversion engine emitted --default claude for every runtime → * neutral Codex config fell back to claude default. * * All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads, * except the integration test (test 4) which is explicitly the source↔engine * parity guard and carries the allow-test-rule exemption. */ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const fc = require('fast-check'); const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); // --------------------------------------------------------------------------- // Unit tests: engine stamps codex-specific defaults into emitted workflows // --------------------------------------------------------------------------- test('codex emit stamps its own runtime default into the runtime-resolution line', () => { const line = 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); assert.ok( out.includes('config-get runtime --default codex --raw'), `Expected 'config-get runtime --default codex --raw' in output; got:\n${out}`, ); assert.ok( out.includes('|| echo "codex")'), `Expected '|| echo "codex")' in output; got:\n${out}`, ); assert.ok( !out.includes('--default claude'), `Expected '--default claude' to be fully rewritten; got:\n${out}`, ); assert.ok( !out.includes('echo "claude"'), `Expected 'echo "claude"' to be fully rewritten; got:\n${out}`, ); }); test('codex emit defaults workflow.use_worktrees to false', () => { const line = 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); assert.ok( out.includes('config-get workflow.use_worktrees --default false --raw'), `Expected 'config-get workflow.use_worktrees --default false --raw' in output; got:\n${out}`, ); assert.ok( out.includes('|| echo "false")'), `Expected '|| echo "false")' in output; got:\n${out}`, ); assert.ok( !out.includes('|| echo "true")'), `Expected '|| echo "true")' to be fully rewritten; got:\n${out}`, ); }); test('claude runtime does NOT rewrite the runtime default — stamping is non-claude-scoped (#1521 inversion)', () => { // #1521 generalizes stamping to ALL non-Claude runtimes. The negative case // (no stamping) is now the 'claude' runtime, not other non-Claude runtimes. const line = 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; const out = conversion._applyRuntimeRewrites(line, 'claude', '$HOME/.claude/', true, undefined); assert.ok( out.includes('--default claude --raw'), `Expected claude output to preserve '--default claude --raw'; got:\n${out}`, ); assert.ok( !out.includes('--default codex'), `Expected claude output NOT to contain '--default codex'; got:\n${out}`, ); }); // --------------------------------------------------------------------------- // Integration / parity guard: real source ↔ engine output for codex (all surfaces) // --------------------------------------------------------------------------- test('regression: every edited workflow gets codex-stamped (source↔engine parity, all surfaces) (#1515)', () => { // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1515) — asserts on engine-transformed output of the real source const WORKFLOWS = ['execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md']; const CLAUDE_RUNTIME = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"'; const CODEX_RUNTIME = 'config-get runtime --default codex --raw 2>/dev/null || echo "codex"'; const TRUE_WT = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'; const FALSE_WT = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"'; for (const wf of WORKFLOWS) { const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', wf), 'utf8'); const out = conversion._applyRuntimeRewrites(src, 'codex', '$HOME/.codex/', true, undefined); // No un-stamped claude/true resolution line may survive codex emit on ANY surface. assert.ok(!out.includes(CLAUDE_RUNTIME), `${wf}: residual un-stamped runtime read — engine regex no longer matches source line (parity drift)`); assert.ok(!out.includes(TRUE_WT), `${wf}: residual un-stamped use_worktrees read — parity drift`); // If the source HAS such a read, the codex form must be present. if (src.includes(CLAUDE_RUNTIME)) assert.ok(out.includes(CODEX_RUNTIME), `${wf}: runtime read not stamped to codex`); if (src.includes(TRUE_WT)) assert.ok(out.includes(FALSE_WT), `${wf}: use_worktrees read not defaulted to false`); } }); // --------------------------------------------------------------------------- // Property tests (RULESET.TESTS.property-based-testing) // --------------------------------------------------------------------------- test('property: runtime stamping applies for ALL non-claude runtimes; only claude leaves --default claude unchanged (#1521)', () => { // #1521: generalised from codex-only to all non-claude runtimes. // Use the canonical list from the conversion module to avoid hand-rolled array drift. const { NON_CLAUDE_RUNTIMES } = conversion; const RUNTIMES = ['claude', ...NON_CLAUDE_RUNTIMES]; const line = 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; fc.assert(fc.property(fc.constantFrom(...RUNTIMES), (rt) => { const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); return rt === 'claude' ? out.includes('--default claude --raw') && !out.includes('--default codex') : out.includes(`--default ${rt} --raw`) && !out.includes('--default claude'); })); }); test('property: codex stamping is idempotent on resolution lines (#1515)', () => { fc.assert(fc.property(fc.constantFrom('runtime', 'use_worktrees'), (which) => { const line = which === 'runtime' ? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n' : 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; const once = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined); const twice = conversion._applyRuntimeRewrites(once, 'codex', '$HOME/.codex/', true, undefined); return once === twice; })); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/fix-1521-non-claude-runtime-default-resolution.test.cjs — consolidation epic #1969 (B3 #1972) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:fix-1521-non-claude-runtime-default-resolution (consolidation epic #1969 B3 #1972)", () => { 'use strict'; /** * Regression tests for #1521: every non-Claude runtime stamps its own runtime * identity + workflow.use_worktrees=false into emitted workflows. * * GSD's worktree isolation relies on Claude Code's isolation="worktree" spawn * parameter, which no other runtime honors. #1519 (Codex-only fix) is * generalized here to ALL non-Claude runtimes. * * All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads, * except the parity integration test which carries the allow-test-rule exemption. */ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const fc = require('fast-check'); const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); // #1521: use the canonical list from the conversion module rather than a hand-rolled // local array that can drift from the real runtime set. const { NON_CLAUDE_RUNTIMES: NON_CLAUDE } = conversion; const WORKFLOWS = [ 'execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md', ]; const CLAUDE_RUNTIME_LINE = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"'; const TRUE_WT_LINE = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'; const FALSE_WT_LINE = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"'; // --------------------------------------------------------------------------- // Parity across ALL non-Claude runtimes × all 5 workflows // --------------------------------------------------------------------------- test('parity: every non-Claude runtime stamps its own runtime default and use_worktrees=false on all workflows (#1521)', () => { // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521) for (const rt of NON_CLAUDE) { for (const wf of WORKFLOWS) { const src = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', wf), 'utf8', ); const out = conversion._applyRuntimeRewrites(src, rt, `$HOME/.${rt}/`, true, undefined); // No un-stamped claude runtime line may survive assert.ok( !out.includes(CLAUDE_RUNTIME_LINE), `${rt}/${wf}: residual un-stamped claude runtime read — _stampNonClaudeRuntimeDefaults not applied`, ); // No un-stamped use_worktrees=true line may survive assert.ok( !out.includes(TRUE_WT_LINE), `${rt}/${wf}: residual un-stamped use_worktrees=true read — _stampNonClaudeRuntimeDefaults not applied`, ); // If the source had a runtime read, the output must have --default if (src.includes(CLAUDE_RUNTIME_LINE)) { assert.ok( out.includes(`config-get runtime --default ${rt} --raw 2>/dev/null || echo "${rt}"`), `${rt}/${wf}: runtime line not stamped to --default ${rt}`, ); } // If the source had a use_worktrees read, the output must have --default false if (src.includes(TRUE_WT_LINE)) { assert.ok( out.includes(FALSE_WT_LINE), `${rt}/${wf}: use_worktrees line not defaulted to false`, ); } } } }); // --------------------------------------------------------------------------- // Claude unchanged — no stamping for the native runtime // --------------------------------------------------------------------------- test('claude runtime leaves runtime default and use_worktrees=true unchanged (#1521)', () => { const src = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'), 'utf8', ); const out = conversion._applyRuntimeRewrites(src, 'claude', '$HOME/.claude/', true, undefined); // Claude emit must preserve the original --default claude line if (src.includes(CLAUDE_RUNTIME_LINE)) { assert.ok( out.includes(CLAUDE_RUNTIME_LINE), `claude/execute-phase.md: expected original claude runtime line to survive; got mutated`, ); } // Claude emit must NOT gain --default false for use_worktrees assert.ok( !out.includes(FALSE_WT_LINE), `claude/execute-phase.md: use_worktrees line must NOT be stamped false for claude runtime`, ); }); // --------------------------------------------------------------------------- // fc property — identity: each runtime stamps itself, claude stays unchanged // --------------------------------------------------------------------------- test('property: _stampNonClaudeRuntimeDefaults stamps each non-claude runtime and leaves claude unchanged (#1521)', () => { const line = 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'; fc.assert( fc.property(fc.constantFrom(...NON_CLAUDE, 'claude'), (rt) => { const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); if (rt === 'claude') { return out.includes('--default claude') && !/--default (?!claude)/.test(out); } return out.includes(`--default ${rt}`) && !out.includes('--default claude'); }), ); }); // --------------------------------------------------------------------------- // fc property — idempotence: stamping twice equals once // --------------------------------------------------------------------------- test('property: _stampNonClaudeRuntimeDefaults is idempotent (#1521)', () => { fc.assert( fc.property( fc.constantFrom(...NON_CLAUDE), fc.constantFrom('runtime', 'use_worktrees'), (rt, which) => { const line = which === 'runtime' ? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n' : 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n'; const once = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined); const twice = conversion._applyRuntimeRewrites(once, rt, `$HOME/.${rt}/`, true, undefined); return once === twice; }, ), ); }); // --------------------------------------------------------------------------- // Guard generalization: execute-phase.md uses != "claude" not = "codex" // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Guard generalization: execute-phase.md, quick.md, and diagnose-issues.md // all use != "claude" (not = "codex") for the worktree guard (#1521) // --------------------------------------------------------------------------- test('execute-phase.md, quick.md, and diagnose-issues.md guards are generalized to != "claude" (not Codex-specific) (#1521)', () => { // allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521) const GUARD_WORKFLOWS = ['execute-phase.md', 'quick.md', 'diagnose-issues.md']; for (const wf of GUARD_WORKFLOWS) { const src = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', wf), 'utf8', ); assert.ok( src.includes('[ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]'), `${wf}: expected generalized guard [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]`, ); assert.ok( !src.includes('[ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]'), `${wf}: found Codex-specific guard — should have been generalized to != "claude"`, ); } }); // --------------------------------------------------------------------------- // Orchestration gating: manager.md + autonomous.md gate background dispatch on // the typed FLATTEN query. #1708 (ADR-1239 Phase B) graduated #1521's // codex-specific check to a documentation-sourced shouldFlattenDispatch — the // prose now branches on `FLATTEN` (false = background), not a runtime name. // --------------------------------------------------------------------------- test('manager.md and autonomous.md gate run_in_background on FLATTEN=false, not a runtime name (#1521, graduated by #1708)', () => { // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521/#1708) const manager = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), 'utf8', ); const autonomous = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'), 'utf8', ); // Both files must gate run_in_background on the typed FLATTEN decision (not a runtime name) assert.ok( /If `FLATTEN` is `false`[\s\S]{0,500}?run_in_background=true/.test(manager), 'manager.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', ); assert.ok( /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true/.test(autonomous), 'autonomous.md: expected run_in_background dispatch gated on FLATTEN=false (typed dispatch-should-flatten query)', ); // Inline is the else branch, keyed on FLATTEN — never a runtime name assert.ok( /Otherwise[\s\S]{0,250}?inline/i.test(manager), 'manager.md: expected "Otherwise ... inline" branch keyed on FLATTEN', ); assert.ok( /Otherwise[\s\S]{0,250}?inline/i.test(autonomous), 'autonomous.md: expected "Otherwise ... inline" branch keyed on FLATTEN', ); // And the old runtime-name gating must be gone (no `RUNTIME` is `codex` dispatch gate) assert.ok( !/`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager), 'manager.md: must no longer gate run_in_background on the runtime name', ); }); test('manager.md and autonomous.md no longer contain old "not claude" background-dispatch gating (#1521)', () => { // allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521) const manager = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'), 'utf8', ); const autonomous = fs.readFileSync( path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'), 'utf8', ); // The old phrasing that unconditionally sent every non-claude runtime to background must be gone assert.ok( !manager.includes('If `RUNTIME` is not `claude` (e.g. Codex)'), 'manager.md: old "If `RUNTIME` is not `claude` (e.g. Codex)" gating must be replaced', ); assert.ok( !autonomous.includes('On other runtimes:'), 'autonomous.md: old "On other runtimes:" branch label must be replaced', ); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2876-skill-frontmatter-quote.test.cjs — consolidation epic #1969 (B8 #1977) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2876-skill-frontmatter-quote (consolidation epic #1969 B8 #1977)", () => { /** * Bug #2876: SKILL.md frontmatter parse failure when `description` begins * with a YAML flow indicator like `[BETA]`. * * description: [BETA] Offload plan phase to Claude Code's ultraplan… * * YAML 1.2 treats a leading `[` as the start of a flow sequence, so any * downstream parser (gh-copilot, JetBrains' kit, etc.) fails with * "Unexpected scalar at node end". The Copilot/Antigravity/Trae/Codebuddy * skill+agent converters in `bin/install.js` re-emit the description * unquoted; the Claude variant `yamlQuote(...)`s it. Bring the others * in line so any value is round-trip-safe regardless of leading char. * * The test is structural: it parses each emitted frontmatter into lines * and asserts the `description` value is a quoted YAML scalar (double or * single quoted) when the source description starts with a flow indicator. * It does not regex the bytes for substrings. */ 'use strict'; process.env.GSD_TEST_MODE = '1'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const fs = require('node:fs'); const REPO_ROOT = path.join(__dirname, '..'); const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')); const installPath = path.resolve(REPO_ROOT, pkg.bin['gsd-core']); const install = require(installPath); // Build a minimal Claude command source whose description starts with the // reporter's exact flow-indicator prefix. Apostrophe in the body forces // any naive single-quoting to also escape correctly — the canonical // safe form is `JSON.stringify(...)` (used by yamlQuote). const REPORTER_DESCRIPTION = "[BETA] Offload plan phase to Claude Code's ultraplan cloud — drafts remotely while terminal stays free, review in browser with inline comments, import back via /gsd-import. Claude Code only."; // Use unquoted description in the source frontmatter — that's exactly the // shape that ships in commands/gsd/*.md when authors paste a description // without quoting it (see commands/gsd/ultraplan-phase.md). The bug is // triggered when the converter re-emits this same value to the destination // runtime without quoting. `extractFrontmatterField` strips a single outer // quote pair but does not unescape internal characters, so quoting the // fixture input would actually mask the bug. function buildClaudeCommand(description) { return [ '---', 'name: gsd:ultraplan-phase', `description: ${description}`, 'argument-hint: "[phase-number]"', 'allowed-tools:', ' - Read', ' - Bash', '---', '', '# body', '', ].join('\n'); } function buildClaudeAgent(description) { return [ '---', 'name: gsd-extract-learnings', `description: ${description}`, 'tools: Read, Bash', '---', '', '# body', '', ].join('\n'); } function extractFrontmatter(content) { // Leading delimiter is `---\n`; closing is the next standalone `---` // on its own line. Tests parse line-structurally so the assertion // doesn't drift on whitespace/order changes (per project test-rigor). assert.ok(content.startsWith('---'), `output must begin with frontmatter, got: ${content.slice(0, 40)}`); const lines = content.split('\n'); let openIdx = -1; let closeIdx = -1; for (let i = 0; i < lines.length; i += 1) { if (lines[i] === '---') { if (openIdx === -1) openIdx = i; else { closeIdx = i; break; } } } assert.ok(openIdx !== -1 && closeIdx !== -1, `output must have a closed frontmatter block, got:\n${content}`); return lines.slice(openIdx + 1, closeIdx); } function findDescriptionLine(frontmatterLines) { for (const line of frontmatterLines) { if (line.startsWith('description:')) return line; } assert.fail(`no description line found in frontmatter:\n${frontmatterLines.join('\n')}`); return ''; // unreachable } function isQuotedYamlScalar(valueText) { // YAML safe-quoted scalar: starts with `"` and ends with `"`, OR // starts with `'` and ends with `'`. This is what `yamlQuote()` // (JSON.stringify) and the Claude variant of these converters emit. const trimmed = valueText.trim(); if (trimmed.startsWith('"') && trimmed.endsWith('"')) return true; if (trimmed.startsWith("'") && trimmed.endsWith("'")) return true; return false; } function parseQuotedYamlValue(valueText) { const trimmed = valueText.trim(); if (trimmed.startsWith('"')) return JSON.parse(trimmed); if (trimmed.startsWith("'")) return trimmed.slice(1, -1).replace(/''/g, "'"); return trimmed; } function assertDescriptionRoundTrips(emitted, expected, label) { const fmLines = extractFrontmatter(emitted); const descLine = findDescriptionLine(fmLines); const valueText = descLine.slice('description:'.length); assert.ok( isQuotedYamlScalar(valueText), `(${label}) description must be a quoted YAML scalar (parser-safe for leading flow indicators). Got line: ${descLine}`, ); assert.strictEqual( parseQuotedYamlValue(valueText), expected, `(${label}) description must round-trip through YAML quoting unchanged.`, ); } const COMMAND_CONVERTERS = [ { label: 'convertClaudeCommandToCopilotSkill', fn: (src) => install.convertClaudeCommandToCopilotSkill(src, 'gsd-ultraplan-phase') }, { label: 'convertClaudeCommandToAntigravitySkill', fn: (src) => install.convertClaudeCommandToAntigravitySkill(src, 'gsd-ultraplan-phase') }, { label: 'convertClaudeCommandToTraeSkill', fn: (src) => install.convertClaudeCommandToTraeSkill(src, 'gsd-ultraplan-phase') }, { label: 'convertClaudeCommandToCodebuddySkill', fn: (src) => install.convertClaudeCommandToCodebuddySkill(src, 'gsd-ultraplan-phase') }, ]; const AGENT_CONVERTERS = [ { label: 'convertClaudeAgentToCopilotAgent', fn: (src) => install.convertClaudeAgentToCopilotAgent(src) }, { label: 'convertClaudeAgentToAntigravityAgent', fn: (src) => install.convertClaudeAgentToAntigravityAgent(src) }, ]; // A grab-bag of leading characters that all break unquoted YAML scalar // parsing per YAML 1.2 §7.3.3 / §6.9. The reporter's case is `[`; the // rest defend against neighbouring drift. const FLOW_HOSTILE_PREFIXES = ['[', '{', '*', '&', '!', '|', '>', '%', '@', '`']; // Some converters (Trae, CodeBuddy) deliberately rewrite "Claude Code" // in body content to their target runtime name, and the rewrite cuts // across the description too. That's correct behavior — out of scope for // the YAML-quoting fix — so for the reporter case we assert only the // quoting requirement, not byte-equality of the round-tripped value. function assertDescriptionIsQuoted(emitted, label) { const fmLines = extractFrontmatter(emitted); const descLine = findDescriptionLine(fmLines); const valueText = descLine.slice('description:'.length); assert.ok( isQuotedYamlScalar(valueText), `(${label}) description must be a quoted YAML scalar (parser-safe for leading flow indicators). Got line: ${descLine}`, ); } describe('bug-2876: skill+agent converters emit YAML-quoted description', () => { for (const { label, fn } of COMMAND_CONVERTERS) { test(`${label}: reporter's "[BETA] ..." description is quoted`, () => { const out = fn(buildClaudeCommand(REPORTER_DESCRIPTION)); assertDescriptionIsQuoted(out, label); }); for (const prefix of FLOW_HOSTILE_PREFIXES) { test(`${label}: leading ${JSON.stringify(prefix)} is quoted`, () => { // Avoid leading/trailing `'` or `"` in the payload — `extractFrontmatterField` // strips a single outer quote char of either kind regardless of whether // the value was actually quoted, which would obscure the round-trip // assertion. Pre-existing behavior, out of scope for #2876. const desc = `${prefix} edge-case payload — flow indicator at start`; const out = fn(buildClaudeCommand(desc)); assertDescriptionRoundTrips(out, desc, `${label} prefix=${prefix}`); }); } } for (const { label, fn } of AGENT_CONVERTERS) { test(`${label}: reporter-shape "[BETA] ..." description is quoted`, () => { const out = fn(buildClaudeAgent(REPORTER_DESCRIPTION)); assertDescriptionIsQuoted(out, label); }); } }); }); }