diff --git a/.gitignore b/.gitignore index 79e4be8bc..23390de76 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,10 @@ docs/GSD-SYSTEM-SPECIFICATION.md gaps.md improve.md philosophy.md + +# Installed skills +.github/agents/gsd-* +.github/skills/gsd-* +.github/get-shit-done/* +.github/skills/get-shit-done +.github/copilot-instructions.md \ No newline at end of file diff --git a/bin/install.js b/bin/install.js index 3f50bf660..7d5b632dd 100755 --- a/bin/install.js +++ b/bin/install.js @@ -16,6 +16,10 @@ const reset = '\x1b[0m'; // Codex config.toml constants const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by get-shit-done installer'; +// Copilot instructions marker constants +const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; +const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; + const CODEX_AGENT_SANDBOX = { 'gsd-executor': 'workspace-write', 'gsd-planner': 'workspace-write', @@ -30,6 +34,23 @@ const CODEX_AGENT_SANDBOX = { 'gsd-integration-checker': 'read-only', }; +// Copilot tool name mapping — Claude Code tools to GitHub Copilot tools +// Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision) +const claudeToCopilotTools = { + Read: 'read', + Write: 'edit', + Edit: 'edit', + Bash: 'execute', + Grep: 'search', + Glob: 'search', + Task: 'agent', + WebSearch: 'web', + WebFetch: 'web', + TodoWrite: 'todo', + AskUserQuestion: 'ask_user', + SlashCommand: 'skill', +}; + // Get version from package.json const pkg = require('../package.json'); @@ -41,6 +62,7 @@ const hasOpencode = args.includes('--opencode'); const hasClaude = args.includes('--claude'); const hasGemini = args.includes('--gemini'); const hasCodex = args.includes('--codex'); +const hasCopilot = args.includes('--copilot'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); @@ -48,7 +70,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u'); // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { - selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex']; + selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { @@ -56,6 +78,7 @@ if (hasAll) { if (hasClaude) selectedRuntimes.push('claude'); if (hasGemini) selectedRuntimes.push('gemini'); if (hasCodex) selectedRuntimes.push('codex'); + if (hasCopilot) selectedRuntimes.push('copilot'); } /** @@ -75,6 +98,7 @@ function toHomePrefix(pathPrefix) { // Helper to get directory name for a runtime (used for local/project installs) function getDirName(runtime) { + if (runtime === 'copilot') return '.github'; if (runtime === 'opencode') return '.opencode'; if (runtime === 'gemini') return '.gemini'; if (runtime === 'codex') return '.codex'; @@ -84,7 +108,7 @@ function getDirName(runtime) { /** * Get the config directory path relative to home directory for a runtime * Used for templating hooks that use path.join(homeDir, '', ...) - * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex' + * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot' * @param {boolean} isGlobal - Whether this is a global install */ function getConfigDirFromHome(runtime, isGlobal) { @@ -93,6 +117,7 @@ function getConfigDirFromHome(runtime, isGlobal) { return `'${getDirName(runtime)}'`; } // Global installs - OpenCode uses XDG path structure + if (runtime === 'copilot') return "'.copilot'"; if (runtime === 'opencode') { // OpenCode: ~/.config/opencode -> '.config', 'opencode' // Return as comma-separated for path.join() replacement @@ -130,7 +155,7 @@ function getOpencodeGlobalDir() { /** * Get the global config directory for a runtime - * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex' + * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot' * @param {string|null} explicitDir - Explicit directory from --config-dir flag */ function getGlobalDir(runtime, explicitDir = null) { @@ -163,6 +188,17 @@ function getGlobalDir(runtime, explicitDir = null) { } return path.join(os.homedir(), '.codex'); } + + if (runtime === 'copilot') { + // Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot + if (explicitDir) { + return expandTilde(explicitDir); + } + if (process.env.COPILOT_CONFIG_DIR) { + return expandTilde(process.env.COPILOT_CONFIG_DIR); + } + return path.join(os.homedir(), '.copilot'); + } // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude if (explicitDir) { @@ -184,7 +220,7 @@ const banner = '\n' + '\n' + ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' + ' A meta-prompting, context engineering and spec-driven\n' + - ' development system for Claude Code, OpenCode, Gemini, and Codex by TÂCHES.\n'; + ' development system for Claude Code, OpenCode, Gemini, Codex, and Copilot by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { @@ -218,7 +254,7 @@ console.log(banner); // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Codex globally${reset}\n npx get-shit-done-cc --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Codex globally${reset}\n npx get-shit-done-cc --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -268,7 +304,7 @@ const attributionCache = new Map(); /** * Get commit attribution setting for a runtime - * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex' + * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot' * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom */ function getCommitAttribution(runtime) { @@ -303,7 +339,7 @@ function getCommitAttribution(runtime) { result = settings.attribution.commit; } } else { - // Codex currently has no attribution setting equivalent + // Codex and Copilot currently have no attribution setting equivalent result = undefined; } @@ -421,6 +457,116 @@ function convertGeminiToolName(claudeTool) { return claudeTool.toLowerCase(); } +/** + * Convert a Claude Code tool name to GitHub Copilot format. + * - Applies explicit mapping from claudeToCopilotTools + * - Handles mcp__context7__* prefix → io.github.upstash/context7/* + * - Falls back to lowercase for unknown tools + */ +function convertCopilotToolName(claudeTool) { + // mcp__context7__* wildcard → io.github.upstash/context7/* + if (claudeTool.startsWith('mcp__context7__')) { + return 'io.github.upstash/context7/' + claudeTool.slice('mcp__context7__'.length); + } + // Check explicit mapping + if (claudeToCopilotTools[claudeTool]) { + return claudeToCopilotTools[claudeTool]; + } + // Default: lowercase + return claudeTool.toLowerCase(); +} + +/** + * Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names). + * Path mappings depend on install mode: + * Global: ~/.claude/ → ~/.copilot/, ./.claude/ → ./.github/ + * Local: ~/.claude/ → ./.github/, ./.claude/ → ./.github/ + * Applied to ALL Copilot content (skills, agents, engine files). + * @param {string} content - Source content to convert + * @param {boolean} [isGlobal=false] - Whether this is a global install + */ +function convertClaudeToCopilotContent(content, isGlobal = false) { + let c = content; + // CONV-06: Path replacement — most specific first to avoid substring matches + if (isGlobal) { + c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.copilot/'); + c = c.replace(/~\/\.claude\//g, '~/.copilot/'); + } else { + c = c.replace(/\$HOME\/\.claude\//g, '.github/'); + c = c.replace(/~\/\.claude\//g, '.github/'); + } + c = c.replace(/\.\/\.claude\//g, './.github/'); + c = c.replace(/\.claude\//g, '.github/'); + // CONV-07: Command name conversion (all gsd: references → gsd-) + c = c.replace(/gsd:/g, 'gsd-'); + return c; +} + +/** + * Convert a Claude command (.md) to a Copilot skill (SKILL.md). + * Transforms frontmatter only — body passes through with CONV-06/07 applied. + * Skills keep original tool names (no mapping) per CONTEXT.md decision. + */ +function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) { + const converted = convertClaudeToCopilotContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint'); + const agent = extractFrontmatterField(frontmatter, 'agent'); + + // CONV-02: Extract allowed-tools YAML multiline list → comma-separated string + const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m); + let toolsLine = ''; + if (toolsMatch) { + const tools = toolsMatch[1].match(/^\s+-\s+(.+)/gm); + if (tools) { + toolsLine = tools.map(t => t.replace(/^\s+-\s+/, '').trim()).join(', '); + } + } + + // Reconstruct frontmatter in Copilot format + let fm = `---\nname: ${skillName}\ndescription: ${description}\n`; + if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`; + if (agent) fm += `agent: ${agent}\n`; + if (toolsLine) fm += `allowed-tools: ${toolsLine}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + +/** + * Convert a Claude agent (.md) to a Copilot agent (.agent.md). + * Applies tool mapping + deduplication, formats tools as JSON array. + * CONV-04: JSON array format. CONV-05: Tool name mapping. + */ +function convertClaudeAgentToCopilotAgent(content, isGlobal = false) { + const converted = convertClaudeToCopilotContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const color = extractFrontmatterField(frontmatter, 'color'); + const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || ''; + + // CONV-04 + CONV-05: Map tools, deduplicate, format as JSON array + const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean); + const mappedTools = claudeTools.map(t => convertCopilotToolName(t)); + const uniqueTools = [...new Set(mappedTools)]; + const toolsArray = uniqueTools.length > 0 + ? "['" + uniqueTools.join("', '") + "']" + : '[]'; + + // Reconstruct frontmatter in Copilot format + let fm = `---\nname: ${name}\ndescription: ${description}\ntools: ${toolsArray}\n`; + if (color) fm += `color: ${color}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + function toSingleLine(value) { return value.replace(/\s+/g, ' ').trim(); } @@ -705,6 +851,67 @@ function mergeCodexConfig(configPath, gsdBlock) { fs.writeFileSync(configPath, content); } +/** + * Merge GSD instructions into copilot-instructions.md. + * Three cases: new file, existing with markers, existing without markers. + * @param {string} filePath - Full path to copilot-instructions.md + * @param {string} gsdContent - Template content (without markers) + */ +function mergeCopilotInstructions(filePath, gsdContent) { + const gsdBlock = GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' + + gsdContent.trim() + '\n' + + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER; + + // Case 1: No file — create fresh + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, gsdBlock + '\n'); + return; + } + + const existing = fs.readFileSync(filePath, 'utf8'); + const openIndex = existing.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER); + const closeIndex = existing.indexOf(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER); + + // Case 2: Has GSD markers — replace between markers + if (openIndex !== -1 && closeIndex !== -1) { + const before = existing.substring(0, openIndex).trimEnd(); + const after = existing.substring(closeIndex + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart(); + let newContent = ''; + if (before) newContent += before + '\n\n'; + newContent += gsdBlock; + if (after) newContent += '\n\n' + after; + newContent += '\n'; + fs.writeFileSync(filePath, newContent); + return; + } + + // Case 3: No markers — append at end + const content = existing.trimEnd() + '\n\n' + gsdBlock + '\n'; + fs.writeFileSync(filePath, content); +} + +/** + * Strip GSD section from copilot-instructions.md content. + * Returns cleaned content, or null if file should be deleted (was GSD-only). + * @param {string} content - File content + * @returns {string|null} - Cleaned content or null if empty + */ +function stripGsdFromCopilotInstructions(content) { + const openIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER); + const closeIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER); + + if (openIndex !== -1 && closeIndex !== -1) { + const before = content.substring(0, openIndex).trimEnd(); + const after = content.substring(closeIndex + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart(); + const cleaned = (before + (before && after ? '\n\n' : '') + after).trim(); + if (!cleaned) return null; + return cleaned + '\n'; + } + + // No markers found — nothing to strip + return content; +} + /** * Generate config.toml and per-agent .toml files for Codex. * Reads agent .md files from source, extracts metadata, writes .toml configs. @@ -1123,6 +1330,55 @@ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtim recurse(srcDir, prefix); } +/** + * Copy Claude commands as Copilot skills — one folder per skill with SKILL.md. + * Applies CONV-01 (structure), CONV-02 (allowed-tools), CONV-06 (paths), CONV-07 (command names). + */ +function copyCommandsAsCopilotSkills(srcDir, skillsDir, prefix, isGlobal = false) { + if (!fs.existsSync(srcDir)) { + return; + } + + fs.mkdirSync(skillsDir, { recursive: true }); + + // Remove previous GSD Copilot skills + const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); + for (const entry of existing) { + if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { + fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); + } + } + + function recurse(currentSrcDir, currentPrefix) { + const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(currentSrcDir, entry.name); + if (entry.isDirectory()) { + recurse(srcPath, `${currentPrefix}-${entry.name}`); + continue; + } + + if (!entry.name.endsWith('.md')) { + continue; + } + + const baseName = entry.name.replace('.md', ''); + const skillName = `${currentPrefix}-${baseName}`; + const skillDir = path.join(skillsDir, skillName); + fs.mkdirSync(skillDir, { recursive: true }); + + let content = fs.readFileSync(srcPath, 'utf8'); + content = convertClaudeCommandToCopilotSkill(content, skillName, isGlobal); + content = processAttribution(content, getCommitAttribution('copilot')); + + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + } + } + + recurse(srcDir, prefix); +} + /** * Recursively copy directory, replacing paths in .md files * Deletes existing destDir first to remove orphaned files from previous versions @@ -1131,9 +1387,10 @@ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtim * @param {string} pathPrefix - Path prefix for file references * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex') */ -function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) { +function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false) { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; + const isCopilot = runtime === 'copilot'; const dirName = getDirName(runtime); // Clean install: remove existing destination to prevent orphaned files @@ -1149,16 +1406,19 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const destPath = path.join(destDir, entry.name); if (entry.isDirectory()) { - copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand); + copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal); } else if (entry.name.endsWith('.md')) { // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths + // Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix)); - content = content.replace(localClaudeRegex, `./${dirName}/`); + if (!isCopilot) { + const globalClaudeRegex = /~\/\.claude\//g; + const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; + const localClaudeRegex = /\.\/\.claude\//g; + content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, toHomePrefix(pathPrefix)); + content = content.replace(localClaudeRegex, `./${dirName}/`); + } content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for opencode compatibility @@ -1179,9 +1439,18 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand } else if (isCodex) { content = convertClaudeToCodexMarkdown(content); fs.writeFileSync(destPath, content); + } else if (isCopilot) { + content = convertClaudeToCopilotContent(content, isGlobal); + content = processAttribution(content, getCommitAttribution(runtime)); + fs.writeFileSync(destPath, content); } else { fs.writeFileSync(destPath, content); } + } else if (isCopilot && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { + // Copilot: also transform .cjs/.js files for CONV-06 and CONV-07 + let content = fs.readFileSync(srcPath, 'utf8'); + content = convertClaudeToCopilotContent(content, isGlobal); + fs.writeFileSync(destPath, content); } else { fs.copyFileSync(srcPath, destPath); } @@ -1267,11 +1536,12 @@ function cleanupOrphanedHooks(settings) { * Uninstall GSD from the specified directory for a specific runtime * Removes only GSD-specific files/directories, preserves user content * @param {boolean} isGlobal - Whether to uninstall from global or local - * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex') + * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot') */ function uninstall(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; + const isCopilot = runtime === 'copilot'; const dirName = getDirName(runtime); // Get the target directory based on runtime and install type @@ -1287,6 +1557,7 @@ function uninstall(isGlobal, runtime = 'claude') { if (runtime === 'opencode') runtimeLabel = 'OpenCode'; if (runtime === 'gemini') runtimeLabel = 'Gemini'; if (runtime === 'codex') runtimeLabel = 'Codex'; + if (runtime === 'copilot') runtimeLabel = 'Copilot'; console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -1364,8 +1635,40 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Cleaned GSD sections from config.toml`); } } + } else if (isCopilot) { + // Copilot: remove skills/gsd-*/ directories (same layout as Codex skills) + const skillsDir = path.join(targetDir, 'skills'); + if (fs.existsSync(skillsDir)) { + let skillCount = 0; + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); + skillCount++; + } + } + if (skillCount > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${skillCount} Copilot skills`); + } + } + + // Copilot: clean GSD section from copilot-instructions.md + const instructionsPath = path.join(targetDir, 'copilot-instructions.md'); + if (fs.existsSync(instructionsPath)) { + const content = fs.readFileSync(instructionsPath, 'utf8'); + const cleaned = stripGsdFromCopilotInstructions(content); + if (cleaned === null) { + fs.unlinkSync(instructionsPath); + removedCount++; + console.log(` ${green}✓${reset} Removed copilot-instructions.md (was GSD-only)`); + } else if (cleaned !== content) { + fs.writeFileSync(instructionsPath, cleaned); + removedCount++; + console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`); + } + } } else { - // Claude Code & Gemini: remove commands/gsd/ directory const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(gsdCommandsDir)) { fs.rmSync(gsdCommandsDir, { recursive: true }); @@ -1772,6 +2075,7 @@ function generateManifest(dir, baseDir) { function writeManifest(configDir, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; + const isCopilot = runtime === 'copilot'; const gsdDir = path.join(configDir, 'get-shit-done'); const commandsDir = path.join(configDir, 'commands', 'gsd'); const opencodeCommandDir = path.join(configDir, 'command'); @@ -1783,7 +2087,7 @@ function writeManifest(configDir, runtime = 'claude') { for (const [rel, hash] of Object.entries(gsdHashes)) { manifest.files['get-shit-done/' + rel] = hash; } - if (!isOpencode && !isCodex && fs.existsSync(commandsDir)) { + if (!isOpencode && !isCodex && !isCopilot && fs.existsSync(commandsDir)) { const cmdHashes = generateManifest(commandsDir); for (const [rel, hash] of Object.entries(cmdHashes)) { manifest.files['commands/gsd/' + rel] = hash; @@ -1796,7 +2100,7 @@ function writeManifest(configDir, runtime = 'claude') { } } } - if (isCodex && fs.existsSync(codexSkillsDir)) { + if ((isCodex || isCopilot) && fs.existsSync(codexSkillsDir)) { for (const skillName of listCodexSkillNames(codexSkillsDir)) { const skillRoot = path.join(codexSkillsDir, skillName); const skillHashes = generateManifest(skillRoot); @@ -1870,7 +2174,7 @@ function reportLocalPatches(configDir, runtime = 'claude') { try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; } if (meta.files && meta.files.length > 0) { - const reapplyCommand = runtime === 'opencode' + const reapplyCommand = (runtime === 'opencode' || runtime === 'copilot') ? '/gsd-reapply-patches' : runtime === 'codex' ? '$gsd-reapply-patches' @@ -1893,6 +2197,7 @@ function install(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isGemini = runtime === 'gemini'; const isCodex = runtime === 'codex'; + const isCopilot = runtime === 'copilot'; const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -1916,6 +2221,7 @@ function install(isGlobal, runtime = 'claude') { if (isOpencode) runtimeLabel = 'OpenCode'; if (isGemini) runtimeLabel = 'Gemini'; if (isCodex) runtimeLabel = 'Codex'; + if (isCopilot) runtimeLabel = 'Copilot'; console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`); @@ -1953,6 +2259,21 @@ function install(isGlobal, runtime = 'claude') { } else { failures.push('skills/gsd-*'); } + } else if (isCopilot) { + const skillsDir = path.join(targetDir, 'skills'); + const gsdSrc = path.join(src, 'commands', 'gsd'); + copyCommandsAsCopilotSkills(gsdSrc, skillsDir, 'gsd', isGlobal); + if (fs.existsSync(skillsDir)) { + const count = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } + } else { + failures.push('skills/gsd-*'); + } } else { // Claude Code & Gemini: nested structure in commands/ directory const commandsDir = path.join(targetDir, 'commands'); @@ -1960,7 +2281,7 @@ function install(isGlobal, runtime = 'claude') { const gsdSrc = path.join(src, 'commands', 'gsd'); const gsdDest = path.join(commandsDir, 'gsd'); - copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true); + copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); if (verifyInstalled(gsdDest, 'commands/gsd')) { console.log(` ${green}✓${reset} Installed commands/gsd`); } else { @@ -1971,7 +2292,7 @@ function install(isGlobal, runtime = 'claude') { // Copy get-shit-done skill with path replacement const skillSrc = path.join(src, 'get-shit-done'); const skillDest = path.join(targetDir, 'get-shit-done'); - copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime); + copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal); if (verifyInstalled(skillDest, 'get-shit-done')) { console.log(` ${green}✓${reset} Installed get-shit-done`); } else { @@ -2001,8 +2322,10 @@ function install(isGlobal, runtime = 'claude') { // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo const dirRegex = /~\/\.claude\//g; const homeDirRegex = /\$HOME\/\.claude\//g; - content = content.replace(dirRegex, pathPrefix); - content = content.replace(homeDirRegex, toHomePrefix(pathPrefix)); + if (!isCopilot) { + content = content.replace(dirRegex, pathPrefix); + content = content.replace(homeDirRegex, toHomePrefix(pathPrefix)); + } content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for runtime compatibility if (isOpencode) { @@ -2011,8 +2334,11 @@ function install(isGlobal, runtime = 'claude') { content = convertClaudeToGeminiAgent(content); } else if (isCodex) { content = convertClaudeAgentToCodexAgent(content); + } else if (isCopilot) { + content = convertClaudeAgentToCopilotAgent(content, isGlobal); } - fs.writeFileSync(path.join(agentsDest, entry.name), content); + const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; + fs.writeFileSync(path.join(agentsDest, destName), content); } } if (verifyInstalled(agentsDest, 'agents')) { @@ -2043,7 +2369,7 @@ function install(isGlobal, runtime = 'claude') { failures.push('VERSION'); } - if (!isCodex) { + if (!isCodex && !isCopilot) { // Write package.json to force CommonJS mode for GSD scripts // Prevents "require is not defined" errors when project has "type": "module" // Node.js walks up looking for package.json - this stops inheritance from project @@ -2134,6 +2460,19 @@ function install(isGlobal, runtime = 'claude') { return { settingsPath: null, settings: null, statuslineCommand: null, runtime }; } + if (isCopilot) { + // Generate copilot-instructions.md + const templatePath = path.join(targetDir, 'get-shit-done', 'templates', 'copilot-instructions.md'); + const instructionsPath = path.join(targetDir, 'copilot-instructions.md'); + if (fs.existsSync(templatePath)) { + const template = fs.readFileSync(templatePath, 'utf8'); + mergeCopilotInstructions(instructionsPath, template); + console.log(` ${green}✓${reset} Generated copilot-instructions.md`); + } + // Copilot: no settings.json, no hooks, no statusline (like Codex) + return { settingsPath: null, settings: null, statuslineCommand: null, runtime }; + } + // Configure statusline and hooks in settings.json // Gemini uses AfterTool instead of PostToolUse for post-tool hooks const postToolEvent = runtime === 'gemini' ? 'AfterTool' : 'PostToolUse'; @@ -2216,8 +2555,9 @@ function install(isGlobal, runtime = 'claude') { function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; + const isCopilot = runtime === 'copilot'; - if (shouldInstallStatusline && !isOpencode && !isCodex) { + if (shouldInstallStatusline && !isOpencode && !isCodex && !isCopilot) { settings.statusLine = { type: 'command', command: statuslineCommand @@ -2226,7 +2566,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS } // Write settings when runtime supports settings.json - if (!isCodex) { + if (!isCodex && !isCopilot) { writeSettings(settingsPath, settings); } @@ -2239,10 +2579,12 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'opencode') program = 'OpenCode'; if (runtime === 'gemini') program = 'Gemini'; if (runtime === 'codex') program = 'Codex'; + if (runtime === 'copilot') program = 'Copilot'; let command = '/gsd:new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; if (runtime === 'codex') command = '$gsd-new-project'; + if (runtime === 'copilot') command = '/gsd-new-project'; console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. @@ -2324,15 +2666,18 @@ function promptRuntime(callback) { ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset} ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset} - ${cyan}5${reset}) All + ${cyan}5${reset}) Copilot ${dim}(~/.copilot)${reset} + ${cyan}6${reset}) All `); rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { answered = true; rl.close(); const choice = answer.trim() || '1'; - if (choice === '5') { - callback(['claude', 'opencode', 'gemini', 'codex']); + if (choice === '6') { + callback(['claude', 'opencode', 'gemini', 'codex', 'copilot']); + } else if (choice === '5') { + callback(['copilot']); } else if (choice === '4') { callback(['codex']); } else if (choice === '3') { @@ -2439,6 +2784,21 @@ if (process.env.GSD_TEST_MODE) { convertClaudeCommandToCodexSkill, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, + getDirName, + getGlobalDir, + getConfigDirFromHome, + claudeToCopilotTools, + convertCopilotToolName, + convertClaudeToCopilotContent, + convertClaudeCommandToCopilotSkill, + convertClaudeAgentToCopilotAgent, + copyCommandsAsCopilotSkills, + GSD_COPILOT_INSTRUCTIONS_MARKER, + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, + mergeCopilotInstructions, + stripGsdFromCopilotInstructions, + writeManifest, + reportLocalPatches, }; } else { diff --git a/commands/gsd/autonomous.md b/commands/gsd/autonomous.md new file mode 100644 index 000000000..bea90d6bb --- /dev/null +++ b/commands/gsd/autonomous.md @@ -0,0 +1,41 @@ +--- +name: gsd:autonomous +description: Run all remaining phases autonomously — discuss→plan→execute per phase +argument-hint: "[--from N]" +allowed-tools: + - Read + - Write + - Bash + - Glob + - Grep + - AskUserQuestion + - Task +--- + +Execute all remaining milestone phases autonomously. For each phase: discuss → plan → execute. Pauses only for user decisions (grey area acceptance, blockers, validation requests). + +Uses ROADMAP.md phase discovery and Skill() flat invocations for each phase command. After all phases complete: milestone audit → complete → cleanup. + +**Creates/Updates:** +- `.planning/STATE.md` — updated after each phase +- `.planning/ROADMAP.md` — progress updated after each phase +- Phase artifacts — CONTEXT.md, PLANs, SUMMARYs per phase + +**After:** Milestone is complete and cleaned up. + + + +@~/.claude/get-shit-done/workflows/autonomous.md +@~/.claude/get-shit-done/references/ui-brand.md + + + +Optional flag: `--from N` — start from phase N instead of the first incomplete phase. + +Project context, phase list, and state are resolved inside the workflow using init commands (`gsd-tools.cjs init milestone-op`, `gsd-tools.cjs roadmap analyze`). No upfront context loading needed. + + + +Execute the autonomous workflow from @~/.claude/get-shit-done/workflows/autonomous.md end-to-end. +Preserve all workflow gates (phase discovery, per-phase execution, blocker handling, progress display). + diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 59dbb5836..b22d9c27b 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -63,8 +63,8 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) { const section = content.slice(headerIndex, sectionEnd).trim(); - // Extract goal if present - const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); + // Extract goal if present (supports both **Goal:** and **Goal**: formats) + const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i); const goal = goalMatch ? goalMatch[1].trim() : null; // Extract success criteria as structured array @@ -117,10 +117,10 @@ function cmdRoadmapAnalyze(cwd, raw) { const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length; const section = content.slice(sectionStart, sectionEnd); - const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i); + const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i); const goal = goalMatch ? goalMatch[1].trim() : null; - const dependsMatch = section.match(/\*\*Depends on:\*\*\s*([^\n]+)/i); + const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i); const depends_on = dependsMatch ? dependsMatch[1].trim() : null; // Check completion on disk diff --git a/get-shit-done/templates/copilot-instructions.md b/get-shit-done/templates/copilot-instructions.md new file mode 100644 index 000000000..c52d0cbb2 --- /dev/null +++ b/get-shit-done/templates/copilot-instructions.md @@ -0,0 +1,7 @@ +# Instructions for GSD + +- Use the get-shit-done skill when the user asks for GSD or uses a `gsd-*` command. +- Treat `/gsd-...` or `gsd-...` as command invocations and load the matching file from `.github/skills/gsd-*`. +- When a command says to spawn a subagent, prefer a matching custom agent from `.github/agents`. +- Do not apply GSD workflows unless the user explicitly asks for them. +- After completing any `gsd-*` command (or any deliverable it triggers: feature, bug fix, tests, docs, etc.), ALWAYS: (1) offer the user the next step by prompting via `ask_user`; repeat this feedback loop until the user explicitly indicates they are done. diff --git a/get-shit-done/workflows/autonomous.md b/get-shit-done/workflows/autonomous.md new file mode 100644 index 000000000..4d4a96eb5 --- /dev/null +++ b/get-shit-done/workflows/autonomous.md @@ -0,0 +1,743 @@ + + +Drive all remaining milestone phases autonomously. For each incomplete phase: discuss → plan → execute using Skill() flat invocations. Pauses only for explicit user decisions (grey area acceptance, blockers, validation requests). Re-reads ROADMAP.md after each phase to catch dynamically inserted phases. + + + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + + + + +## 1. Initialize + +Parse `$ARGUMENTS` for `--from N` flag: + +```bash +FROM_PHASE="" +if echo "$ARGUMENTS" | grep -qE '\-\-from\s+[0-9]'; then + FROM_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-from\s+[0-9]+\.?[0-9]*' | awk '{print $2}') +fi +``` + +Bootstrap via milestone-level init: + +```bash +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init milestone-op) +``` + +Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_phases`, `roadmap_exists`, `state_exists`, `commit_docs`. + +**If `roadmap_exists` is false:** Error — "No ROADMAP.md found. Run `/gsd:new-milestone` first." +**If `state_exists` is false:** Error — "No STATE.md found. Run `/gsd:new-milestone` first." + +Display startup banner: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► AUTONOMOUS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Milestone: {milestone_version} — {milestone_name} + Phases: {phase_count} total, {completed_phases} complete +``` + +If `FROM_PHASE` is set, display: `Starting from phase ${FROM_PHASE}` + + + + + +## 2. Discover Phases + +Run phase discovery: + +```bash +ROADMAP=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap analyze) +``` + +Parse the JSON `phases` array. + +**Filter to incomplete phases:** Keep only phases where `disk_status !== "complete"` OR `roadmap_complete === false`. + +**Apply `--from N` filter:** If `FROM_PHASE` was provided, additionally filter out phases where `number < FROM_PHASE` (use numeric comparison — handles decimal phases like "5.1"). + +**Sort by `number`** in numeric ascending order. + +**If no incomplete phases remain:** + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► AUTONOMOUS ▸ COMPLETE 🎉 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + All phases complete! Nothing left to do. +``` + +Exit cleanly. + +**Display phase plan:** + +``` +## Phase Plan + +| # | Phase | Status | +|---|-------|--------| +| 5 | Skill Scaffolding & Phase Discovery | In Progress | +| 6 | Smart Discuss | Not Started | +| 7 | Auto-Chain Refinements | Not Started | +| 8 | Lifecycle Orchestration | Not Started | +``` + +**Fetch details for each phase:** + +```bash +DETAIL=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase ${PHASE_NUM}) +``` + +Extract `phase_name`, `goal`, `success_criteria` from each. Store for use in execute_phase and transition messages. + + + + + +## 3. Execute Phase + +For the current phase, display the progress banner: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► AUTONOMOUS ▸ Phase {N}/{T}: {Name} [████░░░░] {P}% +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +Where N = current phase number (from the ROADMAP, e.g., 6), T = total milestone phases (from `phase_count` parsed in initialize step, e.g., 8), P = percentage of all milestone phases completed so far. Calculate P as: (number of phases with `disk_status` "complete" from the latest `roadmap analyze` / T × 100). Use █ for filled and ░ for empty segments in the progress bar (8 characters wide). + +**3a. Smart Discuss** + +Check if CONTEXT.md already exists for this phase: + +```bash +PHASE_STATE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op ${PHASE_NUM}) +``` + +Parse `has_context` from JSON. + +**If has_context is true:** Skip discuss — context already gathered. Display: + +``` +Phase ${PHASE_NUM}: Context exists — skipping discuss. +``` + +Proceed to 3b. + +**If has_context is false:** Execute the smart_discuss step for this phase. + +After smart_discuss completes, verify context was written: + +```bash +PHASE_STATE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op ${PHASE_NUM}) +``` + +Check `has_context`. If false → go to handle_blocker: "Smart discuss for phase ${PHASE_NUM} did not produce CONTEXT.md." + +**3b. Plan** + +``` +Skill(skill="gsd:plan-phase", args="${PHASE_NUM}") +``` + +Verify plan produced output — re-run `init phase-op` and check `has_plans`. If false → go to handle_blocker: "Plan phase ${PHASE_NUM} did not produce any plans." + +**3c. Execute** + +``` +Skill(skill="gsd:execute-phase", args="${PHASE_NUM} --no-transition") +``` + +**3d. Post-Execution Routing** + +After execute-phase returns, read the verification result: + +```bash +VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ') +``` + +Where `PHASE_DIR` comes from the `init phase-op` call already made in step 3a. If the variable is not in scope, re-fetch: + +```bash +PHASE_STATE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op ${PHASE_NUM}) +``` + +Parse `phase_dir` from the JSON. + +**If VERIFY_STATUS is empty** (no VERIFICATION.md or no status field): + +Go to handle_blocker: "Execute phase ${PHASE_NUM} did not produce verification results." + +**If `passed`:** + +Display: +``` +Phase ${PHASE_NUM} ✅ ${PHASE_NAME} — Verification passed +``` + +Proceed to iterate step. + +**If `human_needed`:** + +Read the human_verification section from VERIFICATION.md to get the count and items requiring manual testing. + +Display the items, then ask user via AskUserQuestion: +- **question:** "Phase ${PHASE_NUM} has items needing manual verification. Validate now or continue to next phase?" +- **options:** "Validate now" / "Continue without validation" + +On **"Validate now"**: Present the specific items from VERIFICATION.md's human_verification section. After user reviews, ask: +- **question:** "Validation result?" +- **options:** "All good — continue" / "Found issues" + +On "All good — continue": Display `Phase ${PHASE_NUM} ✅ Human validation passed` and proceed to iterate step. + +On "Found issues": Go to handle_blocker with the user's reported issues as the description. + +On **"Continue without validation"**: Display `Phase ${PHASE_NUM} ⏭ Human validation deferred` and proceed to iterate step. + +**If `gaps_found`:** + +Read gap summary from VERIFICATION.md (score and missing items). Display: +``` +⚠ Phase ${PHASE_NUM}: ${PHASE_NAME} — Gaps Found +Score: {N}/{M} must-haves verified +``` + +Ask user via AskUserQuestion: +- **question:** "Gaps found in phase ${PHASE_NUM}. How to proceed?" +- **options:** "Run gap closure" / "Continue without fixing" / "Stop autonomous mode" + +On **"Run gap closure"**: Execute gap closure cycle (limit: 1 attempt): + +``` +Skill(skill="gsd:plan-phase", args="${PHASE_NUM} --gaps") +``` + +Verify gap plans were created — re-run `init phase-op ${PHASE_NUM}` and check `has_plans`. If no new gap plans → go to handle_blocker: "Gap closure planning for phase ${PHASE_NUM} did not produce plans." + +Re-execute: +``` +Skill(skill="gsd:execute-phase", args="${PHASE_NUM} --no-transition") +``` + +Re-read verification status: +```bash +VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ') +``` + +If `passed` or `human_needed`: Route normally (continue or ask user as above). + +If still `gaps_found` after this retry: Display "Gaps persist after closure attempt." and ask via AskUserQuestion: +- **question:** "Gap closure did not fully resolve issues. How to proceed?" +- **options:** "Continue anyway" / "Stop autonomous mode" + +On "Continue anyway": Proceed to iterate step. +On "Stop autonomous mode": Go to handle_blocker. + +This limits gap closure to 1 automatic retry to prevent infinite loops. + +On **"Continue without fixing"**: Display `Phase ${PHASE_NUM} ⏭ Gaps deferred` and proceed to iterate step. + +On **"Stop autonomous mode"**: Go to handle_blocker with "User stopped — gaps remain in phase ${PHASE_NUM}". + + + + + +## Smart Discuss + +Run smart discuss for the current phase. Proposes grey area answers in batch tables — the user accepts or overrides per area. Produces identical CONTEXT.md output to regular discuss-phase. + +> **Note:** Smart discuss is an autonomous-optimized variant of the `gsd:discuss-phase` skill. It produces identical CONTEXT.md output but uses batch table proposals instead of sequential questioning. The original `discuss-phase` skill remains unchanged (per CTRL-03). Future milestones may extract this to a separate skill file. + +**Inputs:** `PHASE_NUM` from execute_phase. Run init to get phase paths: + +```bash +PHASE_STATE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op ${PHASE_NUM}) +``` + +Parse from JSON: `phase_dir`, `phase_slug`, `padded_phase`, `phase_name`. + +--- + +### Sub-step 1: Load prior context + +Read project-level and prior phase context to avoid re-asking decided questions. + +**Read project files:** + +```bash +cat .planning/PROJECT.md 2>/dev/null +cat .planning/REQUIREMENTS.md 2>/dev/null +cat .planning/STATE.md 2>/dev/null +``` + +Extract from these: +- **PROJECT.md** — Vision, principles, non-negotiables, user preferences +- **REQUIREMENTS.md** — Acceptance criteria, constraints, must-haves vs nice-to-haves +- **STATE.md** — Current progress, decisions logged so far + +**Read all prior CONTEXT.md files:** + +```bash +find .planning/phases -name "*-CONTEXT.md" 2>/dev/null | sort +``` + +For each CONTEXT.md where phase number < current phase: +- Read the `` section — these are locked preferences +- Read `` — particular references or "I want it like X" moments +- Note patterns (e.g., "user consistently prefers minimal UI", "user rejected verbose output") + +**Build internal prior_decisions context** (do not write to file): + +``` + +## Project-Level +- [Key principle or constraint from PROJECT.md] +- [Requirement affecting this phase from REQUIREMENTS.md] + +## From Prior Phases +### Phase N: [Name] +- [Decision relevant to current phase] +- [Preference that establishes a pattern] + +``` + +If no prior context exists, continue without — expected for early phases. + +--- + +### Sub-step 2: Scout Codebase + +Lightweight codebase scan to inform grey area identification and proposals. Keep under ~5% context. + +**Check for existing codebase maps:** + +```bash +ls .planning/codebase/*.md 2>/dev/null +``` + +**If codebase maps exist:** Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type). Extract reusable components, established patterns, integration points. Skip to building context below. + +**If no codebase maps, do targeted grep:** + +Extract key terms from the phase goal. Search for related files: + +```bash +grep -rl "{term1}\|{term2}" src/ app/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -10 +ls src/components/ src/hooks/ src/lib/ src/utils/ 2>/dev/null +``` + +Read the 3-5 most relevant files to understand existing patterns. + +**Build internal codebase_context** (do not write to file): +- **Reusable assets** — existing components, hooks, utilities usable in this phase +- **Established patterns** — how the codebase does state management, styling, data fetching +- **Integration points** — where new code connects (routes, nav, providers) + +--- + +### Sub-step 3: Analyze Phase and Generate Proposals + +**Get phase details:** + +```bash +DETAIL=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase ${PHASE_NUM}) +``` + +Extract `goal`, `requirements`, `success_criteria` from the JSON response. + +**Infrastructure detection — check FIRST before generating grey areas:** + +A phase is pure infrastructure when ALL of these are true: +1. Goal keywords match: "scaffolding", "plumbing", "setup", "configuration", "migration", "refactor", "rename", "restructure", "upgrade", "infrastructure" +2. AND success criteria are all technical: "file exists", "test passes", "config valid", "command runs" +3. AND no user-facing behavior is described (no "users can", "displays", "shows", "presents") + +**If infrastructure-only:** Skip Sub-step 4. Jump directly to Sub-step 5 with minimal CONTEXT.md. Display: + +``` +Phase ${PHASE_NUM}: Infrastructure phase — skipping discuss, writing minimal context. +``` + +Use these defaults for the CONTEXT.md: +- ``: Phase boundary from ROADMAP goal +- ``: Single "### Claude's Discretion" subsection — "All implementation choices are at Claude's discretion — pure infrastructure phase" +- ``: Whatever the codebase scout found +- ``: "No specific requirements — infrastructure phase" +- ``: "None" + +**If NOT infrastructure — generate grey area proposals:** + +Determine domain type from the phase goal: +- Something users **SEE** → visual: layout, interactions, states, density +- Something users **CALL** → interface: contracts, responses, errors, auth +- Something users **RUN** → execution: invocation, output, behavior modes, flags +- Something users **READ** → content: structure, tone, depth, flow +- Something being **ORGANIZED** → organization: criteria, grouping, exceptions, naming + +Check prior_decisions — skip grey areas already decided in prior phases. + +Generate **3-4 grey areas** with **~4 questions each**. For each question: +- **Pre-select a recommended answer** based on: prior decisions (consistency), codebase patterns (reuse), domain conventions (standard approaches), ROADMAP success criteria +- Generate **1-2 alternatives** per question +- **Annotate** with prior decision context ("You decided X in Phase N") and code context ("Component Y exists with Z variants") where relevant + +--- + +### Sub-step 4: Present Proposals Per Area + +Present grey areas **one at a time**. For each area (M of N): + +Display a table: + +``` +### Grey Area {M}/{N}: {Area Name} + +| # | Question | ✅ Recommended | Alternative(s) | +|---|----------|---------------|-----------------| +| 1 | {question} | {answer} — {rationale} | {alt1}; {alt2} | +| 2 | {question} | {answer} — {rationale} | {alt1} | +| 3 | {question} | {answer} — {rationale} | {alt1}; {alt2} | +| 4 | {question} | {answer} — {rationale} | {alt1} | +``` + +Then prompt the user via **AskUserQuestion**: +- **header:** "Area {M}/{N}" +- **question:** "Accept these answers for {Area Name}?" +- **options:** Build dynamically — always "Accept all" first, then "Change Q1" through "Change QN" for each question (up to 4), then "Discuss deeper" last. Cap at 6 explicit options max (AskUserQuestion adds "Other" automatically). + +**On "Accept all":** Record all recommended answers for this area. Move to next area. + +**On "Change QN":** Use AskUserQuestion with the alternatives for that specific question: +- **header:** "{Area Name}" +- **question:** "Q{N}: {question text}" +- **options:** List the 1-2 alternatives plus "You decide" (maps to Claude's Discretion) + +Record the user's choice. Re-display the updated table with the change reflected. Re-present the full acceptance prompt so the user can make additional changes or accept. + +**On "Discuss deeper":** Switch to interactive mode for this area only — ask questions one at a time using AskUserQuestion with 2-3 concrete options per question plus "You decide". After 4 questions, prompt: +- **header:** "{Area Name}" +- **question:** "More questions about {area name}, or move to next?" +- **options:** "More questions" / "Next area" + +If "More questions", ask 4 more. If "Next area", display final summary table of captured answers for this area and move on. + +**On "Other" (free text):** Interpret as either a specific change request or general feedback. Incorporate into the area's decisions, re-display updated table, re-present acceptance prompt. + +**Scope creep handling:** If user mentions something outside the phase domain: + +``` +"{Feature} sounds like a new capability — that belongs in its own phase. +I'll note it as a deferred idea. + +Back to {current area}: {return to current question}" +``` + +Track deferred ideas internally for inclusion in CONTEXT.md. + +--- + +### Sub-step 5: Write CONTEXT.md + +After all areas are resolved (or infrastructure skip), write the CONTEXT.md file. + +**File path:** `${phase_dir}/${padded_phase}-CONTEXT.md` + +Use **exactly** this structure (identical to discuss-phase output): + +```markdown +# Phase {PHASE_NUM}: {Phase Name} - Context + +**Gathered:** {date} +**Status:** Ready for planning + + +## Phase Boundary + +{Domain boundary statement from analysis — what this phase delivers} + + + + +## Implementation Decisions + +### {Area 1 Name} +- {Accepted/chosen answer for Q1} +- {Accepted/chosen answer for Q2} +- {Accepted/chosen answer for Q3} +- {Accepted/chosen answer for Q4} + +### {Area 2 Name} +- {Accepted/chosen answer for Q1} +- {Accepted/chosen answer for Q2} +... + +### Claude's Discretion +{Any "You decide" answers collected — note Claude has flexibility here} + + + + +## Existing Code Insights + +### Reusable Assets +- {From codebase scout — components, hooks, utilities} + +### Established Patterns +- {From codebase scout — state management, styling, data fetching} + +### Integration Points +- {From codebase scout — where new code connects} + + + + +## Specific Ideas + +{Any specific references or "I want it like X" from discussion} +{If none: "No specific requirements — open to standard approaches"} + + + + +## Deferred Ideas + +{Ideas captured but out of scope for this phase} +{If none: "None — discussion stayed within phase scope"} + + +``` + +Write the file. + +**Commit:** + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${PADDED_PHASE}): smart discuss context" --files "${phase_dir}/${padded_phase}-CONTEXT.md" +``` + +Display confirmation: + +``` +Created: {path} +Decisions captured: {count} across {area_count} areas +``` + + + + + +## 4. Iterate + +After each phase completes, re-read ROADMAP.md to catch phases inserted mid-execution (decimal phases like 5.1): + +```bash +ROADMAP=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap analyze) +``` + +Re-filter incomplete phases using the same logic as discover_phases: +- Keep phases where `disk_status !== "complete"` OR `roadmap_complete === false` +- Apply `--from N` filter if originally provided +- Sort by number ascending + +Read STATE.md fresh: + +```bash +cat .planning/STATE.md +``` + +Check for blockers in the Blockers/Concerns section. If blockers are found, go to handle_blocker with the blocker description. + +If incomplete phases remain: proceed to next phase, loop back to execute_phase. + +If all phases complete, proceed to lifecycle step. + + + + + +## 5. Lifecycle + +After all phases complete, run the milestone lifecycle sequence: audit → complete → cleanup. + +Display lifecycle transition banner: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► AUTONOMOUS ▸ LIFECYCLE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + All phases complete → Starting lifecycle: audit → complete → cleanup + Milestone: {milestone_version} — {milestone_name} +``` + +**5a. Audit** + +``` +Skill(skill="gsd:audit-milestone") +``` + +After audit completes, detect the result: + +```bash +AUDIT_FILE=".planning/v${milestone_version}-MILESTONE-AUDIT.md" +AUDIT_STATUS=$(grep "^status:" "${AUDIT_FILE}" 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ') +``` + +**If AUDIT_STATUS is empty** (no audit file or no status field): + +Go to handle_blocker: "Audit did not produce results — audit file missing or malformed." + +**If `passed`:** + +Display: +``` +Audit ✅ passed — proceeding to complete milestone +``` + +Proceed to 5b (no user pause — per CTRL-01). + +**If `gaps_found`:** + +Read the gaps summary from the audit file. Display: +``` +⚠ Audit: Gaps Found +``` + +Ask user via AskUserQuestion: +- **question:** "Milestone audit found gaps. How to proceed?" +- **options:** "Continue anyway — accept gaps" / "Stop — fix gaps manually" + +On **"Continue anyway"**: Display `Audit ⏭ Gaps accepted — proceeding to complete milestone` and proceed to 5b. + +On **"Stop"**: Go to handle_blocker with "User stopped — audit gaps remain. Run /gsd:audit-milestone to review, then /gsd:complete-milestone when ready." + +**If `tech_debt`:** + +Read the tech debt summary from the audit file. Display: +``` +⚠ Audit: Tech Debt Identified +``` + +Show the summary, then ask user via AskUserQuestion: +- **question:** "Milestone audit found tech debt. How to proceed?" +- **options:** "Continue with tech debt" / "Stop — address debt first" + +On **"Continue with tech debt"**: Display `Audit ⏭ Tech debt acknowledged — proceeding to complete milestone` and proceed to 5b. + +On **"Stop"**: Go to handle_blocker with "User stopped — tech debt to address. Run /gsd:audit-milestone to review details." + +**5b. Complete Milestone** + +``` +Skill(skill="gsd:complete-milestone", args="${milestone_version}") +``` + +After complete-milestone returns, verify it produced output: + +```bash +ls .planning/milestones/v${milestone_version}-ROADMAP.md 2>/dev/null +``` + +If the archive file does not exist, go to handle_blocker: "Complete milestone did not produce expected archive files." + +**5c. Cleanup** + +``` +Skill(skill="gsd:cleanup") +``` + +Cleanup shows its own dry-run and asks user for approval internally — this is an acceptable pause per CTRL-01 since it's an explicit decision about file deletion. + +**5d. Final Completion** + +Display final completion banner: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► AUTONOMOUS ▸ COMPLETE 🎉 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Milestone: {milestone_version} — {milestone_name} + Status: Complete ✅ + Lifecycle: audit ✅ → complete ✅ → cleanup ✅ + + Ship it! 🚀 +``` + + + + + +## 6. Handle Blocker + +When any phase operation fails or a blocker is detected, present 3 options via AskUserQuestion: + +**Prompt:** "Phase {N} ({Name}) encountered an issue: {description}" + +**Options:** +1. **"Fix and retry"** — Re-run the failed step (discuss, plan, or execute) for this phase +2. **"Skip this phase"** — Mark phase as skipped, continue to the next incomplete phase +3. **"Stop autonomous mode"** — Display summary of progress so far and exit cleanly + +**On "Fix and retry":** Loop back to the failed step within execute_phase. If the same step fails again after retry, re-present these options. + +**On "Skip this phase":** Log `Phase {N} ⏭ {Name} — Skipped by user` and proceed to iterate. + +**On "Stop autonomous mode":** Display progress summary: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + GSD ► AUTONOMOUS ▸ STOPPED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Completed: {list of completed phases} + Skipped: {list of skipped phases} + Remaining: {list of remaining phases} + + Resume with: /gsd:autonomous --from {next_phase} +``` + + + + + + +- [ ] All incomplete phases executed in order (smart discuss → plan → execute each) +- [ ] Smart discuss proposes grey area answers in tables, user accepts or overrides per area +- [ ] Progress banners displayed between phases +- [ ] Execute-phase invoked with --no-transition (autonomous manages transitions) +- [ ] Post-execution verification reads VERIFICATION.md and routes on status +- [ ] Passed verification → automatic continue to next phase +- [ ] Human-needed verification → user prompted to validate or skip +- [ ] Gaps-found → user offered gap closure, continue, or stop +- [ ] Gap closure limited to 1 retry (prevents infinite loops) +- [ ] Plan-phase and execute-phase failures route to handle_blocker +- [ ] ROADMAP.md re-read after each phase (catches inserted phases) +- [ ] STATE.md checked for blockers before each phase +- [ ] Blockers handled via user choice (retry / skip / stop) +- [ ] Final completion or stop summary displayed +- [ ] After all phases complete, lifecycle step is invoked (not manual suggestion) +- [ ] Lifecycle transition banner displayed before audit +- [ ] Audit invoked via Skill(skill="gsd:audit-milestone") +- [ ] Audit result routing: passed → auto-continue, gaps_found → user decides, tech_debt → user decides +- [ ] Audit technical failure (no file/no status) routes to handle_blocker +- [ ] Complete-milestone invoked via Skill() with ${milestone_version} arg +- [ ] Cleanup invoked via Skill() — internal confirmation is acceptable (CTRL-01) +- [ ] Final completion banner displayed after lifecycle +- [ ] Progress bar uses phase number / total milestone phases (not position among incomplete) +- [ ] Smart discuss documents relationship to discuss-phase with CTRL-03 note + diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs new file mode 100644 index 000000000..15feea77d --- /dev/null +++ b/tests/copilot-install.test.cjs @@ -0,0 +1,1362 @@ +/** + * GSD Tools Tests - Copilot Install Plumbing + * + * Tests for Copilot runtime directory resolution, config paths, + * and integration with the multi-runtime installer. + * + * Requirements: CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06 + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const { + getDirName, + getGlobalDir, + getConfigDirFromHome, + claudeToCopilotTools, + convertCopilotToolName, + convertClaudeToCopilotContent, + convertClaudeCommandToCopilotSkill, + convertClaudeAgentToCopilotAgent, + copyCommandsAsCopilotSkills, + GSD_COPILOT_INSTRUCTIONS_MARKER, + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, + mergeCopilotInstructions, + stripGsdFromCopilotInstructions, + writeManifest, + reportLocalPatches, +} = require('../bin/install.js'); + +// ─── getDirName ───────────────────────────────────────────────────────────────── + +describe('getDirName (Copilot)', () => { + test('returns .github for copilot', () => { + assert.strictEqual(getDirName('copilot'), '.github'); + }); + + test('does not break existing runtimes', () => { + assert.strictEqual(getDirName('claude'), '.claude'); + assert.strictEqual(getDirName('opencode'), '.opencode'); + assert.strictEqual(getDirName('gemini'), '.gemini'); + assert.strictEqual(getDirName('codex'), '.codex'); + }); +}); + +// ─── getGlobalDir ─────────────────────────────────────────────────────────────── + +describe('getGlobalDir (Copilot)', () => { + test('returns ~/.copilot with no env var or explicit dir', () => { + const original = process.env.COPILOT_CONFIG_DIR; + try { + delete process.env.COPILOT_CONFIG_DIR; + const result = getGlobalDir('copilot'); + assert.strictEqual(result, path.join(os.homedir(), '.copilot')); + } finally { + if (original !== undefined) { + process.env.COPILOT_CONFIG_DIR = original; + } else { + delete process.env.COPILOT_CONFIG_DIR; + } + } + }); + + test('returns explicit dir when provided', () => { + const result = getGlobalDir('copilot', '/custom/path'); + assert.strictEqual(result, '/custom/path'); + }); + + test('respects COPILOT_CONFIG_DIR env var', () => { + const original = process.env.COPILOT_CONFIG_DIR; + try { + process.env.COPILOT_CONFIG_DIR = '~/custom-copilot'; + const result = getGlobalDir('copilot'); + assert.strictEqual(result, path.join(os.homedir(), 'custom-copilot')); + } finally { + if (original !== undefined) { + process.env.COPILOT_CONFIG_DIR = original; + } else { + delete process.env.COPILOT_CONFIG_DIR; + } + } + }); + + test('explicit dir takes priority over COPILOT_CONFIG_DIR', () => { + const original = process.env.COPILOT_CONFIG_DIR; + try { + process.env.COPILOT_CONFIG_DIR = '~/env-path'; + const result = getGlobalDir('copilot', '/explicit/path'); + assert.strictEqual(result, '/explicit/path'); + } finally { + if (original !== undefined) { + process.env.COPILOT_CONFIG_DIR = original; + } else { + delete process.env.COPILOT_CONFIG_DIR; + } + } + }); + + test('does not break existing runtimes', () => { + assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude')); + assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex')); + }); +}); + +// ─── getConfigDirFromHome ─────────────────────────────────────────────────────── + +describe('getConfigDirFromHome (Copilot)', () => { + test('returns .github path string for local (isGlobal=false)', () => { + assert.strictEqual(getConfigDirFromHome('copilot', false), "'.github'"); + }); + + test('returns .copilot path string for global (isGlobal=true)', () => { + assert.strictEqual(getConfigDirFromHome('copilot', true), "'.copilot'"); + }); + + test('does not break existing runtimes', () => { + assert.strictEqual(getConfigDirFromHome('opencode', true), "'.config', 'opencode'"); + assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'"); + assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'"); + assert.strictEqual(getConfigDirFromHome('codex', true), "'.codex'"); + }); +}); + +// ─── Source code integration checks ───────────────────────────────────────────── + +describe('Source code integration (Copilot)', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8'); + + test('CLI-01: --copilot flag parsing exists', () => { + assert.ok(src.includes("args.includes('--copilot')"), '--copilot flag parsed'); + }); + + test('CLI-03: --all array includes copilot', () => { + assert.ok( + src.includes("'claude', 'opencode', 'gemini', 'codex', 'copilot'"), + '--all includes copilot as 5th runtime' + ); + }); + + test('CLI-06: banner text includes Copilot', () => { + assert.ok(src.includes('Copilot by'), 'banner mentions Copilot'); + }); + + test('CLI-06: help text includes --copilot', () => { + assert.ok(src.includes('--copilot'), 'help text has --copilot option'); + }); + + test('CLI-02: promptRuntime has Copilot as option 5', () => { + assert.ok(src.includes("choice === '5'"), 'choice 5 exists'); + // Verify choice 5 maps to copilot (the line after choice === '5' should reference copilot) + const choice5Index = src.indexOf("choice === '5'"); + const nextLines = src.substring(choice5Index, choice5Index + 100); + assert.ok(nextLines.includes('copilot'), 'choice 5 maps to copilot'); + }); + + test('CLI-02: promptRuntime has All as option 6', () => { + assert.ok(src.includes("choice === '6'"), 'choice 6 exists'); + const choice6Index = src.indexOf("choice === '6'"); + const nextLines = src.substring(choice6Index, choice6Index + 150); + assert.ok(nextLines.includes('copilot'), 'choice 6 (All) includes copilot'); + }); + + test('isCopilot variable exists in install function', () => { + assert.ok(src.includes("const isCopilot = runtime === 'copilot'"), 'isCopilot defined'); + }); + + test('hooks are skipped for Copilot', () => { + assert.ok(src.includes('!isCodex && !isCopilot'), 'hooks skip check includes copilot'); + }); + + test('--both flag unchanged (still claude + opencode only)', () => { + // Verify the else-if-hasBoth maps to ['claude', 'opencode'] — NOT including copilot + const bothUsage = src.indexOf('} else if (hasBoth)'); + assert.ok(bothUsage > 0, 'hasBoth usage exists'); + const bothSection = src.substring(bothUsage, bothUsage + 200); + assert.ok(bothSection.includes("['claude', 'opencode']"), '--both maps to claude+opencode'); + assert.ok(!bothSection.includes('copilot'), '--both does NOT include copilot'); + }); +}); + +// ─── convertCopilotToolName ───────────────────────────────────────────────────── + +describe('convertCopilotToolName', () => { + test('maps Read to read', () => { + assert.strictEqual(convertCopilotToolName('Read'), 'read'); + }); + + test('maps Write to edit', () => { + assert.strictEqual(convertCopilotToolName('Write'), 'edit'); + }); + + test('maps Edit to edit (same as Write)', () => { + assert.strictEqual(convertCopilotToolName('Edit'), 'edit'); + }); + + test('maps Bash to execute', () => { + assert.strictEqual(convertCopilotToolName('Bash'), 'execute'); + }); + + test('maps Grep to search', () => { + assert.strictEqual(convertCopilotToolName('Grep'), 'search'); + }); + + test('maps Glob to search (same as Grep)', () => { + assert.strictEqual(convertCopilotToolName('Glob'), 'search'); + }); + + test('maps Task to agent', () => { + assert.strictEqual(convertCopilotToolName('Task'), 'agent'); + }); + + test('maps WebSearch to web', () => { + assert.strictEqual(convertCopilotToolName('WebSearch'), 'web'); + }); + + test('maps WebFetch to web (same as WebSearch)', () => { + assert.strictEqual(convertCopilotToolName('WebFetch'), 'web'); + }); + + test('maps TodoWrite to todo', () => { + assert.strictEqual(convertCopilotToolName('TodoWrite'), 'todo'); + }); + + test('maps AskUserQuestion to ask_user', () => { + assert.strictEqual(convertCopilotToolName('AskUserQuestion'), 'ask_user'); + }); + + test('maps SlashCommand to skill', () => { + assert.strictEqual(convertCopilotToolName('SlashCommand'), 'skill'); + }); + + test('maps mcp__context7__ prefix to io.github.upstash/context7/', () => { + assert.strictEqual( + convertCopilotToolName('mcp__context7__resolve-library-id'), + 'io.github.upstash/context7/resolve-library-id' + ); + }); + + test('maps mcp__context7__* wildcard', () => { + assert.strictEqual( + convertCopilotToolName('mcp__context7__*'), + 'io.github.upstash/context7/*' + ); + }); + + test('lowercases unknown tools as fallback', () => { + assert.strictEqual(convertCopilotToolName('SomeNewTool'), 'somenewtool'); + }); + + test('mapping constant has 13 entries (12 direct + mcp handled separately)', () => { + assert.strictEqual(Object.keys(claudeToCopilotTools).length, 12); + }); +}); + +// ─── convertClaudeToCopilotContent ────────────────────────────────────────────── + +describe('convertClaudeToCopilotContent', () => { + test('replaces ~/.claude/ with .github/ in local mode (default)', () => { + assert.strictEqual( + convertClaudeToCopilotContent('see ~/.claude/foo'), + 'see .github/foo' + ); + }); + + test('replaces ~/.claude/ with ~/.copilot/ in global mode', () => { + assert.strictEqual( + convertClaudeToCopilotContent('see ~/.claude/foo', true), + 'see ~/.copilot/foo' + ); + }); + + test('replaces ./.claude/ with ./.github/', () => { + assert.strictEqual( + convertClaudeToCopilotContent('at ./.claude/bar'), + 'at ./.github/bar' + ); + }); + + test('replaces bare .claude/ with .github/', () => { + assert.strictEqual( + convertClaudeToCopilotContent('in .claude/baz'), + 'in .github/baz' + ); + }); + + test('replaces $HOME/.claude/ with .github/ in local mode (default)', () => { + assert.strictEqual( + convertClaudeToCopilotContent('"$HOME/.claude/config"'), + '".github/config"' + ); + }); + + test('replaces $HOME/.claude/ with $HOME/.copilot/ in global mode', () => { + assert.strictEqual( + convertClaudeToCopilotContent('"$HOME/.claude/config"', true), + '"$HOME/.copilot/config"' + ); + }); + + test('converts gsd: to gsd- in command names', () => { + assert.strictEqual( + convertClaudeToCopilotContent('run /gsd:health or gsd:progress'), + 'run /gsd-health or gsd-progress' + ); + }); + + test('handles mixed content in local mode', () => { + const input = 'Config at ~/.claude/settings and $HOME/.claude/config.\n' + + 'Local at ./.claude/data and .claude/commands.\n' + + 'Run gsd:health and /gsd:progress.'; + const result = convertClaudeToCopilotContent(input); + assert.ok(result.includes('.github/settings'), 'tilde path converted to local'); + assert.ok(!result.includes('$HOME/.claude/'), '$HOME path converted'); + assert.ok(result.includes('./.github/data'), 'dot-slash path converted'); + assert.ok(result.includes('.github/commands'), 'bare path converted'); + assert.ok(result.includes('gsd-health'), 'command name converted'); + assert.ok(result.includes('/gsd-progress'), 'slash command converted'); + }); + + test('handles mixed content in global mode', () => { + const input = 'Config at ~/.claude/settings and $HOME/.claude/config.\n' + + 'Local at ./.claude/data and .claude/commands.\n' + + 'Run gsd:health and /gsd:progress.'; + const result = convertClaudeToCopilotContent(input, true); + assert.ok(result.includes('~/.copilot/settings'), 'tilde path converted to global'); + assert.ok(result.includes('$HOME/.copilot/config'), '$HOME path converted to global'); + assert.ok(result.includes('./.github/data'), 'dot-slash path converted'); + assert.ok(result.includes('.github/commands'), 'bare path converted'); + }); + + test('does not double-replace in local mode', () => { + const input = '~/.claude/foo and ./.claude/bar and .claude/baz'; + const result = convertClaudeToCopilotContent(input); + assert.ok(!result.includes('.github/.github/'), 'no .github/.github/ artifact'); + assert.strictEqual(result, '.github/foo and ./.github/bar and .github/baz'); + }); + + test('does not double-replace in global mode', () => { + const input = '~/.claude/foo and ./.claude/bar and .claude/baz'; + const result = convertClaudeToCopilotContent(input, true); + assert.ok(!result.includes('.copilot/.github/'), 'no .copilot/.github/ artifact'); + assert.strictEqual(result, '~/.copilot/foo and ./.github/bar and .github/baz'); + }); + + test('preserves content with no matches', () => { + assert.strictEqual( + convertClaudeToCopilotContent('hello world'), + 'hello world' + ); + }); +}); + +// ─── convertClaudeCommandToCopilotSkill ───────────────────────────────────────── + +describe('convertClaudeCommandToCopilotSkill', () => { + test('converts frontmatter with all fields', () => { + const input = `--- +name: gsd:health +description: Diagnose planning directory health +argument-hint: [--repair] +allowed-tools: + - Read + - Bash + - Write + - AskUserQuestion +--- + +Body content here referencing ~/.claude/foo and gsd:health.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-health'); + assert.ok(result.startsWith('---\nname: gsd-health\n'), 'name uses param'); + assert.ok(result.includes('description: Diagnose planning directory health'), 'description preserved'); + assert.ok(result.includes('argument-hint: "[--repair]"'), 'argument-hint double-quoted'); + assert.ok(result.includes('allowed-tools: Read, Bash, Write, AskUserQuestion'), 'tools comma-separated'); + assert.ok(result.includes('.github/foo'), 'CONV-06 applied to body (local mode default)'); + assert.ok(result.includes('gsd-health'), 'CONV-07 applied to body'); + assert.ok(!result.includes('gsd:health'), 'no gsd: references remain'); + }); + + test('handles skill without allowed-tools', () => { + const input = `--- +name: gsd:help +description: Show available GSD commands +--- + +Help content.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-help'); + assert.ok(result.includes('name: gsd-help'), 'name set'); + assert.ok(result.includes('description: Show available GSD commands'), 'description preserved'); + assert.ok(!result.includes('allowed-tools:'), 'no allowed-tools line'); + }); + + test('handles skill without argument-hint', () => { + const input = `--- +name: gsd:progress +description: Show project progress +allowed-tools: + - Read + - Bash +--- + +Progress body.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-progress'); + assert.ok(!result.includes('argument-hint:'), 'no argument-hint line'); + assert.ok(result.includes('allowed-tools: Read, Bash'), 'tools present'); + }); + + test('argument-hint with inner single quotes uses double-quote YAML delimiter', () => { + const input = `--- +name: gsd:new-milestone +description: Start milestone +argument-hint: "[milestone name, e.g., 'v1.1 Notifications']" +allowed-tools: + - Read +--- + +Body.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-new-milestone'); + assert.ok(result.includes(`argument-hint: "[milestone name, e.g., 'v1.1 Notifications']"`), 'inner single quotes preserved with double-quote delimiter'); + }); + + test('applies CONV-06 path conversion to body (local mode)', () => { + const input = `--- +name: gsd:test +description: Test skill +--- + +Check ~/.claude/settings and ./.claude/local and $HOME/.claude/global.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test'); + assert.ok(result.includes('.github/settings'), 'tilde path converted to local'); + assert.ok(result.includes('./.github/local'), 'dot-slash path converted'); + assert.ok(result.includes('.github/global'), '$HOME path converted to local'); + }); + + test('applies CONV-06 path conversion to body (global mode)', () => { + const input = `--- +name: gsd:test +description: Test skill +--- + +Check ~/.claude/settings and ./.claude/local and $HOME/.claude/global.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test', true); + assert.ok(result.includes('~/.copilot/settings'), 'tilde path converted to global'); + assert.ok(result.includes('./.github/local'), 'dot-slash path converted'); + assert.ok(result.includes('$HOME/.copilot/global'), '$HOME path converted to global'); + }); + + test('applies CONV-07 command name conversion to body', () => { + const input = `--- +name: gsd:test +description: Test skill +--- + +Run gsd:health and /gsd:progress for diagnostics.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test'); + assert.ok(result.includes('gsd-health'), 'gsd:health converted'); + assert.ok(result.includes('/gsd-progress'), '/gsd:progress converted'); + assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: command refs remain'); + }); + + test('handles content without frontmatter (local mode)', () => { + const input = 'Just some markdown with ~/.claude/path and gsd:health.'; + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test'); + assert.ok(result.includes('.github/path'), 'CONV-06 applied (local)'); + assert.ok(result.includes('gsd-health'), 'CONV-07 applied'); + assert.ok(!result.includes('---'), 'no frontmatter added'); + }); + + test('preserves agent field in frontmatter', () => { + const input = `--- +name: gsd:execute-phase +description: Execute a phase +agent: gsd-planner +allowed-tools: + - Read + - Bash +--- + +Body.`; + + const result = convertClaudeCommandToCopilotSkill(input, 'gsd-execute-phase'); + assert.ok(result.includes('agent: gsd-planner'), 'agent field preserved'); + }); +}); + +// ─── convertClaudeAgentToCopilotAgent ─────────────────────────────────────────── + +describe('convertClaudeAgentToCopilotAgent', () => { + test('maps and deduplicates tools', () => { + const input = `--- +name: gsd-executor +description: Executes GSD plans +tools: Read, Write, Edit, Bash, Grep, Glob +color: yellow +--- + +Agent body.`; + + const result = convertClaudeAgentToCopilotAgent(input); + assert.ok(result.includes("tools: ['read', 'edit', 'execute', 'search']"), 'tools mapped and deduped'); + }); + + test('formats tools as JSON array', () => { + const input = `--- +name: gsd-test +description: Test agent +tools: Read, Bash +--- + +Body.`; + + const result = convertClaudeAgentToCopilotAgent(input); + assert.ok(result.match(/tools: \['[a-z_]+'(, '[a-z_]+')*\]/), 'tools formatted as JSON array'); + }); + + test('preserves name description and color', () => { + const input = `--- +name: gsd-executor +description: Executes GSD plans with atomic commits +tools: Read, Bash +color: yellow +--- + +Body.`; + + const result = convertClaudeAgentToCopilotAgent(input); + assert.ok(result.includes('name: gsd-executor'), 'name preserved'); + assert.ok(result.includes('description: Executes GSD plans with atomic commits'), 'description preserved'); + assert.ok(result.includes('color: yellow'), 'color preserved'); + }); + + test('handles mcp__context7__ tools', () => { + const input = `--- +name: gsd-researcher +description: Research agent +tools: Read, Bash, mcp__context7__resolve-library-id +color: cyan +--- + +Body.`; + + const result = convertClaudeAgentToCopilotAgent(input); + assert.ok(result.includes('io.github.upstash/context7/resolve-library-id'), 'mcp tool mapped'); + assert.ok(!result.includes('mcp__context7__'), 'no mcp__ prefix remains'); + }); + + test('handles agent with no tools field', () => { + const input = `--- +name: gsd-empty +description: Empty agent +color: green +--- + +Body.`; + + const result = convertClaudeAgentToCopilotAgent(input); + assert.ok(result.includes('tools: []'), 'missing tools produces []'); + }); + + test('applies CONV-06 and CONV-07 to body (local mode)', () => { + const input = `--- +name: gsd-test +description: Test +tools: Read +--- + +Check ~/.claude/settings and run gsd:health.`; + + const result = convertClaudeAgentToCopilotAgent(input); + assert.ok(result.includes('.github/settings'), 'CONV-06 applied (local)'); + assert.ok(result.includes('gsd-health'), 'CONV-07 applied'); + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ remains'); + assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: command refs remain'); + }); + + test('applies CONV-06 and CONV-07 to body (global mode)', () => { + const input = `--- +name: gsd-test +description: Test +tools: Read +--- + +Check ~/.claude/settings and run gsd:health.`; + + const result = convertClaudeAgentToCopilotAgent(input, true); + assert.ok(result.includes('~/.copilot/settings'), 'CONV-06 applied (global)'); + assert.ok(result.includes('gsd-health'), 'CONV-07 applied'); + }); + + test('handles content without frontmatter (local mode)', () => { + const input = 'Just markdown with ~/.claude/path and gsd:test.'; + const result = convertClaudeAgentToCopilotAgent(input); + assert.ok(result.includes('.github/path'), 'CONV-06 applied (local)'); + assert.ok(result.includes('gsd-test'), 'CONV-07 applied'); + assert.ok(!result.includes('---'), 'no frontmatter added'); + }); +}); + +// ─── copyCommandsAsCopilotSkills (integration) ───────────────────────────────── + +describe('copyCommandsAsCopilotSkills', () => { + const srcDir = path.join(__dirname, '..', 'commands', 'gsd'); + + test('creates skill folders from source commands', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-')); + try { + copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd'); + + // Check specific folders exist + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'gsd-health folder exists'); + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health', 'SKILL.md')), 'gsd-health/SKILL.md exists'); + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-help')), 'gsd-help folder exists'); + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-progress')), 'gsd-progress folder exists'); + + // Count gsd-* directories — should be 31 + const dirs = fs.readdirSync(tempDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual(dirs.length, 33, `expected 33 skill folders, got ${dirs.length}`); + } finally { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + test('skill content has Copilot frontmatter format', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-')); + try { + copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd'); + + const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-health', 'SKILL.md'), 'utf8'); + // Frontmatter format checks + assert.ok(skillContent.startsWith('---\nname: gsd-health\n'), 'starts with name: gsd-health'); + assert.ok(skillContent.includes('allowed-tools: Read, Bash, Write, AskUserQuestion'), + 'allowed-tools is comma-separated'); + assert.ok(!skillContent.includes('allowed-tools:\n -'), 'NOT YAML multiline format'); + // CONV-06/07 applied + assert.ok(!skillContent.includes('~/.claude/'), 'no ~/.claude/ references'); + assert.ok(!skillContent.match(/gsd:[a-z]/), 'no gsd: command references'); + } finally { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + test('generates gsd-autonomous skill from autonomous.md command', () => { + // Fail-fast: source command must exist + const srcFile = path.join(srcDir, 'autonomous.md'); + assert.ok(fs.existsSync(srcFile), 'commands/gsd/autonomous.md must exist as source'); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-')); + try { + copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd'); + + // Skill folder and file created + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous')), 'gsd-autonomous folder exists'); + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md')), 'gsd-autonomous/SKILL.md exists'); + + const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md'), 'utf8'); + + // Frontmatter: name converted from gsd:autonomous to gsd-autonomous + assert.ok(skillContent.startsWith('---\nname: gsd-autonomous\n'), 'name is gsd-autonomous'); + assert.ok(skillContent.includes('description: Run all remaining phases autonomously'), + 'description preserved'); + // argument-hint present and double-quoted + assert.ok(skillContent.includes('argument-hint: "[--from N]"'), 'argument-hint present and quoted'); + // allowed-tools comma-separated + assert.ok(skillContent.includes('allowed-tools: Read, Write, Bash, Glob, Grep, AskUserQuestion, Task'), + 'allowed-tools is comma-separated'); + // No Claude-format remnants + assert.ok(!skillContent.includes('allowed-tools:\n -'), 'NOT YAML multiline format'); + assert.ok(!skillContent.includes('~/.claude/'), 'no ~/.claude/ references in body'); + } finally { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + test('autonomous skill body converts gsd: to gsd- (CONV-07)', () => { + // Use convertClaudeToCopilotContent directly on the command body content + const srcContent = fs.readFileSync(path.join(srcDir, 'autonomous.md'), 'utf8'); + const result = convertClaudeToCopilotContent(srcContent); + + // gsd:autonomous references should be converted to gsd-autonomous + assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: command references remain after conversion'); + // Specific: gsd:discuss-phase, gsd:plan-phase, gsd:execute-phase mentioned in body + // The body references gsd-tools.cjs (not a gsd: command) — those should be unaffected + // But /gsd:autonomous → /gsd-autonomous, gsd:discuss-phase → gsd-discuss-phase etc. + if (srcContent.includes('gsd:autonomous')) { + assert.ok(result.includes('gsd-autonomous'), 'gsd:autonomous converted to gsd-autonomous'); + } + // Path conversion: ~/.claude/ → .github/ + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ paths remain'); + }); + + test('cleans up old skill directories on re-run', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-')); + try { + // Create a fake old directory + fs.mkdirSync(path.join(tempDir, 'gsd-fake-old'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'gsd-fake-old', 'SKILL.md'), 'old'); + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir exists before'); + + // Run copy — should clean up old dirs + copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd'); + + assert.ok(!fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir removed'); + assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'real dirs still exist'); + } finally { + fs.rmSync(tempDir, { recursive: true }); + } + }); +}); + +// ─── Copilot agent conversion - real files ────────────────────────────────────── + +describe('Copilot agent conversion - real files', () => { + const agentsSrc = path.join(__dirname, '..', 'agents'); + + test('converts gsd-executor agent correctly', () => { + const content = fs.readFileSync(path.join(agentsSrc, 'gsd-executor.md'), 'utf8'); + const result = convertClaudeAgentToCopilotAgent(content); + + assert.ok(result.startsWith('---\nname: gsd-executor\n'), 'starts with correct name'); + // 6 Claude tools (Read, Write, Edit, Bash, Grep, Glob) → 4 after dedup + assert.ok(result.includes("tools: ['read', 'edit', 'execute', 'search']"), + 'tools mapped and deduplicated (6→4)'); + assert.ok(result.includes('color: yellow'), 'color preserved'); + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ in body'); + }); + + test('converts agent with mcp wildcard tools correctly', () => { + const content = fs.readFileSync(path.join(agentsSrc, 'gsd-phase-researcher.md'), 'utf8'); + const result = convertClaudeAgentToCopilotAgent(content); + + const toolsLine = result.split('\n').find(l => l.startsWith('tools:')); + assert.ok(toolsLine.includes('io.github.upstash/context7/*'), 'mcp wildcard mapped in tools'); + assert.ok(!toolsLine.includes('mcp__context7__'), 'no mcp__ prefix in tools line'); + assert.ok(toolsLine.includes("'web'"), 'WebSearch/WebFetch deduplicated to web'); + assert.ok(toolsLine.includes("'read'"), 'Read mapped'); + }); + + test('all 12 agents convert without error', () => { + const agents = fs.readdirSync(agentsSrc) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.strictEqual(agents.length, 12, `expected 12 agents, got ${agents.length}`); + + for (const agentFile of agents) { + const content = fs.readFileSync(path.join(agentsSrc, agentFile), 'utf8'); + const result = convertClaudeAgentToCopilotAgent(content); + assert.ok(result.startsWith('---\n'), `${agentFile} should have frontmatter`); + assert.ok(result.includes('tools:'), `${agentFile} should have tools field`); + assert.ok(!result.includes('~/.claude/'), `${agentFile} should not contain ~/.claude/`); + } + }); +}); + +// ─── Copilot content conversion - engine files ───────────────────────────────── + +describe('Copilot content conversion - engine files', () => { + test('converts engine .md files correctly (local mode default)', () => { + const healthMd = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'workflows', 'health.md'), 'utf8' + ); + const result = convertClaudeToCopilotContent(healthMd); + + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ references remain'); + assert.ok(!result.includes('$HOME/.claude/'), 'no $HOME/.claude/ references remain'); + assert.ok(!result.match(/\/gsd:[a-z]/), 'no /gsd: command references remain'); + assert.ok(!result.match(/(? { + const healthMd = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'workflows', 'health.md'), 'utf8' + ); + const result = convertClaudeToCopilotContent(healthMd, true); + + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ references remain'); + assert.ok(!result.includes('$HOME/.claude/'), 'no $HOME/.claude/ references remain'); + // Global mode: ~ and $HOME resolve to .copilot + if (healthMd.includes('$HOME/.claude/')) { + assert.ok(result.includes('$HOME/.copilot/'), '$HOME path converted to .copilot'); + } + assert.ok(result.includes('gsd-health'), 'command name converted'); + }); + + test('converts engine .cjs files correctly', () => { + const verifyCjs = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'verify.cjs'), 'utf8' + ); + const result = convertClaudeToCopilotContent(verifyCjs); + + assert.ok(!result.match(/gsd:[a-z]/), 'no gsd: references remain'); + assert.ok(result.includes('gsd-new-project'), 'gsd:new-project converted'); + assert.ok(result.includes('gsd-health'), 'gsd:health converted'); + }); +}); + +// ─── Copilot instructions merge/strip ────────────────────────────────────────── + +describe('Copilot instructions merge/strip', () => { + let tmpDir; + + const gsdContent = '- Follow project conventions\n- Use structured workflows'; + + function makeGsdBlock(content) { + return GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' + content.trim() + '\n' + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER; + } + + describe('mergeCopilotInstructions', () => { + let tmpMergeDir; + + beforeEach(() => { + tmpMergeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-merge-')); + }); + + afterEach(() => { + fs.rmSync(tmpMergeDir, { recursive: true, force: true }); + }); + + test('creates file from scratch when none exists', () => { + const filePath = path.join(tmpMergeDir, 'copilot-instructions.md'); + mergeCopilotInstructions(filePath, gsdContent); + + assert.ok(fs.existsSync(filePath), 'file was created'); + const result = fs.readFileSync(filePath, 'utf8'); + assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'has opening marker'); + assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER), 'has closing marker'); + assert.ok(result.includes('Follow project conventions'), 'has GSD content'); + }); + + test('replaces GSD section when both markers present', () => { + const filePath = path.join(tmpMergeDir, 'copilot-instructions.md'); + const oldContent = '# User Setup\n\n' + + makeGsdBlock('- Old GSD content') + + '\n\n# User Notes\n'; + fs.writeFileSync(filePath, oldContent); + + mergeCopilotInstructions(filePath, gsdContent); + const result = fs.readFileSync(filePath, 'utf8'); + + assert.ok(result.includes('# User Setup'), 'user content before preserved'); + assert.ok(result.includes('# User Notes'), 'user content after preserved'); + assert.ok(!result.includes('Old GSD content'), 'old GSD content removed'); + assert.ok(result.includes('Follow project conventions'), 'new GSD content inserted'); + }); + + test('appends to existing file when no markers present', () => { + const filePath = path.join(tmpMergeDir, 'copilot-instructions.md'); + const userContent = '# My Custom Instructions\n\nDo things my way.\n'; + fs.writeFileSync(filePath, userContent); + + mergeCopilotInstructions(filePath, gsdContent); + const result = fs.readFileSync(filePath, 'utf8'); + + assert.ok(result.includes('# My Custom Instructions'), 'original content preserved'); + assert.ok(result.includes('Do things my way.'), 'original text preserved'); + assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'GSD block appended'); + assert.ok(result.includes('Follow project conventions'), 'GSD content appended'); + // Verify separator exists + assert.ok(result.includes('Do things my way.\n\n' + GSD_COPILOT_INSTRUCTIONS_MARKER), + 'double newline separator before GSD block'); + }); + + test('handles file that is GSD-only (re-creates cleanly)', () => { + const filePath = path.join(tmpMergeDir, 'copilot-instructions.md'); + const gsdOnly = makeGsdBlock('- Old instructions') + '\n'; + fs.writeFileSync(filePath, gsdOnly); + + const newContent = '- Updated instructions'; + mergeCopilotInstructions(filePath, newContent); + const result = fs.readFileSync(filePath, 'utf8'); + + assert.ok(!result.includes('Old instructions'), 'old content removed'); + assert.ok(result.includes('Updated instructions'), 'new content present'); + assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'has opening marker'); + assert.ok(result.includes(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER), 'has closing marker'); + }); + + test('preserves user content before and after markers', () => { + const filePath = path.join(tmpMergeDir, 'copilot-instructions.md'); + const content = '# My Setup\n\n' + + makeGsdBlock('- old content') + + '\n\n# My Notes\n'; + fs.writeFileSync(filePath, content); + + mergeCopilotInstructions(filePath, gsdContent); + const result = fs.readFileSync(filePath, 'utf8'); + + assert.ok(result.includes('# My Setup'), 'content before markers preserved'); + assert.ok(result.includes('# My Notes'), 'content after markers preserved'); + assert.ok(result.includes('Follow project conventions'), 'new GSD content between markers'); + // Verify ordering: before → GSD → after + const setupIdx = result.indexOf('# My Setup'); + const markerIdx = result.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER); + const notesIdx = result.indexOf('# My Notes'); + assert.ok(setupIdx < markerIdx, 'user setup comes before GSD block'); + assert.ok(markerIdx < notesIdx, 'GSD block comes before user notes'); + }); + }); + + describe('stripGsdFromCopilotInstructions', () => { + test('returns null when content is GSD-only', () => { + const content = makeGsdBlock('- GSD instructions only') + '\n'; + const result = stripGsdFromCopilotInstructions(content); + assert.strictEqual(result, null, 'returns null for GSD-only content'); + }); + + test('returns cleaned content when user content exists before markers', () => { + const content = '# My Setup\n\nCustom rules here.\n\n' + + makeGsdBlock('- GSD stuff') + '\n'; + const result = stripGsdFromCopilotInstructions(content); + + assert.ok(result !== null, 'does not return null'); + assert.ok(result.includes('# My Setup'), 'user content preserved'); + assert.ok(result.includes('Custom rules here.'), 'user text preserved'); + assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'opening marker removed'); + assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER), 'closing marker removed'); + assert.ok(!result.includes('GSD stuff'), 'GSD content removed'); + }); + + test('returns cleaned content when user content exists after markers', () => { + const content = makeGsdBlock('- GSD stuff') + '\n\n# My Notes\n\nPersonal notes.\n'; + const result = stripGsdFromCopilotInstructions(content); + + assert.ok(result !== null, 'does not return null'); + assert.ok(result.includes('# My Notes'), 'user content after preserved'); + assert.ok(result.includes('Personal notes.'), 'user text after preserved'); + assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'opening marker removed'); + assert.ok(!result.includes('GSD stuff'), 'GSD content removed'); + }); + + test('returns cleaned content preserving both before and after', () => { + const content = '# Before\n\n' + makeGsdBlock('- GSD middle') + '\n\n# After\n'; + const result = stripGsdFromCopilotInstructions(content); + + assert.ok(result !== null, 'does not return null'); + assert.ok(result.includes('# Before'), 'content before preserved'); + assert.ok(result.includes('# After'), 'content after preserved'); + assert.ok(!result.includes('GSD middle'), 'GSD content removed'); + assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'markers removed'); + }); + + test('returns original content when no markers found', () => { + const content = '# Just user content\n\nNo GSD markers here.\n'; + const result = stripGsdFromCopilotInstructions(content); + assert.strictEqual(result, content, 'returns content unchanged'); + }); + }); +}); + +// ─── Copilot uninstall skill removal ─────────────────────────────────────────── + +describe('Copilot uninstall skill removal', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('identifies gsd-* skill directories for removal', () => { + // Create Copilot-like skills directory structure + const skillsDir = path.join(tmpDir, 'skills'); + fs.mkdirSync(path.join(skillsDir, 'gsd-foo'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'gsd-foo', 'SKILL.md'), '# Foo'); + fs.mkdirSync(path.join(skillsDir, 'gsd-bar'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'gsd-bar', 'SKILL.md'), '# Bar'); + fs.mkdirSync(path.join(skillsDir, 'custom-skill'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'custom-skill', 'SKILL.md'), '# Custom'); + + // Test the pattern: read skills, filter gsd-* entries + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + const gsdSkills = entries + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + const nonGsdSkills = entries + .filter(e => e.isDirectory() && !e.name.startsWith('gsd-')) + .map(e => e.name); + + assert.deepStrictEqual(gsdSkills.sort(), ['gsd-bar', 'gsd-foo'], 'identifies gsd-* skills'); + assert.deepStrictEqual(nonGsdSkills, ['custom-skill'], 'preserves non-gsd skills'); + }); + + test('cleans GSD section from copilot-instructions.md on uninstall', () => { + const content = '# My Setup\n\nMy custom rules.\n\n' + + GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' + + '- GSD managed content\n' + + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER + '\n'; + + const result = stripGsdFromCopilotInstructions(content); + + assert.ok(result !== null, 'does not return null when user content exists'); + assert.ok(result.includes('# My Setup'), 'user content preserved'); + assert.ok(result.includes('My custom rules.'), 'user text preserved'); + assert.ok(!result.includes('GSD managed content'), 'GSD content removed'); + assert.ok(!result.includes(GSD_COPILOT_INSTRUCTIONS_MARKER), 'markers removed'); + }); + + test('deletes copilot-instructions.md when GSD-only on uninstall', () => { + const content = GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' + + '- Only GSD content\n' + + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER + '\n'; + + const result = stripGsdFromCopilotInstructions(content); + + assert.strictEqual(result, null, 'returns null signaling file deletion'); + }); +}); + +// ─── Copilot manifest and patches fixes ──────────────────────────────────────── + +describe('Copilot manifest and patches fixes', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('writeManifest hashes skills for Copilot runtime', () => { + // Create minimal get-shit-done dir (required by writeManifest) + const gsdDir = path.join(tmpDir, 'get-shit-done', 'bin'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'verify.cjs'), '// verify stub'); + + // Create Copilot skills directory + const skillDir = path.join(tmpDir, 'skills', 'gsd-test'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Test Skill\n\nA test skill.'); + + const manifest = writeManifest(tmpDir, 'copilot'); + + // Check manifest file was written + const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'manifest file created'); + + // Read and verify skills are hashed + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const skillKey = 'skills/gsd-test/SKILL.md'; + assert.ok(data.files[skillKey], 'skill file hashed in manifest'); + assert.ok(typeof data.files[skillKey] === 'string', 'hash is a string'); + assert.ok(data.files[skillKey].length === 64, 'hash is SHA-256 (64 hex chars)'); + }); + + test('reportLocalPatches shows /gsd-reapply-patches for Copilot', () => { + // Create patches directory with metadata + const patchesDir = path.join(tmpDir, 'gsd-local-patches'); + fs.mkdirSync(patchesDir, { recursive: true }); + fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ + from_version: '1.0', + files: ['skills/gsd-test/SKILL.md'] + })); + + // Capture console output + const logs = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + + try { + const result = reportLocalPatches(tmpDir, 'copilot'); + + assert.ok(result.length > 0, 'returns patched files list'); + const output = logs.join('\n'); + assert.ok(output.includes('/gsd-reapply-patches'), 'uses dash format for Copilot'); + assert.ok(!output.includes('/gsd:reapply-patches'), 'does not use colon format'); + } finally { + console.log = originalLog; + } + }); + + test('reportLocalPatches shows /gsd:reapply-patches for Claude (unchanged)', () => { + // Create patches directory with metadata + const patchesDir = path.join(tmpDir, 'gsd-local-patches'); + fs.mkdirSync(patchesDir, { recursive: true }); + fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify({ + from_version: '1.0', + files: ['get-shit-done/bin/verify.cjs'] + })); + + // Capture console output + const logs = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + + try { + const result = reportLocalPatches(tmpDir, 'claude'); + + assert.ok(result.length > 0, 'returns patched files list'); + const output = logs.join('\n'); + assert.ok(output.includes('/gsd:reapply-patches'), 'uses colon format for Claude'); + } finally { + console.log = originalLog; + } + }); +}); + +// ============================================================================ +// E2E Integration Tests — Copilot Install & Uninstall +// ============================================================================ + +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); + +const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); +const EXPECTED_SKILLS = 33; +const EXPECTED_AGENTS = 12; + +function runCopilotInstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + return execFileSync(process.execPath, [INSTALL_PATH, '--copilot', '--local'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +function runCopilotUninstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + return execFileSync(process.execPath, [INSTALL_PATH, '--copilot', '--local', '--uninstall'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +describe('E2E: Copilot full install verification', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-')); + runCopilotInstall(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('installs expected number of skill directories', () => { + const skillsDir = path.join(tmpDir, '.github', 'skills'); + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + const gsdSkills = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual(gsdSkills.length, EXPECTED_SKILLS, + `Expected ${EXPECTED_SKILLS} skill directories, got ${gsdSkills.length}`); + }); + + test('each skill directory contains SKILL.md', () => { + const skillsDir = path.join(tmpDir, '.github', 'skills'); + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + const gsdSkills = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + for (const skill of gsdSkills) { + const skillMdPath = path.join(skillsDir, skill.name, 'SKILL.md'); + assert.ok(fs.existsSync(skillMdPath), + `Missing SKILL.md in ${skill.name}`); + } + }); + + test('installs expected number of agent files', () => { + const agentsDir = path.join(tmpDir, '.github', 'agents'); + const files = fs.readdirSync(agentsDir); + const gsdAgents = files.filter(f => f.startsWith('gsd-') && f.endsWith('.agent.md')); + assert.strictEqual(gsdAgents.length, EXPECTED_AGENTS, + `Expected ${EXPECTED_AGENTS} agent files, got ${gsdAgents.length}`); + }); + + test('installs all expected agent files', () => { + const agentsDir = path.join(tmpDir, '.github', 'agents'); + const files = fs.readdirSync(agentsDir); + const gsdAgents = files.filter(f => f.startsWith('gsd-') && f.endsWith('.agent.md')).sort(); + const expected = [ + 'gsd-codebase-mapper.agent.md', + 'gsd-debugger.agent.md', + 'gsd-executor.agent.md', + 'gsd-integration-checker.agent.md', + 'gsd-nyquist-auditor.agent.md', + 'gsd-phase-researcher.agent.md', + 'gsd-plan-checker.agent.md', + 'gsd-planner.agent.md', + 'gsd-project-researcher.agent.md', + 'gsd-research-synthesizer.agent.md', + 'gsd-roadmapper.agent.md', + 'gsd-verifier.agent.md', + ].sort(); + assert.deepStrictEqual(gsdAgents, expected); + }); + + test('generates copilot-instructions.md with GSD markers', () => { + const instrPath = path.join(tmpDir, '.github', 'copilot-instructions.md'); + assert.ok(fs.existsSync(instrPath), 'copilot-instructions.md should exist'); + const content = fs.readFileSync(instrPath, 'utf-8'); + assert.ok(content.includes(''), + 'Should contain GSD Configuration close marker'); + }); + + test('creates manifest with correct structure', () => { + const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'gsd-file-manifest.json should exist'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + assert.ok(manifest.version, 'manifest should have version'); + assert.ok(manifest.timestamp, 'manifest should have timestamp'); + assert.ok(manifest.files && typeof manifest.files === 'object', + 'manifest should have files object'); + assert.ok(Object.keys(manifest.files).length > 0, + 'manifest files should not be empty'); + }); + + test('manifest contains expected file categories', () => { + const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + const keys = Object.keys(manifest.files); + + const skillEntries = keys.filter(k => k.startsWith('skills/')); + const agentEntries = keys.filter(k => k.startsWith('agents/')); + const engineEntries = keys.filter(k => k.startsWith('get-shit-done/')); + + assert.strictEqual(skillEntries.length, EXPECTED_SKILLS, + `Expected ${EXPECTED_SKILLS} skill manifest entries, got ${skillEntries.length}`); + assert.strictEqual(agentEntries.length, EXPECTED_AGENTS, + `Expected ${EXPECTED_AGENTS} agent manifest entries, got ${agentEntries.length}`); + assert.ok(engineEntries.length > 0, + 'Should have get-shit-done/ engine manifest entries'); + }); + + test('manifest SHA256 hashes match actual file contents', () => { + const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + const githubDir = path.join(tmpDir, '.github'); + + for (const [relPath, expectedHash] of Object.entries(manifest.files)) { + const filePath = path.join(githubDir, relPath); + assert.ok(fs.existsSync(filePath), + `Manifest references ${relPath} but file does not exist`); + const content = fs.readFileSync(filePath); + const actualHash = crypto.createHash('sha256').update(content).digest('hex'); + assert.strictEqual(actualHash, expectedHash, + `SHA256 mismatch for ${relPath}: expected ${expectedHash}, got ${actualHash}`); + } + }); + + test('engine directory contains required subdirectories and files', () => { + const engineDir = path.join(tmpDir, '.github', 'get-shit-done'); + const requiredDirs = ['bin', 'references', 'templates', 'workflows']; + const requiredFiles = ['CHANGELOG.md', 'VERSION']; + + for (const dir of requiredDirs) { + const dirPath = path.join(engineDir, dir); + assert.ok(fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory(), + `Engine should contain directory: ${dir}`); + } + for (const file of requiredFiles) { + const filePath = path.join(engineDir, file); + assert.ok(fs.existsSync(filePath) && fs.statSync(filePath).isFile(), + `Engine should contain file: ${file}`); + } + }); +}); + +describe('E2E: Copilot uninstall verification', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-')); + runCopilotInstall(tmpDir); + runCopilotUninstall(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('removes engine directory', () => { + const engineDir = path.join(tmpDir, '.github', 'get-shit-done'); + assert.ok(!fs.existsSync(engineDir), + 'get-shit-done directory should not exist after uninstall'); + }); + + test('removes copilot-instructions.md', () => { + const instrPath = path.join(tmpDir, '.github', 'copilot-instructions.md'); + assert.ok(!fs.existsSync(instrPath), + 'copilot-instructions.md should not exist after uninstall'); + }); + + test('removes all GSD skill directories', () => { + const skillsDir = path.join(tmpDir, '.github', 'skills'); + if (fs.existsSync(skillsDir)) { + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + const gsdSkills = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual(gsdSkills.length, 0, + `Expected 0 GSD skill directories after uninstall, found: ${gsdSkills.map(e => e.name).join(', ')}`); + } + }); + + test('removes all GSD agent files', () => { + const agentsDir = path.join(tmpDir, '.github', 'agents'); + if (fs.existsSync(agentsDir)) { + const files = fs.readdirSync(agentsDir); + const gsdAgents = files.filter(f => f.startsWith('gsd-') && f.endsWith('.agent.md')); + assert.strictEqual(gsdAgents.length, 0, + `Expected 0 GSD agent files after uninstall, found: ${gsdAgents.join(', ')}`); + } + }); + + test('preserves non-GSD content in skills directory', () => { + // Standalone lifecycle: install → add custom content → uninstall → verify + const td = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-preserve-skill-')); + try { + runCopilotInstall(td); + // Add non-GSD custom skill + const customSkillDir = path.join(td, '.github', 'skills', 'my-custom-skill'); + fs.mkdirSync(customSkillDir, { recursive: true }); + fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n'); + // Uninstall + runCopilotUninstall(td); + // Verify custom content preserved + assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), + 'Non-GSD skill directory and SKILL.md should be preserved after uninstall'); + } finally { + fs.rmSync(td, { recursive: true, force: true }); + } + }); + + test('preserves non-GSD content in agents directory', () => { + // Standalone lifecycle: install → add custom content → uninstall → verify + const td = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-preserve-agent-')); + try { + runCopilotInstall(td); + // Add non-GSD custom agent + const customAgentPath = path.join(td, '.github', 'agents', 'my-agent.md'); + fs.writeFileSync(customAgentPath, '# My Custom Agent\n'); + // Uninstall + runCopilotUninstall(td); + // Verify custom content preserved + assert.ok(fs.existsSync(customAgentPath), + 'Non-GSD agent file should be preserved after uninstall'); + } finally { + fs.rmSync(td, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index b1ad4be6b..ea5e2f538 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -146,6 +146,50 @@ This phase covers: assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted'); }); + test('extracts goal when colon is outside bold (**Goal**: format)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.24 + +### Phase 5: Skill Scaffolding +**Goal**: The autonomous skill files exist following project conventions +**Plans:** 2 plans + +### Phase 6: Smart Discuss +**Goal**: Grey area resolution works with proposals +` + ); + + const result = runGsdTools('roadmap get-phase 5', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'phase should be found'); + assert.strictEqual(output.goal, 'The autonomous skill files exist following project conventions', 'goal extracted with colon outside bold'); + }); + + test('extracts goal for both colon-inside and colon-outside bold formats', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 1: Alpha +**Goal:** Colon inside bold format + +### Phase 2: Beta +**Goal**: Colon outside bold format +` + ); + + const result1 = runGsdTools('roadmap get-phase 1', tmpDir); + const output1 = JSON.parse(result1.output); + assert.strictEqual(output1.goal, 'Colon inside bold format', 'colon-inside-bold goal extracted'); + + const result2 = runGsdTools('roadmap get-phase 2', tmpDir); + const output2 = JSON.parse(result2.output); + assert.strictEqual(output2.goal, 'Colon outside bold format', 'colon-outside-bold goal extracted'); + }); + test('detects malformed ROADMAP with summary list but no detail sections', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), @@ -257,6 +301,56 @@ describe('roadmap analyze command', () => { assert.strictEqual(output.phases[1].goal, 'Build features'); assert.strictEqual(output.phases[1].depends_on, 'Phase 1'); }); + + test('extracts goals and depends_on with colon outside bold (**Goal**: format)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.24 + +### Phase 5: Skill Scaffolding +**Goal**: The autonomous skill files exist following project conventions +**Depends on**: Phase 4 (v1.23 complete) + +### Phase 6: Smart Discuss +**Goal**: Grey area resolution works with proposals +**Depends on**: Phase 5 +` + ); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].goal, 'The autonomous skill files exist following project conventions', 'goal extracted with colon outside bold'); + assert.strictEqual(output.phases[0].depends_on, 'Phase 4 (v1.23 complete)', 'depends_on extracted with colon outside bold'); + assert.strictEqual(output.phases[1].goal, 'Grey area resolution works with proposals', 'second phase goal extracted'); + assert.strictEqual(output.phases[1].depends_on, 'Phase 5', 'second phase depends_on extracted'); + }); + + test('handles mixed colon-inside and colon-outside bold formats in analyze', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +### Phase 1: Alpha +**Goal:** Colon inside bold +**Depends on:** Nothing + +### Phase 2: Beta +**Goal**: Colon outside bold +**Depends on**: Phase 1 +` + ); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].goal, 'Colon inside bold', 'colon-inside goal works'); + assert.strictEqual(output.phases[0].depends_on, 'Nothing', 'colon-inside depends_on works'); + assert.strictEqual(output.phases[1].goal, 'Colon outside bold', 'colon-outside goal works'); + assert.strictEqual(output.phases[1].depends_on, 'Phase 1', 'colon-outside depends_on works'); + }); }); // ─────────────────────────────────────────────────────────────────────────────