diff --git a/README.ja-JP.md b/README.ja-JP.md index 9c8d6c0a0..608c11bea 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -4,7 +4,7 @@ [English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · **日本語** -**Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Antigravity向けの軽量かつ強力なメタプロンプティング、コンテキストエンジニアリング、仕様駆動開発システム。** +**Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf、Antigravity、Augment向けの軽量かつ強力なメタプロンプティング、コンテキストエンジニアリング、仕様駆動開発システム。** **コンテキストロット(Claudeがコンテキストウィンドウを消費するにつれ品質が劣化する現象)を解決します。** diff --git a/README.ko-KR.md b/README.ko-KR.md index 57bd59c81..781d08d90 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -4,7 +4,7 @@ [English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · **한국어** -**Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Antigravity를 위한 가볍고 강력한 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.** +**Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment를 위한 가볍고 강력한 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.** **컨텍스트 rot를 해결합니다 — Claude의 컨텍스트 창이 채워질수록 품질이 저하되는 문제.** diff --git a/README.md b/README.md index 1d69c25eb..ea62152bf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) -**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and Antigravity.** +**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, and Augment.** **Solves context rot — the quality degradation that happens as Claude fills its context window.** @@ -84,16 +84,13 @@ npx get-shit-done-cc@latest ``` The installer prompts you to choose: -1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, or all (interactive multi-select — pick multiple runtimes in a single install session) +1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, or all (interactive multi-select — pick multiple runtimes in a single install session) 2. **Location** — Global (all projects) or local (current project only) Verify with: -- Claude Code / Gemini: `/gsd:help` -- OpenCode: `/gsd-help` -- Kilo: `/gsd-help` +- Claude Code / Gemini / Copilot / Antigravity: `/gsd:help` +- OpenCode / Kilo / Augment: `/gsd-help` - Codex: `$gsd-help` -- Copilot: `/gsd:help` -- Antigravity: `/gsd:help` > [!NOTE] > Claude Code 2.1.88+ and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. The installer handles this automatically. @@ -144,12 +141,16 @@ npx get-shit-done-cc --windsurf --local # Install to ./.windsurf/ npx get-shit-done-cc --antigravity --global # Install to ~/.gemini/antigravity/ npx get-shit-done-cc --antigravity --local # Install to ./.agent/ +# Augment +npx get-shit-done-cc --augment --global # Install to ~/.augment/ +npx get-shit-done-cc --augment --local # Install to ./.augment/ + # All runtimes npx get-shit-done-cc --all --global # Install to all directories ``` Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt. -Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, or `--all` to skip the runtime prompt. +Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, or `--all` to skip the runtime prompt. Use `--sdk` to also install the GSD SDK CLI (`gsd-sdk`) for headless autonomous execution. @@ -806,6 +807,7 @@ npx get-shit-done-cc --copilot --global --uninstall npx get-shit-done-cc --cursor --global --uninstall npx get-shit-done-cc --windsurf --global --uninstall npx get-shit-done-cc --antigravity --global --uninstall +npx get-shit-done-cc --augment --global --uninstall # Local installs (current project) npx get-shit-done-cc --claude --local --uninstall @@ -817,6 +819,7 @@ npx get-shit-done-cc --copilot --local --uninstall npx get-shit-done-cc --cursor --local --uninstall npx get-shit-done-cc --windsurf --local --uninstall npx get-shit-done-cc --antigravity --local --uninstall +npx get-shit-done-cc --augment --local --uninstall ``` This removes all GSD commands, agents, hooks, and settings while preserving your other configurations. diff --git a/README.pt-BR.md b/README.pt-BR.md index 7e517aab3..8e97b31a6 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -4,7 +4,7 @@ [English](README.md) · **Português** · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) -**Um sistema leve e poderoso de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificação para Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor e Antigravity.** +**Um sistema leve e poderoso de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificação para Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity e Augment.** **Resolve context rot — a degradação de qualidade que acontece conforme o Claude enche a janela de contexto.** diff --git a/bin/install.js b/bin/install.js index 4b550a8d3..dfb1efccd 100755 --- a/bin/install.js +++ b/bin/install.js @@ -68,6 +68,7 @@ const hasCopilot = args.includes('--copilot'); const hasAntigravity = args.includes('--antigravity'); const hasCursor = args.includes('--cursor'); const hasWindsurf = args.includes('--windsurf'); +const hasAugment = args.includes('--augment'); const hasSdk = args.includes('--sdk'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasAll = args.includes('--all'); @@ -76,7 +77,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', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf']; + selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { @@ -89,6 +90,7 @@ if (hasAll) { if (hasAntigravity) selectedRuntimes.push('antigravity'); if (hasCursor) selectedRuntimes.push('cursor'); if (hasWindsurf) selectedRuntimes.push('windsurf'); + if (hasAugment) selectedRuntimes.push('augment'); } // WSL + Windows Node.js detection @@ -135,6 +137,7 @@ function getDirName(runtime) { if (runtime === 'antigravity') return '.agent'; if (runtime === 'cursor') return '.cursor'; if (runtime === 'windsurf') return '.windsurf'; + if (runtime === 'augment') return '.augment'; return '.claude'; } @@ -165,6 +168,7 @@ function getConfigDirFromHome(runtime, isGlobal) { } if (runtime === 'cursor') return "'.cursor'"; if (runtime === 'windsurf') return "'.windsurf'"; + if (runtime === 'augment') return "'.augment'"; return "'.claude'"; } @@ -306,6 +310,16 @@ function getGlobalDir(runtime, explicitDir = null) { return path.join(os.homedir(), '.windsurf'); } + if (runtime === 'augment') { + // Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment + if (explicitDir) { + return expandTilde(explicitDir); + } + if (process.env.AUGMENT_CONFIG_DIR) { + return expandTilde(process.env.AUGMENT_CONFIG_DIR); + } + return path.join(os.homedir(), '.augment'); + } // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude if (explicitDir) { @@ -327,7 +341,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, Kilo, Codex, Copilot, Antigravity, Cursor, and Windsurf by TÂCHES.\n'; + ' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, and Augment by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { @@ -365,7 +379,7 @@ if (hasUninstall) { // 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}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}--sdk${reset} Also install GSD SDK CLI (gsd-sdk)\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 Kilo globally${reset}\n npx get-shit-done-cc --kilo --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 Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --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 --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --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 / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR 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}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}--sdk${reset} Also install GSD SDK CLI (gsd-sdk)\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 Kilo globally${reset}\n npx get-shit-done-cc --kilo --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 Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --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 --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --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 / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -1224,6 +1238,189 @@ function convertClaudeAgentToWindsurfAgent(content) { return `${cleanFrontmatter}\n${body}`; } +// --- Augment converters --- +// Augment (auggie CLI) uses a tool set similar to Cursor/Windsurf (VS Code-based). +// Config lives in .augment/ (local) and ~/.augment/ (global). + +const claudeToAugmentTools = { + Bash: 'launch-process', + Edit: 'str-replace-editor', + AskUserQuestion: null, + SlashCommand: null, + TodoWrite: 'add_tasks', +}; + +function convertAugmentToolName(claudeTool) { + if (claudeTool in claudeToAugmentTools) { + return claudeToAugmentTools[claudeTool]; + } + if (claudeTool.startsWith('mcp__')) { + return claudeTool; + } + const toolMapping = { + Read: 'view', + Write: 'save-file', + Glob: 'view', + Grep: 'grep', + Task: null, + WebSearch: 'web-search', + WebFetch: 'web-fetch', + }; + return toolMapping[claudeTool] || claudeTool; +} + +function convertSlashCommandsToAugmentSkillMentions(content) { + return content.replace(/gsd:/gi, 'gsd-'); +} + +function convertClaudeToAugmentMarkdown(content) { + let converted = convertSlashCommandsToAugmentSkillMentions(content); + converted = converted.replace(/\bBash\(/g, 'launch-process('); + converted = converted.replace(/\bEdit\(/g, 'str-replace-editor('); + converted = converted.replace(/\bRead\(/g, 'view('); + converted = converted.replace(/\bWrite\(/g, 'save-file('); + converted = converted.replace(/\bTodoWrite\(/g, 'add_tasks('); + converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting'); + // Replace subagent_type from Claude to Augment format + converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"'); + converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); + // Replace project-level Claude conventions with Augment equivalents + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.augment/rules/`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.augment/rules/'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.augment/rules/`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.augment/rules/'); + converted = converted.replace(/\.claude\/skills\//g, '.augment/skills/'); + // Remove Claude Code-specific bug workarounds before brand replacement + converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); + converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); + // Replace "Claude Code" brand references with "Augment" + converted = converted.replace(/\bClaude Code\b/g, 'Augment'); + return converted; +} + +function getAugmentSkillAdapterHeader(skillName) { + return ` +## A. Skill Invocation +- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill. +- Treat all user text after the skill mention as \`{{GSD_ARGS}}\`. +- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty. + +## B. User Prompting +When the workflow needs user input, prompt the user conversationally: +- Present options as a numbered list in your response text +- Ask the user to reply with their choice +- For multi-select, ask for comma-separated numbers + +## C. Tool Usage +Use these Augment tools when executing GSD workflows: +- \`launch-process\` for running commands (terminal operations) +- \`str-replace-editor\` for editing existing files +- \`view\` for reading files and listing directories +- \`save-file\` for creating new files +- \`grep\` for searching code (or use MCP servers for advanced search) +- \`web-search\`, \`web-fetch\` for web queries +- \`add_tasks\`, \`view_tasklist\`, \`update_tasks\` for task management + +## D. Subagent Spawning +When the workflow needs to spawn a subagent: +- Use the built-in subagent spawning capability +- Define agent prompts in \`.augment/agents/\` directory +`; +} + +function convertClaudeCommandToAugmentSkill(content, skillName) { + const converted = convertClaudeToAugmentMarkdown(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + let description = `Run GSD workflow ${skillName}.`; + if (frontmatter) { + const maybeDescription = extractFrontmatterField(frontmatter, 'description'); + if (maybeDescription) { + description = maybeDescription; + } + } + description = toSingleLine(description); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + const adapter = getAugmentSkillAdapterHeader(skillName); + + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; +} + +/** + * Convert Claude Code agent markdown to Augment agent format. + * Strips frontmatter fields Augment doesn't support (color, skills), + * converts tool references, and cleans up for Augment agents. + */ +function convertClaudeAgentToAugmentAgent(content) { + let converted = convertClaudeToAugmentMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +/** + * Copy Claude commands as Augment skills — one folder per skill with SKILL.md. + * Mirrors copyCommandsAsCursorSkills but uses Augment converters. + */ +function copyCommandsAsAugmentSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { + if (!fs.existsSync(srcDir)) { + return; + } + + fs.mkdirSync(skillsDir, { recursive: true }); + + // Remove previous GSD Augment skills to avoid stale command 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'); + const globalClaudeRegex = /~\/\.claude\//g; + const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; + const localClaudeRegex = /\.\/\.claude\//g; + const augmentDirRegex = /~\/\.augment\//g; + content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, pathPrefix); + content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); + content = content.replace(augmentDirRegex, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + content = convertClaudeCommandToAugmentSkill(content, skillName); + + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + } + } + + recurse(srcDir, prefix); +} + function convertSlashCommandsToCodexSkillMentions(content) { let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => { return `$gsd-${String(commandName).toLowerCase()}`; @@ -3509,6 +3706,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const isAntigravity = runtime === 'antigravity'; const isCursor = runtime === 'cursor'; const isWindsurf = runtime === 'windsurf'; + const isAugment = runtime === 'augment'; const dirName = getDirName(runtime); // Clean install: remove existing destination to prevent orphaned files @@ -3777,6 +3975,7 @@ function uninstall(isGlobal, runtime = 'claude') { const isAntigravity = runtime === 'antigravity'; const isCursor = runtime === 'cursor'; const isWindsurf = runtime === 'windsurf'; + const isAugment = runtime === 'augment'; const dirName = getDirName(runtime); // Get the target directory based on runtime and install type @@ -3797,6 +3996,7 @@ function uninstall(isGlobal, runtime = 'claude') { if (runtime === 'antigravity') runtimeLabel = 'Antigravity'; if (runtime === 'cursor') runtimeLabel = 'Cursor'; if (runtime === 'windsurf') runtimeLabel = 'Windsurf'; + if (runtime === 'augment') runtimeLabel = 'Augment'; console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -3964,9 +4164,26 @@ function uninstall(isGlobal, runtime = 'claude') { // Gemini: still uses commands/gsd/ const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(gsdCommandsDir)) { + // Preserve user-generated files before wipe (#1423) + // Note: if more user files are added, consider a naming convention (e.g., USER-*.md) + // and preserve all matching files instead of listing each one individually. + const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md'); + const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; + fs.rmSync(gsdCommandsDir, { recursive: true }); removedCount++; console.log(` ${green}✓${reset} Removed commands/gsd/`); + + // Restore user-generated files + if (preservedDevPrefs) { + try { + fs.mkdirSync(gsdCommandsDir, { recursive: true }); + fs.writeFileSync(devPrefsPath, preservedDevPrefs); + console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`); + } catch (err) { + console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`); + } + } } } else { // Claude Code: remove skills/gsd-*/ directories @@ -3989,18 +4206,47 @@ function uninstall(isGlobal, runtime = 'claude') { // Also clean up legacy commands/gsd/ from older installs const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(legacyCommandsDir)) { + // Preserve user-generated files before legacy wipe (#1423) + const devPrefsPath = path.join(legacyCommandsDir, 'dev-preferences.md'); + const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; + fs.rmSync(legacyCommandsDir, { recursive: true }); removedCount++; console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); + + if (preservedDevPrefs) { + try { + fs.mkdirSync(legacyCommandsDir, { recursive: true }); + fs.writeFileSync(devPrefsPath, preservedDevPrefs); + console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`); + } catch (err) { + console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`); + } + } } } // 2. Remove get-shit-done directory const gsdDir = path.join(targetDir, 'get-shit-done'); if (fs.existsSync(gsdDir)) { + // Preserve user-generated files before wipe (#1423) + const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md'); + const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null; + fs.rmSync(gsdDir, { recursive: true }); removedCount++; console.log(` ${green}✓${reset} Removed get-shit-done/`); + + // Restore user-generated files + if (preservedProfile) { + try { + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(userProfilePath, preservedProfile); + console.log(` ${green}✓${reset} Preserved get-shit-done/USER-PROFILE.md`); + } catch (err) { + console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`); + } + } } // 3. Remove GSD agents (gsd-*.md files only) @@ -4023,7 +4269,7 @@ function uninstall(isGlobal, runtime = 'claude') { // 4. Remove GSD hooks const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { - const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js']; + const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh']; let hookCount = 0; for (const hook of gsdHooks) { const hookPath = path.join(hooksDir, hook); @@ -4338,7 +4584,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) { } } - // OpenCode also supports a top-level string permission like "allow". + // OpenCode also allows a top-level string permission like "allow". // In that case, path-specific permission entries are unnecessary. if (typeof config.permission === 'string') { return; @@ -4419,7 +4665,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) { } // Ensure permission structure exists - if (!config.permission) { + if (!config.permission || typeof config.permission !== 'object') { config.permission = {}; } @@ -4704,6 +4950,7 @@ function install(isGlobal, runtime = 'claude') { const isAntigravity = runtime === 'antigravity'; const isCursor = runtime === 'cursor'; const isWindsurf = runtime === 'windsurf'; + const isAugment = runtime === 'augment'; const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -4736,6 +4983,7 @@ function install(isGlobal, runtime = 'claude') { if (isAntigravity) runtimeLabel = 'Antigravity'; if (isCursor) runtimeLabel = 'Cursor'; if (isWindsurf) runtimeLabel = 'Windsurf'; + if (isAugment) runtimeLabel = 'Augment'; console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`); @@ -4823,11 +5071,19 @@ function install(isGlobal, runtime = 'claude') { } else { failures.push('skills/gsd-*'); } + } else if (isAugment) { + const skillsDir = path.join(targetDir, 'skills'); + const gsdSrc = path.join(src, 'commands', 'gsd'); + copyCommandsAsAugmentSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); + const installedSkillNames = listCodexSkillNames(skillsDir); + if (installedSkillNames.length > 0) { + console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } } else if (isGemini) { - // Gemini: nested structure in commands/ directory (still supported) const commandsDir = path.join(targetDir, 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); - const gsdSrc = path.join(src, 'commands', 'gsd'); const gsdDest = path.join(commandsDir, 'gsd'); copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); @@ -4916,6 +5172,8 @@ function install(isGlobal, runtime = 'claude') { content = convertClaudeAgentToCursorAgent(content); } else if (isWindsurf) { content = convertClaudeAgentToWindsurfAgent(content); + } else if (isAugment) { + content = convertClaudeAgentToAugmentAgent(content); } const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; fs.writeFileSync(path.join(agentsDest, destName), content); @@ -5241,6 +5499,75 @@ function install(isGlobal, runtime = 'claude') { }); console.log(` ${green}✓${reset} Configured prompt injection guard hook`); } + + // Community hooks — registered on install but opt-in at runtime. + // Each hook checks .planning/config.json for hooks.community: true + // and exits silently (no-op) if not enabled. This lets users enable + // them per-project by adding: "hooks": { "community": true } + + // Configure commit validation hook (Conventional Commits enforcement, opt-in) + const validateCommitCommand = isGlobal + ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-validate-commit.sh' + : 'bash ' + dirName + '/hooks/gsd-validate-commit.sh'; + const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit')) + ); + + if (!hasValidateCommitHook) { + settings.hooks[preToolEvent].push({ + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: validateCommitCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`); + } + + // Configure session state orientation hook (opt-in) + const sessionStateCommand = isGlobal + ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-session-state.sh' + : 'bash ' + dirName + '/hooks/gsd-session-state.sh'; + const hasSessionStateHook = settings.hooks.SessionStart.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state')) + ); + + if (!hasSessionStateHook) { + settings.hooks.SessionStart.push({ + hooks: [ + { + type: 'command', + command: sessionStateCommand + } + ] + }); + console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`); + } + + // Configure phase boundary detection hook (opt-in) + const phaseBoundaryCommand = isGlobal + ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-phase-boundary.sh' + : 'bash ' + dirName + '/hooks/gsd-phase-boundary.sh'; + const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary')) + ); + + if (!hasPhaseBoundaryHook) { + settings.hooks[postToolEvent].push({ + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: phaseBoundaryCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`); + } } return { settingsPath, settings, statuslineCommand, runtime, configDir: targetDir }; @@ -5309,6 +5636,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'copilot') program = 'Copilot'; if (runtime === 'antigravity') program = 'Antigravity'; if (runtime === 'cursor') program = 'Cursor'; + if (runtime === 'windsurf') program = 'Windsurf'; + if (runtime === 'augment') program = 'Augment'; let command = '/gsd:new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; @@ -5317,6 +5646,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'copilot') command = '/gsd-new-project'; if (runtime === 'antigravity') command = '/gsd-new-project'; if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)'; + if (runtime === 'windsurf') command = '/gsd-new-project'; + if (runtime === 'augment') command = '/gsd-new-project'; console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. @@ -5468,9 +5799,10 @@ function promptRuntime(callback) { '6': 'copilot', '7': 'antigravity', '8': 'cursor', - '9': 'windsurf' + '9': 'windsurf', + '10': 'augment' }; - const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf']; + const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment']; console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} ${cyan}2${reset}) Kilo ${dim}(~/.config/kilo)${reset} - open source, the #1 AI coding platform on OpenRouter @@ -5481,7 +5813,8 @@ function promptRuntime(callback) { ${cyan}7${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} ${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset} ${cyan}9${reset}) Windsurf ${dim}(~/.windsurf)${reset} - ${cyan}10${reset}) All + ${cyan}10${reset}) Augment ${dim}(~/.augment)${reset} + ${cyan}11${reset}) All ${dim}Select multiple: 1,2,6 or 1 2 6${reset} `); @@ -5492,7 +5825,7 @@ function promptRuntime(callback) { const input = answer.trim() || '1'; // "All" shortcut - if (input === '10') { + if (input === '11') { callback(allRuntimes); return; } @@ -5655,11 +5988,16 @@ if (process.env.GSD_TEST_MODE) { convertClaudeCommandToWindsurfSkill, convertClaudeAgentToWindsurfAgent, copyCommandsAsWindsurfSkills, + convertClaudeToAugmentMarkdown, + convertClaudeCommandToAugmentSkill, + convertClaudeAgentToAugmentAgent, + copyCommandsAsAugmentSkills, writeManifest, reportLocalPatches, validateHookFields, installSdk, promptSdk, + configureOpencodePermissions, }; } else { diff --git a/commands/gsd/autonomous.md b/commands/gsd/autonomous.md index bea90d6bb..fc2f0a3fe 100644 --- a/commands/gsd/autonomous.md +++ b/commands/gsd/autonomous.md @@ -1,7 +1,7 @@ --- name: gsd:autonomous description: Run all remaining phases autonomously — discuss→plan→execute per phase -argument-hint: "[--from N]" +argument-hint: "[--from N] [--only N] [--interactive]" allowed-tools: - Read - Write @@ -30,7 +30,10 @@ Uses ROADMAP.md phase discovery and Skill() flat invocations for each phase comm -Optional flag: `--from N` — start from phase N instead of the first incomplete phase. +Optional flags: +- `--from N` — start from phase N instead of the first incomplete phase. +- `--only N` — execute only phase N (single-phase mode). +- `--interactive` — run discuss inline with questions (not auto-answered), then dispatch plan→execute as background agents. Keeps the main context lean while preserving user input on decisions. 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. diff --git a/commands/gsd/debug.md b/commands/gsd/debug.md index 9a2a728d3..993ac6d23 100644 --- a/commands/gsd/debug.md +++ b/commands/gsd/debug.md @@ -1,7 +1,7 @@ --- name: gsd:debug description: Systematic debugging with persistent state across context resets -argument-hint: [issue description] +argument-hint: [--diagnose] [issue description] allowed-tools: - Read - Bash @@ -15,6 +15,9 @@ Debug issues using scientific method with subagent isolation. **Orchestrator role:** Gather symptoms, spawn gsd-debugger agent, handle checkpoints, spawn continuations. **Why subagent:** Investigation burns context fast (reading files, forming hypotheses, testing). Fresh 200k context per investigation. Main context stays lean for user interaction. + +**Flags:** +- `--diagnose` — Diagnose only. Find root cause without applying a fix. Returns a structured Root Cause Report. Use when you want to validate the diagnosis before committing to a fix. @@ -25,6 +28,10 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo User's issue: $ARGUMENTS +Parse flags from $ARGUMENTS: +- If `--diagnose` is present, set `diagnose_only=true` and remove the flag from the issue description. +- Otherwise, `diagnose_only=false`. + Check for active sessions: ```bash ls .planning/debug/*.md 2>/dev/null | grep -v resolved | head -5 @@ -87,7 +94,7 @@ timeline: {timeline} symptoms_prefilled: true -goal: find_and_fix +goal: {if diagnose_only: "find_root_cause_only", else: "find_and_fix"} @@ -106,12 +113,18 @@ Task( ## 4. Handle Agent Return -**If `## ROOT CAUSE FOUND`:** -- Display root cause and evidence summary +**If `## ROOT CAUSE FOUND` (diagnose-only mode):** +- Display root cause, confidence level, files involved, and suggested fix strategies - Offer options: - - "Fix now" - spawn fix subagent - - "Plan fix" - suggest /gsd:plan-phase --gaps - - "Manual fix" - done + - "Fix now" — spawn a continuation agent with `goal: find_and_fix` to apply the fix (see step 5) + - "Plan fix" — suggest `/gsd:plan-phase --gaps` + - "Manual fix" — done + +**If `## DEBUG COMPLETE` (find_and_fix mode):** +- Display root cause and fix summary +- Offer options: + - "Plan fix" — suggest `/gsd:plan-phase --gaps` if further work needed + - "Done" — mark resolved **If `## CHECKPOINT REACHED`:** - Present checkpoint details to user @@ -128,9 +141,9 @@ Task( - "Manual investigation" - done - "Add more context" - gather more symptoms, spawn again -## 5. Spawn Continuation Agent (After Checkpoint) +## 5. Spawn Continuation Agent (After Checkpoint or "Fix now") -When user responds to checkpoint, spawn fresh agent: +When user responds to checkpoint OR selects "Fix now" from diagnose-only results, spawn fresh agent: ```markdown diff --git a/commands/gsd/review.md b/commands/gsd/review.md index 84d8171f6..eaad1ac36 100644 --- a/commands/gsd/review.md +++ b/commands/gsd/review.md @@ -1,7 +1,7 @@ --- name: gsd:review description: Request cross-AI peer review of phase plans from external AI CLIs -argument-hint: "--phase N [--gemini] [--claude] [--codex] [--all]" +argument-hint: "--phase N [--gemini] [--claude] [--codex] [--opencode] [--all]" allowed-tools: - Read - Write @@ -11,7 +11,7 @@ allowed-tools: --- -Invoke external AI CLIs (Gemini, Claude, Codex) to independently review phase plans. +Invoke external AI CLIs (Gemini, Claude, Codex, OpenCode) to independently review phase plans. Produces a structured REVIEWS.md with per-reviewer feedback that can be fed back into planning via /gsd:plan-phase --reviews. @@ -29,6 +29,7 @@ Phase number: extracted from $ARGUMENTS (required) - `--gemini` — Include Gemini CLI review - `--claude` — Include Claude CLI review (uses separate session) - `--codex` — Include Codex CLI review +- `--opencode` — Include OpenCode review (uses model from user's OpenCode config) - `--all` — Include all available CLIs diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 7afa387c5..d186e3813 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -2,7 +2,7 @@ # GET SHIT DONE -**一个轻量级且强大的元提示、上下文工程和规格驱动开发系统,支持 Claude Code、OpenCode、Gemini CLI、Kilo 和 Codex。** +**一个轻量级且强大的元提示、上下文工程和规格驱动开发系统,支持 Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf、Antigravity 和 Augment。** **解决上下文衰减 —— 即 Claude 填充上下文窗口时发生的质量退化问题。** diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 8cdf43617..4fb0a9183 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -430,6 +430,11 @@ async function runCommand(command, args, cwd, raw) { break; } + case 'check-commit': { + commands.cmdCheckCommit(cwd, raw); + break; + } + case 'commit-to-subrepo': { const message = args[1]; const filesIndex = args.indexOf('--files'); diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index bbacb3542..f2f825900 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -960,6 +960,39 @@ function cmdStats(cwd, format, raw) { } } +/** + * Check whether a commit should be allowed based on commit_docs config. + * When commit_docs is false, rejects commits that stage .planning/ files. + * Intended for use as a pre-commit hook guard. + */ +function cmdCheckCommit(cwd, raw) { + const config = loadConfig(cwd); + + // If commit_docs is true (or not set), allow all commits + if (config.commit_docs !== false) { + output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed'); + return; + } + + // commit_docs is false — check if any .planning/ files are staged + try { + const staged = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8' }).trim(); + const planningFiles = staged.split('\n').filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\')); + + if (planningFiles.length > 0) { + error( + `commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` + + planningFiles.map(f => ` ${f}`).join('\n') + + `\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}` + ); + } + } catch { + // git diff --cached failed (no staged files or not a git repo) — allow + } + + output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed'); +} + module.exports = { cmdGenerateSlug, cmdCurrentTimestamp, @@ -976,4 +1009,5 @@ module.exports = { cmdTodoMatchPhase, cmdScaffold, cmdStats, + cmdCheckCommit, }; diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 6b66d2668..5f1ae3d38 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -23,8 +23,9 @@ const VALID_CONFIG_KEYS = new Set([ 'workflow.skip_discuss', 'workflow._auto_chain_active', 'workflow.use_worktrees', - 'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', + 'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', 'planning.commit_docs', 'planning.search_gitignored', + 'workflow.subagent_timeout', 'hooks.context_warnings', 'project_code', 'phase_naming', ]); diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 9f0419a05..81b8cd9b4 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -195,7 +195,7 @@ function safeReadFile(filePath) { } function loadConfig(cwd) { - const configPath = path.join(cwd, '.planning', 'config.json'); + const configPath = path.join(planningDir(cwd), 'config.json'); const defaults = { model_profile: 'balanced', commit_docs: true, @@ -218,6 +218,7 @@ function loadConfig(cwd) { context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs) project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation') + subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms) }; try { @@ -310,6 +311,7 @@ function loadConfig(cwd) { context_window: get('context_window') ?? defaults.context_window, phase_naming: get('phase_naming') ?? defaults.phase_naming, project_code: get('project_code') ?? defaults.project_code, + subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout, model_overrides: parsed.model_overrides || null, agent_skills: parsed.agent_skills || {}, }; @@ -360,20 +362,44 @@ function normalizeMd(content) { const lines = text.split('\n'); const result = []; + // Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning + const fenceRegex = /^```/; + const insideFence = new Array(lines.length); + let fenceOpen = false; + for (let i = 0; i < lines.length; i++) { + if (fenceRegex.test(lines[i].trimEnd())) { + if (fenceOpen) { + // This is a closing fence — mark as NOT inside (it's the boundary) + insideFence[i] = false; + fenceOpen = false; + } else { + // This is an opening fence + insideFence[i] = false; + fenceOpen = true; + } + } else { + insideFence[i] = fenceOpen; + } + } + for (let i = 0; i < lines.length; i++) { const line = lines[i]; const prev = i > 0 ? lines[i - 1] : ''; const prevTrimmed = prev.trimEnd(); const trimmed = line.trimEnd(); + const isFenceLine = fenceRegex.test(trimmed); // MD022: Blank line before headings (skip first line and frontmatter delimiters) if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') { result.push(''); } - // MD031: Blank line before fenced code blocks - if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) { - result.push(''); + // MD031: Blank line before fenced code blocks (opening fences only) + if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) { + // Only add blank before opening fences (not closing ones) + if (i === 0 || !insideFence[i - 1]) { + result.push(''); + } } // MD032: Blank line before lists (- item, * item, N. item, - [ ] item) @@ -394,7 +420,7 @@ function normalizeMd(content) { } // MD031: Blank line after closing fenced code blocks - if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) { + if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) { const next = lines[i + 1]; if (next !== undefined && next.trimEnd() !== '') { result.push(''); @@ -424,24 +450,6 @@ function normalizeMd(content) { return text; } -/** Check if line index i is inside an already-open fenced code block */ -function isInsideFencedBlock(lines, i) { - let fenceCount = 0; - for (let j = 0; j < i; j++) { - if (/^```/.test(lines[j].trimEnd())) fenceCount++; - } - return fenceCount % 2 === 1; -} - -/** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */ -function isClosingFence(lines, i) { - let fenceCount = 0; - for (let j = 0; j <= i; j++) { - if (/^```/.test(lines[j].trimEnd())) fenceCount++; - } - return fenceCount % 2 === 0; -} - function execGit(cwd, args) { const result = spawnSync('git', args, { cwd, @@ -542,20 +550,43 @@ function withPlanningLock(cwd, fn) { } /** - * Get the .planning directory path, workstream-aware. - * When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var), - * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`. + * Get the .planning directory path, project- and workstream-aware. + * + * Resolution order: + * 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to + * `.planning/{project}/` — supports multi-project workspaces where several + * independent projects share a single `.planning/` root directory (e.g., + * an Obsidian vault or monorepo knowledge base used as a command center). + * 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`. + * 3. Otherwise returns `.planning/`. + * + * GSD_PROJECT and GSD_WORKSTREAM can be combined: + * `.planning/{project}/workstreams/{ws}/` * * @param {string} cwd - project root * @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var + * @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var */ -function planningDir(cwd, ws) { +function planningDir(cwd, ws, project) { + if (project === undefined) project = process.env.GSD_PROJECT || null; if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; - if (!ws) return path.join(cwd, '.planning'); - return path.join(cwd, '.planning', 'workstreams', ws); + + // Reject path separators and traversal components in project/workstream names + const BAD_SEGMENT = /[/\\]|\.\./; + if (project && BAD_SEGMENT.test(project)) { + throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`); + } + if (ws && BAD_SEGMENT.test(ws)) { + throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`); + } + + let base = path.join(cwd, '.planning'); + if (project) base = path.join(base, project); + if (ws) base = path.join(base, 'workstreams', ws); + return base; } -/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */ +/** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */ function planningRoot(cwd) { return path.join(cwd, '.planning'); } diff --git a/get-shit-done/bin/lib/frontmatter.cjs b/get-shit-done/bin/lib/frontmatter.cjs index 28690b0f5..3ea7f1334 100644 --- a/get-shit-done/bin/lib/frontmatter.cjs +++ b/get-shit-done/bin/lib/frontmatter.cjs @@ -252,6 +252,19 @@ function parseMustHavesBlock(content, blockName) { } if (current) items.push(current); + // Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue. + // This is a critical diagnostic: empty must_haves causes verification to silently degrade + // to Option C (LLM-derived truths) instead of checking documented contracts. + if (items.length === 0 && blockLines.length > 0) { + const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length; + if (nonEmptyLines > 0) { + process.stderr.write( + `[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` + + `Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n` + ); + } + } + return items; } diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 0cb2a078f..dd126eb42 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -780,6 +780,7 @@ function cmdInitMapCodebase(cwd, raw) { commit_docs: config.commit_docs, search_gitignored: config.search_gitignored, parallelization: config.parallelization, + subagent_timeout: config.subagent_timeout, // Paths codebase_dir: '.planning/codebase', diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 0a488b457..d6080375a 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error, readSubdirectories } = require('./core.cjs'); +const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs'); @@ -328,57 +328,62 @@ function cmdPhaseAdd(cwd, description, raw, customId) { error('ROADMAP.md not found'); } - const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); - const content = extractCurrentMilestone(rawContent, cwd); const slug = generateSlugInternal(description); - let newPhaseId; - let dirName; + // Wrap entire read-modify-write in lock to prevent concurrent corruption + const { newPhaseId, dirName } = withPlanningLock(cwd, () => { + const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); + const content = extractCurrentMilestone(rawContent, cwd); - // Optional project code prefix (e.g., 'CK' → 'CK-01-foundation') - const projectCode = config.project_code || ''; - const prefix = projectCode ? `${projectCode}-` : ''; + // Optional project code prefix (e.g., 'CK' → 'CK-01-foundation') + const projectCode = config.project_code || ''; + const prefix = projectCode ? `${projectCode}-` : ''; - if (customId || config.phase_naming === 'custom') { - // Custom phase naming: use provided ID or generate from description - newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-'); - if (!newPhaseId) error('--id required when phase_naming is "custom"'); - dirName = `${prefix}${newPhaseId}-${slug}`; - } else { - // Sequential mode: find highest integer phase number (in current milestone only) - const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; - let maxPhase = 0; - let m; - while ((m = phasePattern.exec(content)) !== null) { - const num = parseInt(m[1], 10); - if (num > maxPhase) maxPhase = num; + let _newPhaseId; + let _dirName; + + if (customId || config.phase_naming === 'custom') { + // Custom phase naming: use provided ID or generate from description + _newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-'); + if (!_newPhaseId) error('--id required when phase_naming is "custom"'); + _dirName = `${prefix}${_newPhaseId}-${slug}`; + } else { + // Sequential mode: find highest integer phase number (in current milestone only) + const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; + let maxPhase = 0; + let m; + while ((m = phasePattern.exec(content)) !== null) { + const num = parseInt(m[1], 10); + if (num > maxPhase) maxPhase = num; + } + + _newPhaseId = maxPhase + 1; + const paddedNum = String(_newPhaseId).padStart(2, '0'); + _dirName = `${prefix}${paddedNum}-${slug}`; } - newPhaseId = maxPhase + 1; - const paddedNum = String(newPhaseId).padStart(2, '0'); - dirName = `${prefix}${paddedNum}-${slug}`; - } + const dirPath = path.join(planningDir(cwd), 'phases', _dirName); - const dirPath = path.join(planningDir(cwd), 'phases', dirName); + // Create directory with .gitkeep so git tracks empty folders + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); - // Create directory with .gitkeep so git tracks empty folders - fs.mkdirSync(dirPath, { recursive: true }); - fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); + // Build phase entry + const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`; + const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_newPhaseId} to break down)\n`; - // Build phase entry - const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`; - const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseId} to break down)\n`; + // Find insertion point: before last "---" or at end + let updatedContent; + const lastSeparator = rawContent.lastIndexOf('\n---'); + if (lastSeparator > 0) { + updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator); + } else { + updatedContent = rawContent + phaseEntry; + } - // Find insertion point: before last "---" or at end - let updatedContent; - const lastSeparator = rawContent.lastIndexOf('\n---'); - if (lastSeparator > 0) { - updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator); - } else { - updatedContent = rawContent + phaseEntry; - } - - fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); + fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); + return { newPhaseId: _newPhaseId, dirName: _dirName }; + }); const result = { phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId), @@ -402,71 +407,75 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { error('ROADMAP.md not found'); } - const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); - const content = extractCurrentMilestone(rawContent, cwd); const slug = generateSlugInternal(description); - // Normalize input then strip leading zeros for flexible matching - const normalizedAfter = normalizePhaseName(afterPhase); - const unpadded = normalizedAfter.replace(/^0+/, ''); - const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); - const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); - if (!targetPattern.test(content)) { - error(`Phase ${afterPhase} not found in ROADMAP.md`); - } + // Wrap entire read-modify-write in lock to prevent concurrent corruption + const { decimalPhase, dirName } = withPlanningLock(cwd, () => { + const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); + const content = extractCurrentMilestone(rawContent, cwd); - // Calculate next decimal using existing logic - const phasesDir = path.join(planningDir(cwd), 'phases'); - const normalizedBase = normalizePhaseName(afterPhase); - let existingDecimals = []; - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`); - for (const dir of dirs) { - const dm = dir.match(decimalPattern); - if (dm) existingDecimals.push(parseInt(dm[1], 10)); + // Normalize input then strip leading zeros for flexible matching + const normalizedAfter = normalizePhaseName(afterPhase); + const unpadded = normalizedAfter.replace(/^0+/, ''); + const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); + const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); + if (!targetPattern.test(content)) { + error(`Phase ${afterPhase} not found in ROADMAP.md`); } - } catch { /* intentionally empty */ } - const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; - const decimalPhase = `${normalizedBase}.${nextDecimal}`; + // Calculate next decimal using existing logic + const phasesDir = path.join(planningDir(cwd), 'phases'); + const normalizedBase = normalizePhaseName(afterPhase); + let existingDecimals = []; - // Optional project code prefix - const config = loadConfig(cwd); - const projectCode = config.project_code || ''; - const prefix = projectCode ? `${projectCode}-` : ''; - const dirName = `${prefix}${decimalPhase}-${slug}`; - const dirPath = path.join(planningDir(cwd), 'phases', dirName); + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`); + for (const dir of dirs) { + const dm = dir.match(decimalPattern); + if (dm) existingDecimals.push(parseInt(dm[1], 10)); + } + } catch { /* intentionally empty */ } - // Create directory with .gitkeep so git tracks empty folders - fs.mkdirSync(dirPath, { recursive: true }); - fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); + const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; + const _decimalPhase = `${normalizedBase}.${nextDecimal}`; + // Optional project code prefix + const insertConfig = loadConfig(cwd); + const projectCode = insertConfig.project_code || ''; + const pfx = projectCode ? `${projectCode}-` : ''; + const _dirName = `${pfx}${_decimalPhase}-${slug}`; + const dirPath = path.join(planningDir(cwd), 'phases', _dirName); - // Build phase entry - const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`; + // Create directory with .gitkeep so git tracks empty folders + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); - // Insert after the target phase section - const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); - const headerMatch = rawContent.match(headerPattern); - if (!headerMatch) { - error(`Could not find Phase ${afterPhase} header`); - } + // Build phase entry + const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_decimalPhase} to break down)\n`; - const headerIdx = rawContent.indexOf(headerMatch[0]); - const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length); - const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i); + // Insert after the target phase section + const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); + const headerMatch = rawContent.match(headerPattern); + if (!headerMatch) { + error(`Could not find Phase ${afterPhase} header`); + } - let insertIdx; - if (nextPhaseMatch) { - insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index; - } else { - insertIdx = rawContent.length; - } + const headerIdx = rawContent.indexOf(headerMatch[0]); + const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length); + const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i); - const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx); - fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); + let insertIdx; + if (nextPhaseMatch) { + insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index; + } else { + insertIdx = rawContent.length; + } + + const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx); + fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); + return { decimalPhase: _decimalPhase, dirName: _dirName }; + }); const result = { phase_number: decimalPhase, @@ -554,29 +563,32 @@ function renameIntegerPhases(phasesDir, removedInt) { /** * Remove a phase section from ROADMAP.md and renumber all subsequent integer phases. */ -function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt) { - let content = fs.readFileSync(roadmapPath, 'utf-8'); - const escaped = escapeRegex(targetPhase); +function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) { + // Wrap entire read-modify-write in lock to prevent concurrent corruption + withPlanningLock(cwd, () => { + let content = fs.readFileSync(roadmapPath, 'utf-8'); + const escaped = escapeRegex(targetPhase); - content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), ''); - content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), ''); - content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), ''); + content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), ''); + content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), ''); + content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), ''); - if (!isDecimal) { - const MAX_PHASE = 99; - for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) { - const newNum = oldNum - 1; - const oldStr = String(oldNum), newStr = String(newNum); - const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0'); - content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`); - content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`); - content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`); - content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `); - content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`); + if (!isDecimal) { + const MAX_PHASE = 99; + for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) { + const newNum = oldNum - 1; + const oldStr = String(oldNum), newStr = String(newNum); + const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0'); + content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`); + content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`); + content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`); + content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `); + content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`); + } } - } - fs.writeFileSync(roadmapPath, content, 'utf-8'); + fs.writeFileSync(roadmapPath, content, 'utf-8'); + }); } function cmdPhaseRemove(cwd, targetPhase, options, raw) { @@ -617,7 +629,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { } catch { /* intentionally empty */ } // Update ROADMAP.md - updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10)); + updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd); // Update STATE.md phase count const statePath = path.join(planningDir(cwd), 'STATE.md'); @@ -686,98 +698,100 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { } } catch {} - // Update ROADMAP.md: mark phase complete + // Update ROADMAP.md and REQUIREMENTS.md atomically under lock if (fs.existsSync(roadmapPath)) { - let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + withPlanningLock(cwd, () => { + let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE) - const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`, - 'i' - ); - roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); - - // Progress table: update Status to Complete, add date (handles 4 or 5 column tables) - const phaseEscaped = escapeRegex(phaseNum); - const tableRowPattern = new RegExp( - `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, - 'im' - ); - roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => { - const cells = fullRow.split('|').slice(1, -1); - if (cells.length === 5) { - // 5-col: Phase | Milestone | Plans | Status | Completed - cells[2] = ` ${summaryCount}/${planCount} `; - cells[3] = ' Complete '; - cells[4] = ` ${today} `; - } else if (cells.length === 4) { - // 4-col: Phase | Plans | Status | Completed - cells[1] = ` ${summaryCount}/${planCount} `; - cells[2] = ' Complete '; - cells[3] = ` ${today} `; - } - return '|' + cells.join('|') + '|'; - }); - - // Update plan count in phase section - const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, - 'i' - ); - roadmapContent = replaceInCurrentMilestone( - roadmapContent, planCountPattern, - `$1${summaryCount}/${planCount} plans complete` - ); - - // Mark completed plan checkboxes (safety net for missed per-plan updates) - for (const summaryFile of phaseInfo.summaries) { - const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); - if (!planId) continue; - const planEscaped = escapeRegex(planId); - const planCheckboxPattern = new RegExp( - `(-\\s*\\[) (\\]\\s*${planEscaped})`, + // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE) + const checkboxPattern = new RegExp( + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`, 'i' ); - roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); - } + roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); - fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); + // Progress table: update Status to Complete, add date (handles 4 or 5 column tables) + const phaseEscaped = escapeRegex(phaseNum); + const tableRowPattern = new RegExp( + `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, + 'im' + ); + roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => { + const cells = fullRow.split('|').slice(1, -1); + if (cells.length === 5) { + // 5-col: Phase | Milestone | Plans | Status | Completed + cells[2] = ` ${summaryCount}/${planCount} `; + cells[3] = ' Complete '; + cells[4] = ` ${today} `; + } else if (cells.length === 4) { + // 4-col: Phase | Plans | Status | Completed + cells[1] = ` ${summaryCount}/${planCount} `; + cells[2] = ' Complete '; + cells[3] = ` ${today} `; + } + return '|' + cells.join('|') + '|'; + }); - // Update REQUIREMENTS.md traceability for this phase's requirements - const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md'); - if (fs.existsSync(reqPath)) { - // Extract the current phase section from roadmap (scoped to avoid cross-phase matching) - const phaseEsc = escapeRegex(phaseNum); - const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd); - const phaseSectionMatch = currentMilestoneRoadmap.match( - new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i') + // Update plan count in phase section + const planCountPattern = new RegExp( + `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + 'i' + ); + roadmapContent = replaceInCurrentMilestone( + roadmapContent, planCountPattern, + `$1${summaryCount}/${planCount} plans complete` ); - const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : ''; - const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i); - - if (reqMatch) { - const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean); - let reqContent = fs.readFileSync(reqPath, 'utf-8'); - - for (const reqId of reqIds) { - const reqEscaped = escapeRegex(reqId); - // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID** - reqContent = reqContent.replace( - new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), - '$1x$2' - ); - // Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete | - reqContent = reqContent.replace( - new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), - '$1 Complete $2' - ); - } - - fs.writeFileSync(reqPath, reqContent, 'utf-8'); - requirementsUpdated = true; + // Mark completed plan checkboxes (safety net for missed per-plan updates) + for (const summaryFile of phaseInfo.summaries) { + const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); + if (!planId) continue; + const planEscaped = escapeRegex(planId); + const planCheckboxPattern = new RegExp( + `(-\\s*\\[) (\\]\\s*${planEscaped})`, + 'i' + ); + roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); } - } + + fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); + + // Update REQUIREMENTS.md traceability for this phase's requirements + const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md'); + if (fs.existsSync(reqPath)) { + // Extract the current phase section from roadmap (scoped to avoid cross-phase matching) + const phaseEsc = escapeRegex(phaseNum); + const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd); + const phaseSectionMatch = currentMilestoneRoadmap.match( + new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i') + ); + + const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : ''; + const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i); + + if (reqMatch) { + const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean); + let reqContent = fs.readFileSync(reqPath, 'utf-8'); + + for (const reqId of reqIds) { + const reqEscaped = escapeRegex(reqId); + // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID** + reqContent = reqContent.replace( + new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), + '$1x$2' + ); + // Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete | + reqContent = reqContent.replace( + new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), + '$1 Complete $2' + ); + } + + fs.writeFileSync(reqPath, reqContent, 'utf-8'); + requirementsUpdated = true; + } + } + }); } // Find next phase — check both filesystem AND roadmap diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 693baf4f5..e817c53e3 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, normalizePhaseName, planningPaths, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs'); +const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs'); function cmdRoadmapGetPhase(cwd, phaseNum, raw) { const roadmapPath = planningPaths(cwd).roadmap; @@ -254,64 +254,67 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { return; } - let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - const phaseEscaped = escapeRegex(phaseNum); + // Wrap entire read-modify-write in lock to prevent concurrent corruption + withPlanningLock(cwd, () => { + let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); + const phaseEscaped = escapeRegex(phaseNum); - // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables) - const tableRowPattern = new RegExp( - `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, - 'im' - ); - const dateField = isComplete ? ` ${today} ` : ' '; - roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => { - const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split - if (cells.length === 5) { - // 5-col: Phase | Milestone | Plans | Status | Completed - cells[2] = ` ${summaryCount}/${planCount} `; - cells[3] = ` ${status.padEnd(11)}`; - cells[4] = dateField; - } else if (cells.length === 4) { - // 4-col: Phase | Plans | Status | Completed - cells[1] = ` ${summaryCount}/${planCount} `; - cells[2] = ` ${status.padEnd(11)}`; - cells[3] = dateField; + // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables) + const tableRowPattern = new RegExp( + `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, + 'im' + ); + const dateField = isComplete ? ` ${today} ` : ' '; + roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => { + const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split + if (cells.length === 5) { + // 5-col: Phase | Milestone | Plans | Status | Completed + cells[2] = ` ${summaryCount}/${planCount} `; + cells[3] = ` ${status.padEnd(11)}`; + cells[4] = dateField; + } else if (cells.length === 4) { + // 4-col: Phase | Plans | Status | Completed + cells[1] = ` ${summaryCount}/${planCount} `; + cells[2] = ` ${status.padEnd(11)}`; + cells[3] = dateField; + } + return '|' + cells.join('|') + '|'; + }); + + // Update plan count in phase detail section + const planCountPattern = new RegExp( + `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + 'i' + ); + const planCountText = isComplete + ? `${summaryCount}/${planCount} plans complete` + : `${summaryCount}/${planCount} plans executed`; + roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`); + + // If complete: check checkbox + if (isComplete) { + const checkboxPattern = new RegExp( + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, + 'i' + ); + roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); } - return '|' + cells.join('|') + '|'; + + // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:") + for (const summaryFile of phaseInfo.summaries) { + const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); + if (!planId) continue; + const planEscaped = escapeRegex(planId); + const planCheckboxPattern = new RegExp( + `(-\\s*\\[) (\\]\\s*${planEscaped})`, + 'i' + ); + roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); + } + + fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); }); - // Update plan count in phase detail section - const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, - 'i' - ); - const planCountText = isComplete - ? `${summaryCount}/${planCount} plans complete` - : `${summaryCount}/${planCount} plans executed`; - roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`); - - // If complete: check checkbox - if (isComplete) { - const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, - 'i' - ); - roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); - } - - // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:") - for (const summaryFile of phaseInfo.summaries) { - const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); - if (!planId) continue; - const planEscaped = escapeRegex(planId); - const planCheckboxPattern = new RegExp( - `(-\\s*\\[) (\\]\\s*${planEscaped})`, - 'i' - ); - roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); - } - - fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); - output({ updated: true, phase: phaseNum, diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 6fe9dfa09..2792f6e55 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -141,29 +141,28 @@ function cmdStatePatch(cwd, patches, raw) { const statePath = planningPaths(cwd).state; try { - let content = fs.readFileSync(statePath, 'utf-8'); const results = { updated: [], failed: [] }; - for (const [field, value] of Object.entries(patches)) { - const fieldEscaped = escapeRegex(field); - // Try **Field:** bold format first, then plain Field: format - const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); - const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im'); + // Use atomic read-modify-write to prevent lost updates from concurrent agents + readModifyWriteStateMd(statePath, (content) => { + for (const [field, value] of Object.entries(patches)) { + const fieldEscaped = escapeRegex(field); + // Try **Field:** bold format first, then plain Field: format + const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); + const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im'); - if (boldPattern.test(content)) { - content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`); - results.updated.push(field); - } else if (plainPattern.test(content)) { - content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`); - results.updated.push(field); - } else { - results.failed.push(field); + if (boldPattern.test(content)) { + content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`); + results.updated.push(field); + } else if (plainPattern.test(content)) { + content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`); + results.updated.push(field); + } else { + results.failed.push(field); + } } - } - - if (results.updated.length > 0) { - writeStateMd(statePath, content, cwd); - } + return content; + }, cwd); output(results, raw, results.updated.length > 0 ? 'true' : 'false'); } catch { @@ -236,6 +235,12 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) { result = stateReplaceField(content, fallback, value); if (result) return result; } + // Neither pattern matched — field may have been reformatted or removed. + // Log diagnostic so template drift is detected early rather than silently swallowed. + process.stderr.write( + `[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` + + `This may indicate STATE.md was externally modified or uses an unexpected format.\n` + ); return content; } @@ -780,6 +785,50 @@ function syncStateFrontmatter(content, cwd) { return `---\n${yamlStr}\n---\n\n${body}`; } +/** + * Acquire a lockfile for STATE.md operations. + * Returns the lock path for later release. + */ +function acquireStateLock(statePath) { + const lockPath = statePath + '.lock'; + const maxRetries = 10; + const retryDelay = 200; // ms + + for (let i = 0; i < maxRetries; i++) { + try { + const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY); + fs.writeSync(fd, String(process.pid)); + fs.closeSync(fd); + return lockPath; + } catch (err) { + if (err.code === 'EEXIST') { + try { + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs > 10000) { + fs.unlinkSync(lockPath); + continue; + } + } catch { /* lock was released between check — retry */ } + + if (i === maxRetries - 1) { + try { fs.unlinkSync(lockPath); } catch {} + return lockPath; + } + const jitter = Math.floor(Math.random() * 50); + const start = Date.now(); + while (Date.now() - start < retryDelay + jitter) { /* busy wait */ } + continue; + } + return lockPath; // non-EEXIST error — proceed without lock + } + } + return statePath + '.lock'; +} + +function releaseStateLock(lockPath) { + try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ } +} + /** * Write STATE.md with synchronized YAML frontmatter. * All STATE.md writes should use this instead of raw writeFileSync. @@ -788,48 +837,29 @@ function syncStateFrontmatter(content, cwd) { */ function writeStateMd(statePath, content, cwd) { const synced = syncStateFrontmatter(content, cwd); - const lockPath = statePath + '.lock'; - const maxRetries = 10; - const retryDelay = 200; // ms - - // Acquire lock (spin with backoff) - for (let i = 0; i < maxRetries; i++) { - try { - // O_EXCL fails if file already exists — atomic lock - const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY); - fs.writeSync(fd, String(process.pid)); - fs.closeSync(fd); - break; - } catch (err) { - if (err.code === 'EEXIST') { - // Check for stale lock (> 10s old) - try { - const stat = fs.statSync(lockPath); - if (Date.now() - stat.mtimeMs > 10000) { - fs.unlinkSync(lockPath); - continue; // retry immediately after clearing stale lock - } - } catch { /* lock was released between check — retry */ } - - if (i === maxRetries - 1) { - // Last resort: write anyway rather than losing data - try { fs.unlinkSync(lockPath); } catch {} - break; - } - // Spin-wait with small jitter - const jitter = Math.floor(Math.random() * 50); - const start = Date.now(); - while (Date.now() - start < retryDelay + jitter) { /* busy wait */ } - continue; - } - break; // non-EEXIST error — proceed without lock - } - } - + const lockPath = acquireStateLock(statePath); try { fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8'); } finally { - try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ } + releaseStateLock(lockPath); + } +} + +/** + * Atomic read-modify-write for STATE.md. + * Holds the lock across the entire read -> transform -> write cycle, + * preventing the lost-update problem where two agents read the same + * content and the second write clobbers the first. + */ +function readModifyWriteStateMd(statePath, transformFn, cwd) { + const lockPath = acquireStateLock(statePath); + try { + const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : ''; + const modified = transformFn(content); + const synced = syncStateFrontmatter(modified, cwd); + fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8'); + } finally { + releaseStateLock(lockPath); } } diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 8975f9f39..d05b68adf 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { safeReadFile, loadConfig, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error, checkAgentsInstalled } = require('./core.cjs'); +const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error, checkAgentsInstalled } = require('./core.cjs'); const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs'); const { writeStateMd } = require('./state.cjs'); @@ -757,6 +757,71 @@ function cmdValidateHealth(cwd, options, raw) { } } + // ─── Check 9: STATE.md / ROADMAP.md cross-validation ───────────────────── + if (fs.existsSync(statePath) && fs.existsSync(roadmapPath)) { + try { + const stateContent = fs.readFileSync(statePath, 'utf-8'); + const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8'); + + // Extract current phase from STATE.md + const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/i) || + stateContent.match(/Current Phase:\s*(\S+)/i); + if (currentPhaseMatch) { + const statePhase = currentPhaseMatch[1].replace(/^0+/, ''); + // Check if ROADMAP shows this phase as already complete + const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}[:\\s]`, 'i'); + if (phaseCheckboxRe.test(roadmapContentFull)) { + // STATE says "current" but ROADMAP says "complete" — divergence + const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i); + const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : ''; + if (statusVal !== 'complete' && statusVal !== 'done') { + addIssue('warning', 'W011', + `STATE.md says current phase is ${statePhase} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`, + 'Run /gsd:progress to re-derive current position, or manually update STATE.md'); + } + } + } + } catch { /* intentionally empty — cross-validation is advisory */ } + } + + // ─── Check 10: Config field validation ──────────────────────────────────── + if (fs.existsSync(configPath)) { + try { + const configRaw = fs.readFileSync(configPath, 'utf-8'); + const configParsed = JSON.parse(configRaw); + + // Validate branching_strategy + const validStrategies = ['none', 'phase', 'milestone']; + if (configParsed.branching_strategy && !validStrategies.includes(configParsed.branching_strategy)) { + addIssue('warning', 'W012', + `config.json: invalid branching_strategy "${configParsed.branching_strategy}"`, + `Valid values: ${validStrategies.join(', ')}`); + } + + // Validate context_window is a positive integer + if (configParsed.context_window !== undefined) { + const cw = configParsed.context_window; + if (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw)) { + addIssue('warning', 'W013', + `config.json: context_window should be a positive integer, got "${cw}"`, + 'Set to 200000 (default) or 1000000 (for 1M models)'); + } + } + + // Validate branch templates have required placeholders + if (configParsed.phase_branch_template && !configParsed.phase_branch_template.includes('{phase}')) { + addIssue('warning', 'W014', + 'config.json: phase_branch_template missing {phase} placeholder', + 'Template must include {phase} for phase number substitution'); + } + if (configParsed.milestone_branch_template && !configParsed.milestone_branch_template.includes('{milestone}')) { + addIssue('warning', 'W015', + 'config.json: milestone_branch_template missing {milestone} placeholder', + 'Template must include {milestone} for version substitution'); + } + } catch { /* parse error already caught in Check 5 */ } + } + // ─── Perform repairs if requested ───────────────────────────────────────── const repairActions = []; if (options.repair && repairs.length > 0) { diff --git a/get-shit-done/references/planning-config.md b/get-shit-done/references/planning-config.md index 20ee56f37..e598f6214 100644 --- a/get-shit-done/references/planning-config.md +++ b/get-shit-done/references/planning-config.md @@ -10,6 +10,7 @@ Configuration options for `.planning/` directory behavior. }, "git": { "branching_strategy": "none", + "base_branch": null, "phase_branch_template": "gsd/phase-{phase}-{slug}", "milestone_branch_template": "gsd/{milestone}-{slug}", "quick_branch_template": null @@ -21,10 +22,12 @@ Configuration options for `.planning/` directory behavior. | `commit_docs` | `true` | Whether to commit planning artifacts to git | | `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches | | `git.branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` | +| `git.base_branch` | `null` (auto-detect) | Target branch for PRs and merges (e.g. `"master"`, `"develop"`). When `null`, auto-detects from `git symbolic-ref refs/remotes/origin/HEAD`, falling back to `"main"`. | | `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy | | `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy | | `git.quick_branch_template` | `null` | Optional branch template for quick-task runs | | `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. | +| `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). | diff --git a/get-shit-done/workflows/autonomous.md b/get-shit-done/workflows/autonomous.md index 0a50a677a..d007a258c 100644 --- a/get-shit-done/workflows/autonomous.md +++ b/get-shit-done/workflows/autonomous.md @@ -16,7 +16,7 @@ Read all files referenced by the invoking prompt's execution_context before star ## 1. Initialize -Parse `$ARGUMENTS` for `--from N` and `--only N` flags: +Parse `$ARGUMENTS` for `--from N`, `--only N`, and `--interactive` flags: ```bash FROM_PHASE="" @@ -29,10 +29,17 @@ if echo "$ARGUMENTS" | grep -qE '\-\-only\s+[0-9]'; then ONLY_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-only\s+[0-9]+\.?[0-9]*' | awk '{print $2}') FROM_PHASE="$ONLY_PHASE" fi + +INTERACTIVE="" +if echo "$ARGUMENTS" | grep -q '\-\-interactive'; then + INTERACTIVE="true" +fi ``` When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies. +When `--interactive` is set, discuss runs inline with questions (not auto-answered), while plan and execute are dispatched as background agents. This keeps the main context lean — only discuss conversations accumulate — while preserving user input on all design decisions. + Bootstrap via milestone-level init: ```bash @@ -58,6 +65,7 @@ Display startup banner: If `ONLY_PHASE` is set, display: `Single phase mode: Phase ${ONLY_PHASE}` Else if `FROM_PHASE` is set, display: `Starting from phase ${FROM_PHASE}` +If `INTERACTIVE` is set, display: `Mode: Interactive (discuss inline, plan+execute in background)` @@ -230,18 +238,26 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${PADDED_PHASE Proceed to 3b. -**If SKIP_DISCUSS is `false` (or unset):** Execute the smart_discuss step for this phase. +**If SKIP_DISCUSS is `false` (or unset):** **IMPORTANT — Discuss must be single-pass in autonomous mode.** The discuss step in `--auto` mode MUST NOT loop. If CONTEXT.md already exists after discuss completes, do NOT re-invoke discuss for the same phase. The `has_context` check below is authoritative — once true, discuss is done for this phase regardless of perceived "gaps" in the context file. -After smart_discuss completes, verify context was written: +**If `INTERACTIVE` is set:** Run the standard discuss-phase skill inline (asks interactive questions, waits for user answers). This preserves user input on all design decisions while keeping plan+execute out of the main context: + +``` +Skill(skill="gsd:discuss-phase", args="${PHASE_NUM}") +``` + +**If `INTERACTIVE` is NOT set:** Execute the smart_discuss step for this phase (batch table proposals, auto-optimized). + +After discuss completes (either mode), 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." +Check `has_context`. If false → go to handle_blocker: "Discuss for phase ${PHASE_NUM} did not produce CONTEXT.md." **3a.5. UI Design Contract (Frontend Phases)** @@ -284,6 +300,20 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1) **3b. Plan** +**If `INTERACTIVE` is set:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4). + +``` +Agent( + description="Plan phase ${PHASE_NUM}: ${PHASE_NAME}", + run_in_background=true, + prompt="Run plan-phase for phase ${PHASE_NUM}: Skill(skill=\"gsd:plan-phase\", args=\"${PHASE_NUM}\")" +) +``` + +Store the agent task_id. After discuss for the next phase completes (or if no next phase), wait for the plan agent to finish before proceeding to execute. + +**If `INTERACTIVE` is NOT set (default):** Run plan inline as before. + ``` Skill(skill="gsd:plan-phase", args="${PHASE_NUM}") ``` @@ -292,13 +322,29 @@ Verify plan produced output — re-run `init phase-op` and check `has_plans`. If **3c. Execute** +**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already), verify plans exist, then dispatch execute as a background agent: + +``` +Agent( + description="Execute phase ${PHASE_NUM}: ${PHASE_NAME}", + run_in_background=true, + prompt="Run execute-phase for phase ${PHASE_NUM}: Skill(skill=\"gsd:execute-phase\", args=\"${PHASE_NUM} --no-transition\")" +) +``` + +Store the agent task_id. The workflow can now start discussing the next phase while this phase executes in the background. Before starting post-execution routing for this phase, wait for the execute agent to complete. + +**If `INTERACTIVE` is NOT set (default):** Run execute inline as before. + ``` Skill(skill="gsd:execute-phase", args="${PHASE_NUM} --no-transition") ``` **3d. Post-Execution Routing** -After execute-phase returns, read the verification result: +**If `INTERACTIVE` is set:** Wait for the execute agent to complete before reading verification results. + +After execute-phase returns (or the execute agent completes), read the verification result: ```bash VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ') @@ -733,6 +779,13 @@ Check for blockers in the Blockers/Concerns section. If blockers are found, go t If incomplete phases remain: proceed to next phase, loop back to execute_phase. +**Interactive mode overlap:** When `INTERACTIVE` is set, the iterate step enables pipeline parallelism: +1. After discuss completes for Phase N, dispatch plan+execute as background agents +2. Immediately start discuss for Phase N+1 (the next incomplete phase) while Phase N builds +3. Before starting plan for Phase N+1, wait for Phase N's execute agent to complete and handle its post-execution routing (verification, gap closure, etc.) + +This means the user is always answering discuss questions (lightweight, interactive) while the heavy work (planning, code generation) runs in the background. The main context only accumulates discuss conversations — plan and execute contexts are isolated in their agents. + If all phases complete, proceed to lifecycle step. @@ -935,4 +988,10 @@ When any phase operation fails or a blocker is detected, present 3 options via A - [ ] `--only N` exits cleanly after single phase completes - [ ] `--only N` on already-complete phase exits with message - [ ] `--only N` handle_blocker resume message uses --only flag +- [ ] `--interactive` runs discuss inline via gsd:discuss-phase (asks questions, waits for user) +- [ ] `--interactive` dispatches plan and execute as background agents (context isolation) +- [ ] `--interactive` enables pipeline parallelism: discuss Phase N+1 while Phase N builds +- [ ] `--interactive` main context only accumulates discuss conversations (lean) +- [ ] `--interactive` waits for background agents before post-execution routing +- [ ] `--interactive` compatible with `--only` and `--from` flags diff --git a/get-shit-done/workflows/complete-milestone.md b/get-shit-done/workflows/complete-milestone.md index e95e7cb61..302d26494 100644 --- a/get-shit-done/workflows/complete-milestone.md +++ b/get-shit-done/workflows/complete-milestone.md @@ -537,6 +537,15 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi Extract `branching_strategy`, `phase_branch_template`, `milestone_branch_template`, and `commit_docs` from init JSON. +Detect base branch: +```bash +BASE_BRANCH=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get git.base_branch 2>/dev/null || echo "") +if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then + BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||') + BASE_BRANCH="${BASE_BRANCH:-main}" +fi +``` + **If "none":** Skip to git_tag. **For "phase" strategy:** @@ -575,7 +584,7 @@ AskUserQuestion with options: Squash merge (Recommended), Merge with history, De ```bash CURRENT_BRANCH=$(git branch --show-current) -git checkout main +git checkout ${BASE_BRANCH} if [ "$BRANCHING_STRATEGY" = "phase" ]; then for branch in $PHASE_BRANCHES; do @@ -604,7 +613,7 @@ git checkout "$CURRENT_BRANCH" ```bash CURRENT_BRANCH=$(git branch --show-current) -git checkout main +git checkout ${BASE_BRANCH} if [ "$BRANCHING_STRATEGY" = "phase" ]; then for branch in $PHASE_BRANCHES; do diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 2db0e2f29..5077b9b0e 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -76,6 +76,17 @@ USE_WORKTREES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get When `USE_WORKTREES` is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees. +Read context window size for adaptive prompt enrichment: + +```bash +CONTEXT_WINDOW=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get context_window 2>/dev/null || echo "200000") +``` + +When `CONTEXT_WINDOW >= 500000` (1M-class models), subagent prompts include richer context: +- Executor agents receive prior wave SUMMARY.md files and the phase CONTEXT.md/RESEARCH.md +- Verifier agents receive all PLAN.md, SUMMARY.md, CONTEXT.md files plus REQUIREMENTS.md +- This enables cross-phase awareness and history-aware verification + **If `phase_found` is false:** Error — phase directory not found. **If `plan_count` is 0:** Error — no plans found in phase. **If `state_exists` is false but `.planning/` exists:** Offer reconstruct or continue. @@ -236,6 +247,7 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT ``` Task( subagent_type="gsd-executor", + description="Execute plan {plan_number} of phase {phase_number}", model="{executor_model}", isolation="worktree", prompt=" @@ -265,6 +277,11 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT - .planning/PROJECT.md (Project context — core value, requirements, evolution rules) - .planning/STATE.md (State) - .planning/config.json (Config, if exists) + ${CONTEXT_WINDOW >= 500000 ? ` + - ${phase_dir}/*-CONTEXT.md (User decisions from discuss-phase — honors locked choices) + - ${phase_dir}/*-RESEARCH.md (Technical research — pitfalls and patterns to follow) + - ${prior_wave_summaries} (SUMMARY.md files from earlier waves in this phase — what was already built) + ` : ''} - ./CLAUDE.md (Project instructions, if exists — follow project-specific guidelines and coding conventions) - .claude/skills/ or .agents/skills/ (Project skills, if either exists — list skills, read SKILL.md for each, follow relevant rules during implementation) @@ -732,6 +749,7 @@ VERIFIER_SKILLS=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-ski ``` Task( + description="Verify phase {phase_number} goal achievement", prompt="Verify phase {phase_number} goal achievement. Phase directory: {phase_dir} Phase goal: {goal from ROADMAP.md} @@ -739,6 +757,18 @@ Phase requirement IDs: {phase_req_ids} Check must_haves against actual codebase. Cross-reference requirement IDs from PLAN frontmatter against REQUIREMENTS.md — every ID MUST be accounted for. Create VERIFICATION.md. + + +Read these files before verification: +- {phase_dir}/*-PLAN.md (All plans — understand intent, check must_haves) +- {phase_dir}/*-SUMMARY.md (All summaries — cross-reference claimed vs actual) +- .planning/REQUIREMENTS.md (Requirement traceability) +${CONTEXT_WINDOW >= 500000 ? `- {phase_dir}/*-CONTEXT.md (User decisions — verify they were honored) +- {phase_dir}/*-RESEARCH.md (Known pitfalls — check for traps) +- Prior VERIFICATION.md files from earlier phases (regression check) +` : ''} + + ${VERIFIER_SKILLS}", subagent_type="gsd-verifier", model="{verifier_model}" diff --git a/get-shit-done/workflows/map-codebase.md b/get-shit-done/workflows/map-codebase.md index 270ad090a..8c28c58cb 100644 --- a/get-shit-done/workflows/map-codebase.md +++ b/get-shit-done/workflows/map-codebase.md @@ -36,7 +36,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS_MAPPER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-codebase-mapper 2>/dev/null) ``` -Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`. +Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`, `subagent_timeout`. @@ -199,9 +199,11 @@ Wait for all 4 agents to complete using TaskOutput tool. TaskOutput tool: task_id: "{task_id from Agent result}" block: true - timeout: 300000 + timeout: {subagent_timeout from init context, default 300000} ``` +> The timeout is configurable via `workflow.subagent_timeout` in `.planning/config.json` (milliseconds). Default: 300000 (5 minutes). Increase for large codebases or slower models. + Call TaskOutput for all 4 agents in parallel (single message with 4 TaskOutput calls). Once all TaskOutput calls return, read each agent's output file to collect confirmations. diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index 361b676d8..3fd5d7f88 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -27,8 +27,11 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS_RESEARCHER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-researcher 2>/dev/null) AGENT_SKILLS_PLANNER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-planner 2>/dev/null) AGENT_SKILLS_CHECKER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-checker 2>/dev/null) +CONTEXT_WINDOW=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get context_window 2>/dev/null || echo "200000") ``` +When `CONTEXT_WINDOW >= 500000`, the planner prompt includes prior phase CONTEXT.md files so cross-phase decisions are consistent (e.g., "use library X for all data fetching" from Phase 2 is visible to Phase 5's planner). + Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`, `phase_req_ids`. **File paths (for blocks):** `state_path`, `roadmap_path`, `requirements_path`, `context_path`, `research_path`, `verification_path`, `uat_path`, `reviews_path`. These are null if files don't exist. @@ -576,6 +579,11 @@ Planner prompt: - {uat_path} (UAT Gaps - if --gaps) - {reviews_path} (Cross-AI Review Feedback - if --reviews) - {UI_SPEC_PATH} (UI Design Contract — visual/interaction specs, if exists) +${CONTEXT_WINDOW >= 500000 ? ` +**Cross-phase context (1M model enrichment):** +- Prior phase CONTEXT.md files (locked decisions from earlier phases — maintain consistency) +- Prior phase SUMMARY.md files (what was actually built — reuse patterns, avoid duplication) +` : ''} ${AGENT_SKILLS_PLANNER} diff --git a/get-shit-done/workflows/review.md b/get-shit-done/workflows/review.md index 8b8a85be3..598ea6091 100644 --- a/get-shit-done/workflows/review.md +++ b/get-shit-done/workflows/review.md @@ -19,6 +19,7 @@ command -v gemini >/dev/null 2>&1 && echo "gemini:available" || echo "gemini:mis command -v claude >/dev/null 2>&1 && echo "claude:available" || echo "claude:missing" command -v codex >/dev/null 2>&1 && echo "codex:available" || echo "codex:missing" command -v coderabbit >/dev/null 2>&1 && echo "coderabbit:available" || echo "coderabbit:missing" +command -v opencode >/dev/null 2>&1 && echo "opencode:available" || echo "opencode:missing" ``` Parse flags from `$ARGUMENTS`: @@ -26,6 +27,7 @@ Parse flags from `$ARGUMENTS`: - `--claude` → include Claude - `--codex` → include Codex - `--coderabbit` → include CodeRabbit +- `--opencode` → include OpenCode - `--all` → include all available - No flags → include all available @@ -35,6 +37,7 @@ No external AI CLIs found. Install at least one: - gemini: https://github.com/google-gemini/gemini-cli - codex: https://github.com/openai/codex - claude: https://github.com/anthropics/claude-code +- opencode: https://opencode.ai (leverages GitHub Copilot subscription models) Then run /gsd:review again. ``` @@ -141,6 +144,14 @@ Note: CodeRabbit reviews the current git diff/working tree — it does not accep coderabbit review --prompt-only 2>/dev/null > /tmp/gsd-review-coderabbit-{phase}.md ``` +**OpenCode (via GitHub Copilot):** +```bash +cat /tmp/gsd-review-prompt-{phase}.md | opencode run - 2>/dev/null > /tmp/gsd-review-opencode-{phase}.md +if [ ! -s /tmp/gsd-review-opencode-{phase}.md ]; then + echo "OpenCode review failed or returned empty output." > /tmp/gsd-review-opencode-{phase}.md +fi +``` + If a CLI fails, log the error and continue with remaining CLIs. Display progress: @@ -160,7 +171,7 @@ Combine all review responses into `{phase_dir}/{padded_phase}-REVIEWS.md`: ```markdown --- phase: {N} -reviewers: [gemini, claude, codex, coderabbit] +reviewers: [gemini, claude, codex, coderabbit, opencode] reviewed_at: {ISO timestamp} plans_reviewed: [{list of PLAN.md files}] --- @@ -191,6 +202,12 @@ plans_reviewed: [{list of PLAN.md files}] --- +## OpenCode Review + +{opencode review content} + +--- + ## Consensus Summary {synthesize common concerns across all reviewers} diff --git a/get-shit-done/workflows/ship.md b/get-shit-done/workflows/ship.md index 3c29de1ff..49ee35ccd 100644 --- a/get-shit-done/workflows/ship.md +++ b/get-shit-done/workflows/ship.md @@ -24,6 +24,15 @@ CONFIG=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state load) ``` Extract: `branching_strategy`, `branch_name`. + +Detect base branch for PRs and merges: +```bash +BASE_BRANCH=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get git.base_branch 2>/dev/null || echo "") +if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then + BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||') + BASE_BRANCH="${BASE_BRANCH:-main}" +fi +``` @@ -46,7 +55,7 @@ Verify the work is ready to ship: ```bash CURRENT_BRANCH=$(git branch --show-current) ``` - If on `main`/`master`: warn — should be on a feature branch. + If on `${BASE_BRANCH}`: warn — should be on a feature branch. If branching_strategy is `none`: offer to create a branch now. 4. **Remote configured?** @@ -74,7 +83,7 @@ If push fails (e.g., no upstream): set upstream: git push --set-upstream origin ${CURRENT_BRANCH} 2>&1 ``` -Report: "Pushed `{branch}` to origin ({commit_count} commits ahead of main)" +Report: "Pushed `{branch}` to origin ({commit_count} commits ahead of ${BASE_BRANCH})" @@ -141,7 +150,7 @@ Create the PR using the generated body: gh pr create \ --title "Phase ${PHASE_NUMBER}: ${PHASE_NAME}" \ --body "${PR_BODY}" \ - --base main + --base ${BASE_BRANCH} ``` If `--draft` flag was passed: add `--draft`. @@ -194,7 +203,7 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${padded_phase ## ✓ Phase {X}: {Name} — Shipped PR: #{number} ({url}) -Branch: {branch} → main +Branch: {branch} → ${BASE_BRANCH} Commits: {count} Verification: ✓ Passed Requirements: {N} REQ-IDs addressed diff --git a/hooks/gsd-phase-boundary.sh b/hooks/gsd-phase-boundary.sh new file mode 100755 index 000000000..6b0c39f07 --- /dev/null +++ b/hooks/gsd-phase-boundary.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# gsd-phase-boundary.sh — PostToolUse hook: detect .planning/ file writes +# Outputs a reminder when planning files are modified outside normal workflow. +# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency). +# +# OPT-IN: This hook is a no-op unless config.json has hooks.community: true. +# Enable with: "hooks": { "community": true } in .planning/config.json + +# Check opt-in config — exit silently if not enabled +if [ -f .planning/config.json ]; then + ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null) + if [ "$ENABLED" != "1" ]; then exit 0; fi +else + exit 0 +fi + +INPUT=$(cat) + +# Extract file_path from JSON using Node (handles escaping correctly) +FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{}})" 2>/dev/null) + +if [[ "$FILE" == *.planning/* ]] || [[ "$FILE" == .planning/* ]]; then + echo ".planning/ file modified: $FILE" + echo "Check: Should STATE.md be updated to reflect this change?" +fi + +exit 0 diff --git a/hooks/gsd-session-state.sh b/hooks/gsd-session-state.sh new file mode 100755 index 000000000..57ebe26cb --- /dev/null +++ b/hooks/gsd-session-state.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# gsd-session-state.sh — SessionStart hook: inject project state reminder +# Outputs STATE.md head on every session start for orientation. +# +# OPT-IN: This hook is a no-op unless config.json has hooks.community: true. +# Enable with: "hooks": { "community": true } in .planning/config.json + +# Check opt-in config — exit silently if not enabled +if [ -f .planning/config.json ]; then + ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null) + if [ "$ENABLED" != "1" ]; then exit 0; fi +else + exit 0 +fi + +echo '## Project State Reminder' +echo '' + +if [ -f .planning/STATE.md ]; then + echo 'STATE.md exists - check for blockers and current phase.' + head -20 .planning/STATE.md +else + echo 'No .planning/ found - suggest /gsd:new-project if starting new work.' +fi + +echo '' + +if [ -f .planning/config.json ]; then + MODE=$(grep -o '"mode"[[:space:]]*:[[:space:]]*"[^"]*"' .planning/config.json 2>/dev/null || echo '"mode": "unknown"') + echo "Config: $MODE" +fi + +exit 0 diff --git a/hooks/gsd-validate-commit.sh b/hooks/gsd-validate-commit.sh new file mode 100755 index 000000000..fd2dd38a5 --- /dev/null +++ b/hooks/gsd-validate-commit.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format +# Blocks git commit commands with non-conforming messages (exit 2). +# Allows conforming messages and all non-commit commands (exit 0). +# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency). +# +# OPT-IN: This hook is a no-op unless config.json has hooks.community: true. +# Enable with: "hooks": { "community": true } in .planning/config.json + +# Check opt-in config — exit silently if not enabled +if [ -f .planning/config.json ]; then + ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null) + if [ "$ENABLED" != "1" ]; then exit 0; fi +else + exit 0 +fi + +INPUT=$(cat) + +# Extract command from JSON using Node (handles escaping correctly, no jq needed) +CMD=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{}})" 2>/dev/null) + +# Only check git commit commands +if [[ "$CMD" =~ ^git[[:space:]]+commit ]]; then + # Extract message from -m flag + MSG="" + if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then + MSG="${BASH_REMATCH[1]}" + elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then + MSG="${BASH_REMATCH[1]}" + fi + + if [ -n "$MSG" ]; then + SUBJECT=$(echo "$MSG" | head -1) + # Validate Conventional Commits format + if ! [[ "$SUBJECT" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?:[[:space:]].+ ]]; then + echo '{"decision": "block", "reason": "Commit message must follow Conventional Commits: (): . Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}' + exit 2 + fi + if [ ${#SUBJECT} -gt 72 ]; then + echo '{"decision": "block", "reason": "Commit subject must be 72 characters or less."}' + exit 2 + fi + fi +fi + +exit 0 diff --git a/tests/augment-conversion.test.cjs b/tests/augment-conversion.test.cjs new file mode 100644 index 000000000..7f810b323 --- /dev/null +++ b/tests/augment-conversion.test.cjs @@ -0,0 +1,171 @@ +/** + * Augment conversion regression tests. + * + * Ensures Augment frontmatter names are emitted as plain identifiers + * (without surrounding quotes), so Augment does not treat quotes as + * literal parts of skill/subagent names. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert'); + +const { + convertClaudeCommandToAugmentSkill, + convertClaudeAgentToAugmentAgent, + convertClaudeToAugmentMarkdown, +} = require('../bin/install.js'); + +describe('convertClaudeCommandToAugmentSkill', () => { + test('writes unquoted Augment skill name in frontmatter', () => { + const input = `--- +name: quick +description: Execute a quick task +--- + + +Test body + +`; + + const result = convertClaudeCommandToAugmentSkill(input, 'gsd-quick'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + + assert.ok(nameMatch, 'frontmatter contains name field'); + assert.strictEqual(nameMatch[1], 'gsd-quick', 'skill name is plain scalar'); + assert.ok(!result.includes('name: "gsd-quick"'), 'quoted skill name is not emitted'); + }); + + test('preserves slash for slash commands in markdown body', () => { + const input = `--- +name: gsd:plan-phase +description: Plan a phase +--- + +Next: +/gsd:execute-phase 17 +/gsd-help +gsd:progress +`; + + const result = convertClaudeCommandToAugmentSkill(input, 'gsd-plan-phase'); + // Slash commands: /gsd:execute-phase -> /gsd-execute-phase + assert.ok(result.includes('/gsd-execute-phase 17'), 'slash command gsd: -> gsd-'); + assert.ok(result.includes('/gsd-help'), '/gsd-help preserved'); + assert.ok(result.includes('gsd-progress'), 'bare gsd: -> gsd-'); + }); + + test('includes augment_skill_adapter block', () => { + const input = `--- +name: test +description: A test skill +--- + +Body content. +`; + + const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test'); + assert.ok(result.includes(''), 'adapter header present'); + assert.ok(result.includes(''), 'adapter footer present'); + assert.ok(result.includes('launch-process'), 'launch-process tool mentioned'); + assert.ok(result.includes('str-replace-editor'), 'str-replace-editor tool mentioned'); + }); + + test('converts tool names to Augment format', () => { + const input = `--- +name: test +description: Test +--- + +Use Bash() to run commands. +Use Edit() to modify files. +Use Read() to view files. +`; + + const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test'); + assert.ok(result.includes('launch-process('), 'Bash converted to launch-process'); + assert.ok(result.includes('str-replace-editor('), 'Edit converted to str-replace-editor'); + assert.ok(result.includes('view('), 'Read converted to view'); + }); +}); + +describe('convertClaudeAgentToAugmentAgent', () => { + test('converts agent frontmatter with unquoted name', () => { + const input = `--- +name: gsd-bugfix +description: "Fix bugs automatically" +tools: Read, Write, Edit, Bash +color: green +--- + + +You are a bug fixer. + +`; + + const result = convertClaudeAgentToAugmentAgent(input); + const nameMatch = result.match(/^name:\s*(.+)$/m); + + assert.ok(nameMatch, 'frontmatter contains name field'); + assert.strictEqual(nameMatch[1], 'gsd-bugfix', 'agent name is plain scalar'); + assert.ok(!result.includes('name: "gsd-bugfix"'), 'quoted agent name is not emitted'); + }); + + test('removes color and skills from frontmatter', () => { + const input = `--- +name: gsd-test +description: Test agent +color: blue +skills: + - some-skill +tools: Read, Write +--- + + +Test role + +`; + + const result = convertClaudeAgentToAugmentAgent(input); + + assert.ok(result.includes('name: gsd-test'), 'name preserved'); + assert.ok(result.includes('description:'), 'description preserved'); + assert.ok(!result.includes('color:'), 'color removed'); + assert.ok(!result.includes('skills:'), 'skills removed'); + }); + + test('replaces CLAUDE.md with .augment/rules/', () => { + const input = `--- +name: gsd-test +description: Test +--- + +See CLAUDE.md for details. +`; + + const result = convertClaudeAgentToAugmentAgent(input); + assert.ok(result.includes('.augment/rules/'), 'CLAUDE.md replaced with .augment/rules/'); + assert.ok(!result.includes('CLAUDE.md'), 'CLAUDE.md reference removed'); + }); +}); + +describe('convertClaudeToAugmentMarkdown', () => { + test('replaces Claude Code with Augment', () => { + const input = 'Use Claude Code tools for this task.'; + const result = convertClaudeToAugmentMarkdown(input); + assert.strictEqual(result, 'Use Augment tools for this task.'); + }); + + test('replaces .claude/skills/ with .augment/skills/', () => { + const input = 'Check .claude/skills/ for more info.'; + const result = convertClaudeToAugmentMarkdown(input); + assert.strictEqual(result, 'Check .augment/skills/ for more info.'); + }); + + test('normalizes gsd: to gsd-', () => { + const input = 'Run /gsd:new-project for new projects.'; + const result = convertClaudeToAugmentMarkdown(input); + assert.strictEqual(result, 'Run /gsd-new-project for new projects.'); + }); +}); \ No newline at end of file diff --git a/tests/autonomous-interactive.test.cjs b/tests/autonomous-interactive.test.cjs new file mode 100644 index 000000000..c5d6ba902 --- /dev/null +++ b/tests/autonomous-interactive.test.cjs @@ -0,0 +1,84 @@ +/** + * GSD Tools Tests - autonomous --interactive flag + * + * Validates that the autonomous workflow and command definition + * correctly document and support the --interactive flag. + * + * Closes: #1413 + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +describe('autonomous --interactive flag (#1413)', () => { + const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'autonomous.md'); + const commandPath = path.join(__dirname, '..', 'commands', 'gsd', 'autonomous.md'); + + test('command definition includes --interactive in argument-hint', () => { + const content = fs.readFileSync(commandPath, 'utf8'); + assert.ok(content.includes('--interactive'), 'command should document --interactive flag'); + assert.ok(content.includes('argument-hint:') && content.includes('--interactive'), + 'argument-hint should include --interactive'); + }); + + test('command definition describes interactive mode behavior', () => { + const content = fs.readFileSync(commandPath, 'utf8'); + assert.ok(content.includes('discuss') && content.includes('inline'), + 'command should describe discuss running inline'); + assert.ok(content.includes('background'), + 'command should mention background agents for plan+execute'); + }); + + test('workflow parses --interactive flag', () => { + const content = fs.readFileSync(workflowPath, 'utf8'); + assert.ok(content.includes("--interactive") && content.includes('INTERACTIVE'), + 'workflow should parse --interactive into INTERACTIVE variable'); + }); + + test('workflow uses discuss-phase skill in interactive mode', () => { + const content = fs.readFileSync(workflowPath, 'utf8'); + assert.ok( + content.includes('gsd:discuss-phase') && content.includes('INTERACTIVE'), + 'workflow should invoke gsd:discuss-phase when INTERACTIVE is set' + ); + }); + + test('workflow dispatches plan as background agent in interactive mode', () => { + const content = fs.readFileSync(workflowPath, 'utf8'); + // Should have Agent() with run_in_background for plan + assert.ok( + content.includes('run_in_background') && content.includes('plan-phase'), + 'workflow should dispatch plan-phase as background agent in interactive mode' + ); + }); + + test('workflow dispatches execute as background agent in interactive mode', () => { + const content = fs.readFileSync(workflowPath, 'utf8'); + assert.ok( + content.includes('run_in_background') && content.includes('execute-phase'), + 'workflow should dispatch execute-phase as background agent in interactive mode' + ); + }); + + test('workflow describes pipeline parallelism in interactive mode', () => { + const content = fs.readFileSync(workflowPath, 'utf8'); + assert.ok( + content.includes('pipeline parallelism') || content.includes('Phase N+1'), + 'workflow should describe overlapping discuss/execute between phases' + ); + }); + + test('success criteria include --interactive requirements', () => { + const content = fs.readFileSync(workflowPath, 'utf8'); + const criteriaMatch = content.match(/([\s\S]*?)<\/success_criteria>/); + const criteria = criteriaMatch ? criteriaMatch[1] : ''; + assert.ok(criteria.includes('--interactive'), + 'success criteria should include --interactive requirements'); + assert.ok(criteria.includes('discuss inline'), + 'success criteria should mention discuss inline'); + assert.ok(criteria.includes('background agents'), + 'success criteria should mention background agents'); + }); +}); diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 53633a7b7..078fd30bd 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -1694,3 +1694,60 @@ describe('stats command', () => { assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// check-commit command (#1395) +// ───────────────────────────────────────────────────────────────────────────── + +describe('check-commit command', () => { + const { createTempGitProject } = require('./helpers.cjs'); + let tmpDir; + + beforeEach(() => { + tmpDir = createTempGitProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('allows commit when commit_docs is true', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: true }) + ); + const result = runGsdTools('check-commit', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.allowed, true); + }); + + test('allows commit when no .planning/ files staged and commit_docs is false', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: false }) + ); + // Stage a non-planning file + fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")'); + execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' }); + + const result = runGsdTools('check-commit', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.allowed, true); + }); + + test('blocks commit when .planning/ files staged and commit_docs is false', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: false }) + ); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State'); + execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' }); + + const result = runGsdTools('check-commit', tmpDir); + assert.ok(!result.success, 'should block commit'); + assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files'); + assert.ok(result.error.includes('unstage'), 'error should suggest unstage command'); + }); +}); diff --git a/tests/concurrency-safety.test.cjs b/tests/concurrency-safety.test.cjs new file mode 100644 index 000000000..d0a0d5c2f --- /dev/null +++ b/tests/concurrency-safety.test.cjs @@ -0,0 +1,819 @@ +/** + * GSD Tools Tests - Concurrency Safety + * + * Tests for fix/concurrency-safety-1473a: + * - Planning lock integration (withPlanningLock in phase/roadmap operations) + * - readModifyWriteStateMd (atomic state updates) + * - normalizeMd behavioral equivalence (O(n) insideFence rewrite) + * - Warnings (frontmatter parse warning, stateReplaceFieldWithFallback) + * - Performance benchmarks (normalizeMd O(n) verification) + * - Snapshot tests for normalizeMd (regression detection) + * - Multi-process concurrent write tests + * - Stress tests at scale (50+ phases) + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync, exec } = require('child_process'); +const { promisify } = require('util'); +const { performance } = require('perf_hooks'); +const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); + +const { + normalizeMd, +} = require('../get-shit-done/bin/lib/core.cjs'); + +const execAsync = promisify(exec); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function writeMinimalRoadmap(tmpDir, phases = ['1']) { + const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n${lines}\n` + ); +} + +function writeMinimalStateMd(tmpDir, content) { + const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + defaultContent + ); +} + +function writeMinimalProjectMd(tmpDir) { + const sections = ['## What This Is', '## Core Value', '## Requirements']; + const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + `# Project\n\n${content}` + ); +} + +function writeValidConfigJson(tmpDir, overrides = {}) { + const base = { model_profile: 'balanced', commit_docs: true }; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ ...base, ...overrides }, null, 2) + ); +} + +/** + * Generate a 50-phase project structure for stress testing. + */ +function create50PhaseProject(tmpDir, completedCount = 25) { + let roadmapContent = '# Roadmap v1.0\n\n'; + for (let i = 1; i <= 50; i++) { + roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`; + } + roadmapContent += '\n'; + for (let i = 1; i <= 50; i++) { + const pad = String(i).padStart(2, '0'); + roadmapContent += `### Phase ${i}: Feature ${i}\n\n`; + roadmapContent += `**Goal:** Build feature ${i}\n`; + roadmapContent += `**Requirements:** REQ-${pad}\n`; + roadmapContent += `**Plans:** 1 plans\n\n`; + roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`; + } + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + roadmapContent + ); + + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + for (let i = 1; i <= 50; i++) { + const pad = String(i).padStart(2, '0'); + const dirName = `${pad}-feature-${i}`; + const phaseDir = path.join(phasesDir, dirName); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(phaseDir, `${pad}-01-PLAN.md`), + `# Phase ${i} Plan 1\n\nBuild feature ${i}.\n` + ); + if (i <= completedCount) { + fs.writeFileSync( + path.join(phaseDir, `${pad}-01-SUMMARY.md`), + `# Phase ${i} Plan 1 Summary\n\nFeature ${i} completed.\n` + ); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Planning lock integration +// ───────────────────────────────────────────────────────────────────────────── + +describe('planning lock integration', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('phase add creates and releases .planning/.lock during ROADMAP write', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n` + ); + + const result = runGsdTools('phase add Testing', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const lockPath = path.join(tmpDir, '.planning', '.lock'); + assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase add'); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_number, 2, 'should be phase 2'); + }); + + test('phase complete creates and releases .planning/.lock', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true }); + + const result = runGsdTools('phase complete 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const lockPath = path.join(tmpDir, '.planning', '.lock'); + assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase complete'); + + const output = JSON.parse(result.output); + assert.strictEqual(output.completed_phase, '1', 'phase should be completed'); + }); + + test('roadmap update-plan-progress creates and releases .planning/.lock', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n| Phase | Plans | Status | Updated |\n|-------|-------|--------|---------|\n| 1 | 0/0 | Not started | - |\n\n### Phase 1: Foundation\n**Goal:** Setup\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary'); + + const result = runGsdTools('roadmap update-plan-progress 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const lockPath = path.join(tmpDir, '.planning', '.lock'); + assert.ok(!fs.existsSync(lockPath), '.lock file should be released after roadmap update'); + }); + + test('lock file does NOT persist after successful phase operations', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n` + ); + + runGsdTools('phase add First Phase', tmpDir); + runGsdTools('phase add Second Phase', tmpDir); + + const lockPath = path.join(tmpDir, '.planning', '.lock'); + assert.ok(!fs.existsSync(lockPath), '.lock file should not persist after multiple operations'); + }); + + test('phase add still works correctly with lock (behavioral regression)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n\n---\n` + ); + + const result = runGsdTools('phase add User Dashboard', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_number, 3, 'should be phase 3'); + assert.strictEqual(output.slug, 'user-dashboard'); + + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')), + 'directory should be created' + ); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. readModifyWriteStateMd (tested via CLI commands that use it) +// ───────────────────────────────────────────────────────────────────────────── + +describe('readModifyWriteStateMd (via state patch)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('transforms content atomically (read + modify + write under lock)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 03\n**Status:** Planning\n**Current Plan:** 03-01\n` + ); + + const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 03-02', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(content.includes('**Status:** In progress'), 'Status should be updated'); + assert.ok(content.includes('03-02'), 'Current Plan should be updated'); + + const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock'); + assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should be released after patch'); + }); + + test('lock file cleaned up after state patch operation', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 01\n**Status:** Ready\n` + ); + + runGsdTools('state patch --Status "In progress"', tmpDir); + + const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock'); + assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after operation'); + }); + + test('state patch still works correctly via readModifyWriteStateMd path (behavioral regression)', () => { + const stateMd = [ + '# Project State', + '', + '**Current Phase:** 03', + '**Status:** Planning', + '**Current Plan:** 03-01', + '**Last Activity:** 2024-01-15', + ].join('\n') + '\n'; + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete'); + assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged'); + }); + + test('two sequential state patches both persist (patch A then patch B)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n**Last Activity:** 2024-01-01\n` + ); + + const resultA = runGsdTools('state patch --Status "In progress"', tmpDir); + assert.ok(resultA.success, `Patch A failed: ${resultA.error}`); + + const resultB = runGsdTools('state patch --"Current Plan" 01-02', tmpDir); + assert.ok(resultB.success, `Patch B failed: ${resultB.error}`); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(content.includes('**Status:** In progress'), 'Patch A (Status) should persist'); + assert.ok(content.includes('01-02'), 'Patch B (Current Plan) should persist'); + assert.ok(content.includes('**Last Activity:** 2024-01-01'), 'Untouched field should be preserved'); + }); + + test('lock file does not persist after rapid sequential patches', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n` + ); + + runGsdTools('state patch --Status "In progress"', tmpDir); + runGsdTools('state patch --"Current Plan" 01-02', tmpDir); + runGsdTools('state patch --Status Complete', tmpDir); + + const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock'); + assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after rapid sequential patches'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Multi-process concurrent write tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('multi-process concurrent write tests', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('two concurrent state patches to DIFFERENT fields both persist', async () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + [ + '# Project State', + '', + '**Current Phase:** 01', + '**Status:** In progress', + '**Current Plan:** 01-01', + '**Last Activity:** 2025-01-01', + '**Last Activity Description:** Working', + '', + ].join('\n') + ); + + const toolsPath = TOOLS_PATH; + const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`; + const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`; + + const [resultA, resultB] = await Promise.all([ + execAsync(cmdA, { encoding: 'utf-8' }).catch(e => e), + execAsync(cmdB, { encoding: 'utf-8' }).catch(e => e), + ]); + + const aOk = !(resultA instanceof Error); + const bOk = !(resultB instanceof Error); + assert.ok(aOk || bOk, 'At least one concurrent patch should succeed'); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + + assert.ok( + content.includes('Complete') || content.includes('01-02'), + `At least one concurrent patch should persist in STATE.md. Content:\n${content}` + ); + + if (content.includes('Complete') && content.includes('01-02')) { + assert.ok(true, 'Both concurrent patches persisted (lock serialization)'); + } + + assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field Current Phase should survive'); + assert.ok(content.includes('2025-01-01'), 'Untouched field Last Activity should survive'); + }); + + test('lock file does not persist after concurrent operations', async () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + [ + '# Project State', + '', + '**Current Phase:** 01', + '**Status:** Planning', + '**Current Plan:** 01-01', + '', + ].join('\n') + ); + + const toolsPath = TOOLS_PATH; + const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`; + const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`; + + await Promise.all([ + execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}), + execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}), + ]); + + const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock'); + assert.ok( + !fs.existsSync(lockPath), + 'STATE.md.lock should not persist after concurrent operations complete' + ); + }); + + test('three rapid sequential patches all persist', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + [ + '# Project State', + '', + '**Current Phase:** 01', + '**Status:** Planning', + '**Current Plan:** 01-01', + '**Last Activity:** 2025-01-01', + '', + ].join('\n') + ); + + const r1 = runGsdTools('state patch --Status "In progress"', tmpDir); + assert.ok(r1.success, `Patch 1 failed: ${r1.error}`); + + const r2 = runGsdTools('state patch --"Current Plan" 01-02', tmpDir); + assert.ok(r2.success, `Patch 2 failed: ${r2.error}`); + + const r3 = runGsdTools('state patch --"Last Activity" 2025-06-15', tmpDir); + assert.ok(r3.success, `Patch 3 failed: ${r3.error}`); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(content.includes('In progress'), 'Patch 1 (Status) should persist'); + assert.ok(content.includes('01-02'), 'Patch 2 (Current Plan) should persist'); + assert.ok(content.includes('2025-06-15'), 'Patch 3 (Last Activity) should persist'); + assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field should be preserved'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. normalizeMd behavioral equivalence (O(n) insideFence rewrite) +// ───────────────────────────────────────────────────────────────────────────── + +describe('normalizeMd behavioral equivalence', () => { + test('simple markdown with headings and paragraphs', () => { + const input = '# Title\nSome text.\n## Section\nMore text.\n'; + const result = normalizeMd(input); + assert.ok(result.includes('# Title\n\nSome text.'), 'title heading should have blank line after'); + assert.ok(result.includes('\n\n## Section\n\nMore text.'), 'section heading should have blank lines around it'); + assert.ok(result.endsWith('\n'), 'should end with newline'); + assert.ok(!result.endsWith('\n\n'), 'should not end with double newline'); + }); + + test('single fenced code block gets blank lines before/after', () => { + const input = 'Some text\n```js\nconst x = 1;\n```\nMore text\n'; + const result = normalizeMd(input); + assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before'); + assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after'); + assert.ok(result.includes('const x = 1;'), 'code content should be preserved'); + }); + + test('multiple fenced code blocks', () => { + const input = 'Intro\n```js\nfoo();\n```\nMiddle\n```py\nbar()\n```\nEnd\n'; + const result = normalizeMd(input); + assert.ok(result.includes('Intro\n\n```js'), 'first code block should have blank line before'); + assert.ok(result.includes('```\n\nMiddle'), 'first code block should have blank line after'); + assert.ok(result.includes('Middle\n\n```py'), 'second code block should have blank line before'); + assert.ok(result.includes('```\n\nEnd'), 'second code block should have blank line after'); + }); + + test('unclosed fence at end of file (edge case)', () => { + const input = 'Some text\n```js\nconst x = 1;\n'; + const result = normalizeMd(input); + assert.ok(typeof result === 'string', 'should return a string'); + assert.ok(result.includes('```js'), 'fence opener should be preserved'); + assert.ok(result.includes('const x = 1;'), 'content after unclosed fence should be preserved'); + assert.ok(result.endsWith('\n'), 'should end with newline'); + }); + + test('empty string input', () => { + assert.strictEqual(normalizeMd(''), '', 'empty string should return empty string'); + }); + + test('mixed headings + lists + fences (complex case)', () => { + const input = [ + '# Title', + '## Section One', + 'Paragraph text.', + '- item 1', + '- item 2', + '## Section Two', + '```bash', + 'echo hello', + '```', + 'After code.', + '## Section Three', + '1. First', + '2. Second', + 'Done.', + ].join('\n') + '\n'; + + const result = normalizeMd(input); + + assert.ok(result.includes('\n\n## Section One\n\n'), 'Section One heading needs blank lines'); + assert.ok(result.includes('\n\n## Section Two\n\n'), 'Section Two heading needs blank lines'); + assert.ok(result.includes('\n\n## Section Three\n\n'), 'Section Three heading needs blank lines'); + assert.ok(result.includes('Paragraph text.\n\n- item 1'), 'list should have blank line before'); + assert.ok(result.includes('\n\n```bash'), 'code block should have blank line before'); + assert.ok(result.includes('```\n\nAfter code.'), 'code block should have blank line after'); + assert.ok(result.includes('echo hello'), 'code content should be preserved'); + assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. normalizeMd performance benchmark +// ───────────────────────────────────────────────────────────────────────────── + +describe('normalizeMd performance benchmark', () => { + test('processes a 100-line markdown file in under 50ms', () => { + const lines = []; + for (let i = 0; i < 100; i++) { + if (i % 20 === 0) { + lines.push(`## Section ${i / 20 + 1}`); + } else if (i % 30 === 0) { + lines.push('```js'); + lines.push(`const x${i} = ${i};`); + lines.push('```'); + } else if (i % 5 === 0) { + lines.push(`- List item ${i}`); + } else { + lines.push(`Paragraph text line ${i} with some content to process.`); + } + } + const input = lines.join('\n') + '\n'; + + const start = performance.now(); + const result = normalizeMd(input); + const elapsed = performance.now() - start; + + assert.ok(typeof result === 'string', 'should return a string'); + assert.ok(result.length > 0, 'result should not be empty'); + assert.ok(result.endsWith('\n'), 'result should end with newline'); + assert.ok(elapsed < 50, `100-line file should process in under 50ms, took ${elapsed.toFixed(2)}ms`); + }); + + test('processes a 1000-line markdown file with 20 code blocks in under 200ms', () => { + const lines = []; + let codeBlockCount = 0; + for (let i = 0; i < 1000; i++) { + if (i % 50 === 0 && codeBlockCount < 20) { + lines.push(`## Section ${codeBlockCount + 1}`); + lines.push(''); + lines.push('Some introductory text for this section.'); + lines.push(''); + lines.push('```python'); + for (let j = 0; j < 5; j++) { + lines.push(` result_${codeBlockCount}_${j} = compute(${j})`); + } + lines.push('```'); + lines.push(''); + lines.push('Explanation of the code above.'); + codeBlockCount++; + } else if (i % 10 === 0) { + lines.push(`### Subsection at line ${i}`); + } else if (i % 7 === 0) { + lines.push(`- Item ${i}: description of this list item`); + } else if (i % 13 === 0) { + lines.push(`1. Ordered item ${i}`); + } else { + lines.push(`Line ${i}: Regular paragraph content with various markdown elements.`); + } + } + const input = lines.join('\n') + '\n'; + + normalizeMd(input); // warm up JIT + + const start = performance.now(); + const result = normalizeMd(input); + const elapsed = performance.now() - start; + + assert.ok(typeof result === 'string', 'should return a string'); + assert.ok(result.length > 0, 'result should not be empty'); + assert.ok(result.endsWith('\n'), 'result should end with newline'); + assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines'); + assert.ok(elapsed < 200, `1000-line file with 20 code blocks should process in under 200ms, took ${elapsed.toFixed(2)}ms`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. normalizeMd snapshot tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('normalizeMd snapshot tests', () => { + test('snapshot - heading spacing', () => { + const input = '# Title\nParagraph\n## Section\nMore text'; + const expected = '# Title\n\nParagraph\n\n## Section\n\nMore text\n'; + const result = normalizeMd(input); + assert.strictEqual(result, expected, + `Heading spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}` + ); + }); + + test('snapshot - code block spacing', () => { + const input = 'Text before\n```js\nconst x = 1;\n```\nText after\n'; + const expected = 'Text before\n\n```js\nconst x = 1;\n```\n\nText after\n'; + const result = normalizeMd(input); + assert.strictEqual(result, expected, + `Code block spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}` + ); + }); + + test('snapshot - list spacing', () => { + const input = 'Paragraph\n- item 1\n- item 2\nAnother paragraph'; + const expected = 'Paragraph\n\n- item 1\n- item 2\n\nAnother paragraph\n'; + const result = normalizeMd(input); + assert.strictEqual(result, expected, + `List spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}` + ); + }); + + test('snapshot - complex mixed document', () => { + const input = [ + '# Main Title', + 'Intro paragraph.', + '## Section One', + 'Some text here.', + '```js', + 'const a = 1;', + '```', + '- first item', + '- second item', + '## Section Two', + 'Final text.', + ].join('\n'); + + const expected = [ + '# Main Title', + '', + 'Intro paragraph.', + '', + '## Section One', + '', + 'Some text here.', + '', + '```js', + 'const a = 1;', + '```', + '', + '- first item', + '- second item', + '', + '## Section Two', + '', + 'Final text.', + '', + ].join('\n'); + + const result = normalizeMd(input); + assert.strictEqual(result, expected, + `Complex mixed document snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}` + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Warnings (frontmatter parse, state field miss) +// ───────────────────────────────────────────────────────────────────────────── + +describe('warnings', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('must_haves parse warning fires for block with content but 0 items', () => { + const planDir = path.join(tmpDir, '.planning', 'phases', '01-test'); + fs.mkdirSync(planDir, { recursive: true }); + fs.writeFileSync( + path.join(planDir, '01-01-PLAN.md'), + `--- +phase: "01" +plan: "01" +must_haves: + acceptance: + bare content without dash prefix + another line without dash prefix +--- + +# Plan 01-01 +` + ); + + const result = runGsdTools( + ['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), 'must_haves'], + tmpDir + ); + + const stderr = result.error || ''; + assert.ok( + stderr.includes('WARNING') && stderr.includes('must_haves') || + result.output.includes('acceptance'), + `Expected WARNING about must_haves parse or valid parse result. stderr: ${stderr}, stdout: ${result.output}` + ); + }); + + test('stateReplaceFieldWithFallback logs warning on miss', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n` + ); + + const result = runGsdTools('state advance-plan', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Malformed input resilience +// ───────────────────────────────────────────────────────────────────────────── + +describe('malformed input resilience', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('STATE.md with invalid bold format -- state patch returns gracefully', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Project State\n\n**Current Phase: 01\n**Status:** Planning\n' + ); + + const result = runGsdTools('state patch --Status "In progress"', tmpDir); + const didNotCrash = result.success || (result.output !== undefined); + assert.ok(didNotCrash, `state patch should not crash on malformed bold format: ${result.error}`); + + if (result.success) { + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok( + content.includes('In progress'), + 'Status field (with valid bold format) should be updated' + ); + } + }); + + test('STATE.md with only frontmatter, no body -- state patch handles gracefully', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '---\nphase: "01"\n---\n' + ); + + const result = runGsdTools('state patch --Status "In progress"', tmpDir); + const didNotCrash = result.success || (result.output !== undefined); + assert.ok(didNotCrash, `state patch should not crash on frontmatter-only STATE.md: ${result.error}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 9. Stress tests with 50+ phases +// ───────────────────────────────────────────────────────────────────────────── + +describe('stress tests with 50+ phases', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('roadmap analyze on 50-phase ROADMAP completes in under 2000ms', () => { + create50PhaseProject(tmpDir, 25); + + const start = performance.now(); + const result = runGsdTools('roadmap analyze', tmpDir); + const elapsed = performance.now() - start; + + assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`); + assert.ok(elapsed < 2000, `Should complete in under 2000ms, took ${elapsed.toFixed(0)}ms`); + + const output = JSON.parse(result.output); + assert.ok(Array.isArray(output.phases), 'Output should contain a phases array'); + assert.strictEqual(output.phases.length, 50, `Should have 50 phases, got ${output.phases.length}`); + + const completedPhases = output.phases.filter(p => p.disk_status === 'complete'); + assert.strictEqual(completedPhases.length, 25, `Should have 25 complete phases, got ${completedPhases.length}`); + }); + + test('phase complete on phase 26 of 50-phase project works correctly', () => { + create50PhaseProject(tmpDir, 25); + writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** In progress\n'); + + const phase26Dir = path.join(tmpDir, '.planning', 'phases', '26-feature-26'); + fs.writeFileSync( + path.join(phase26Dir, '26-01-SUMMARY.md'), + '# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n' + ); + + const result = runGsdTools('phase complete 26', tmpDir); + assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`); + + const roadmapContent = fs.readFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + 'utf-8' + ); + const phase26Checkbox = roadmapContent.match(/-\s*\[(x| )\]\s*.*Phase\s+26/i); + assert.ok(phase26Checkbox, 'Should find Phase 26 checkbox in ROADMAP'); + assert.strictEqual(phase26Checkbox[1], 'x', 'Phase 26 should now be marked as complete [x]'); + }); +}); diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 6280df25e..bd731d8c1 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -242,6 +242,16 @@ describe('config-set command', () => { assert.strictEqual(config.workflow.use_worktrees, false); }); + test('sets git.base_branch for non-main default branches', () => { + writeConfig(tmpDir, {}); + + const result = runGsdTools('config-set git.base_branch master', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.git.base_branch, 'master'); + }); + test('errors when no key path provided', () => { const result = runGsdTools('config-set', tmpDir); assert.strictEqual(result.success, false); @@ -329,6 +339,27 @@ describe('config-get command', () => { }); }); + test('gets git.base_branch after it is set', () => { + runGsdTools('config-set git.base_branch master', tmpDir); + const result = runGsdTools('config-get git.base_branch', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output, 'master'); + }); + + test('errors for git.base_branch when not explicitly set', () => { + // Default config from config-ensure-section does not include git.base_branch, + // so config-get should return "Key not found" — this triggers auto-detect + // fallback in the workflow (origin/HEAD detection). + const result = runGsdTools('config-get git.base_branch', tmpDir); + assert.strictEqual(result.success, false); + assert.ok( + result.error.includes('Key not found'), + `Expected "Key not found" in error: ${result.error}` + ); + }); + test('errors when no key path provided', () => { const result = runGsdTools('config-get', tmpDir); assert.strictEqual(result.success, false); diff --git a/tests/context-enrichment.test.cjs b/tests/context-enrichment.test.cjs new file mode 100644 index 000000000..71d35fab3 --- /dev/null +++ b/tests/context-enrichment.test.cjs @@ -0,0 +1,145 @@ +/** + * GSD Tools Tests - Adaptive Context Enrichment for 1M Models + * + * Tests for feat/1m-context-enrichment-1473b: + * - Workflow template syntax validation (CONTEXT_WINDOW conditionals) + * - execute-phase.md enrichment blocks (executor + verifier) + * - plan-phase.md cross-phase context gating + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +// ───────────────────────────────────────────────────────────────────────────── +// Workflow template syntax validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('execute-phase.md context enrichment', () => { + const EXECUTE_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-phase.md'); + + test('contains CONTEXT_WINDOW config-get command', () => { + const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8'); + assert.ok( + content.includes('CONTEXT_WINDOW'), + 'execute-phase.md should reference CONTEXT_WINDOW variable' + ); + assert.ok( + content.includes('config-get context_window'), + 'execute-phase.md should read context_window via config-get' + ); + assert.ok( + content.includes('|| echo "200000"'), + 'execute-phase.md should default CONTEXT_WINDOW to 200000' + ); + }); + + test('contains conditional prior_wave_summaries in executor prompt', () => { + const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8'); + assert.ok( + content.includes('CONTEXT_WINDOW >= 500000'), + 'execute-phase.md should gate enrichment on CONTEXT_WINDOW >= 500000' + ); + assert.ok( + content.includes('prior_wave_summaries'), + 'execute-phase.md should include prior_wave_summaries in enrichment block' + ); + assert.ok( + content.includes('CONTEXT.md'), + 'execute-phase.md should reference CONTEXT.md in conditional enrichment' + ); + assert.ok( + content.includes('RESEARCH.md'), + 'execute-phase.md should reference RESEARCH.md in conditional enrichment' + ); + }); + + test('verifier prompt includes files_to_read block', () => { + const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8'); + assert.ok( + content.includes(''), + 'execute-phase.md should contain opening tag' + ); + assert.ok( + content.includes(''), + 'execute-phase.md should contain closing tag' + ); + const verifierSection = content.substring(content.lastIndexOf('')); + assert.ok( + verifierSection.includes('PLAN.md'), + 'verifier files_to_read should reference PLAN.md' + ); + assert.ok( + verifierSection.includes('SUMMARY.md'), + 'verifier files_to_read should reference SUMMARY.md' + ); + assert.ok( + verifierSection.includes('REQUIREMENTS.md'), + 'verifier files_to_read should reference REQUIREMENTS.md' + ); + }); + + test('executor enrichment block includes CONTEXT.md and RESEARCH.md for 1M models', () => { + const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8'); + // Find the executor section's enrichment block + const executorIdx = content.indexOf('CONTEXT_WINDOW >= 500000'); + assert.ok(executorIdx > -1, 'Should find CONTEXT_WINDOW >= 500000 conditional'); + + // Extract ~500 chars after the conditional to check what's included + const enrichmentBlock = content.substring(executorIdx, executorIdx + 500); + assert.ok( + enrichmentBlock.includes('CONTEXT.md'), + 'executor enrichment should include CONTEXT.md' + ); + assert.ok( + enrichmentBlock.includes('RESEARCH.md'), + 'executor enrichment should include RESEARCH.md' + ); + }); +}); + +describe('plan-phase.md context enrichment', () => { + const PLAN_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-phase.md'); + + test('contains CONTEXT_WINDOW conditional for prior CONTEXT.md', () => { + const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8'); + assert.ok( + content.includes('CONTEXT_WINDOW'), + 'plan-phase.md should reference CONTEXT_WINDOW variable' + ); + assert.ok( + content.includes('config-get context_window'), + 'plan-phase.md should read context_window via config-get' + ); + assert.ok( + content.includes('CONTEXT_WINDOW >= 500000'), + 'plan-phase.md should gate cross-phase context on CONTEXT_WINDOW >= 500000' + ); + assert.ok( + content.includes('CONTEXT.md'), + 'plan-phase.md should reference CONTEXT.md in cross-phase enrichment' + ); + }); + + test('enrichment block mentions cross-phase decision consistency', () => { + const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8'); + // The enrichment should explain why prior context matters + assert.ok( + content.includes('cross-phase') || content.includes('Cross-phase'), + 'plan-phase.md should mention cross-phase context' + ); + assert.ok( + content.includes('SUMMARY.md'), + 'plan-phase.md should reference prior SUMMARY.md files' + ); + }); + + test('default CONTEXT_WINDOW fallback is 200000', () => { + const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8'); + assert.ok( + content.includes('|| echo "200000"'), + 'plan-phase.md should default CONTEXT_WINDOW to 200000' + ); + }); +}); diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index d4949a664..2d8fbaf99 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -659,7 +659,7 @@ describe('copyCommandsAsCopilotSkills', () => { 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'); + assert.ok(skillContent.includes('argument-hint: "[--from N] [--only N] [--interactive]"'), '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'); @@ -1349,3 +1349,89 @@ describe('E2E: Copilot uninstall verification', () => { }); }); }); + +// ─── Claude uninstall: user file preservation (#1423) ───────────────────────── + +function runClaudeInstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +function runClaudeUninstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--uninstall'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +describe('Claude uninstall preserves user-generated files (#1423)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-preserve-')); + runClaudeInstall(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('preserves USER-PROFILE.md across uninstall', () => { + const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md'); + const content = '# Developer Profile\n\nAutonomy: High\nGenerated: 2026-03-29\n'; + fs.writeFileSync(profilePath, content); + + runClaudeUninstall(tmpDir); + + assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive uninstall'); + assert.strictEqual(fs.readFileSync(profilePath, 'utf-8'), content, 'content should be identical'); + }); + + test('preserves dev-preferences.md across uninstall', () => { + const prefsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + fs.mkdirSync(prefsDir, { recursive: true }); + const prefsPath = path.join(prefsDir, 'dev-preferences.md'); + const content = '---\nname: dev-preferences\n---\n# Preferences\nUse TypeScript strict.\n'; + fs.writeFileSync(prefsPath, content); + + runClaudeUninstall(tmpDir); + + assert.ok(fs.existsSync(prefsPath), 'dev-preferences.md should survive uninstall'); + assert.strictEqual(fs.readFileSync(prefsPath, 'utf-8'), content, 'content should be identical'); + }); + + test('still removes GSD engine files during uninstall', () => { + const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# Profile\n'); + + // Verify engine files exist before uninstall + const binDir = path.join(tmpDir, '.claude', 'get-shit-done', 'bin'); + assert.ok(fs.existsSync(binDir), 'bin/ should exist before uninstall'); + + runClaudeUninstall(tmpDir); + + // Engine files gone, user file preserved + assert.ok(!fs.existsSync(binDir), 'bin/ should be removed after uninstall'); + assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive'); + }); + + test('clean uninstall when no user files exist', () => { + runClaudeUninstall(tmpDir); + + const gsdDir = path.join(tmpDir, '.claude', 'get-shit-done'); + const cmdDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + // Directories should be fully removed when no user files to preserve + assert.ok(!fs.existsSync(gsdDir), 'get-shit-done/ should not exist after clean uninstall'); + assert.ok(!fs.existsSync(cmdDir), 'commands/gsd/ should not exist after clean uninstall'); + }); +}); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 18c31544f..7dc61a33b 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -30,6 +30,7 @@ const { findPhaseInternal, findProjectRoot, detectSubRepos, + planningDir, } = require('../get-shit-done/bin/lib/core.cjs'); // ─── loadConfig ──────────────────────────────────────────────────────────────── @@ -1611,3 +1612,78 @@ describe('reapStaleTempFiles', () => { }); }); }); + +// ─── planningDir ────────────────────────────────────────────────────────────── + +describe('planningDir', () => { + const cwd = '/fake/repo'; + let savedProject, savedWorkstream; + + beforeEach(() => { + savedProject = process.env.GSD_PROJECT; + savedWorkstream = process.env.GSD_WORKSTREAM; + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; + }); + + afterEach(() => { + if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject; + else delete process.env.GSD_PROJECT; + if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream; + else delete process.env.GSD_WORKSTREAM; + }); + + test('returns .planning/ when neither project nor workstream is set', () => { + const result = planningDir(cwd, null, null); + assert.strictEqual(result, path.join(cwd, '.planning')); + }); + + test('returns .planning/{project}/ when project is set', () => { + const result = planningDir(cwd, null, 'my-app'); + assert.strictEqual(result, path.join(cwd, '.planning', 'my-app')); + }); + + test('returns .planning/workstreams/{ws}/ when workstream is set', () => { + const result = planningDir(cwd, 'feature-x', null); + assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x')); + }); + + test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => { + const result = planningDir(cwd, 'feature-x', 'my-app'); + assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x')); + }); + + test('reads GSD_PROJECT from env when project param is undefined', () => { + process.env.GSD_PROJECT = 'env-project'; + const result = planningDir(cwd); + assert.strictEqual(result, path.join(cwd, '.planning', 'env-project')); + }); + + test('rejects path traversal in project name', () => { + assert.throws( + () => planningDir(cwd, null, '../../etc'), + /invalid path characters/ + ); + }); + + test('rejects forward slash in project name', () => { + assert.throws( + () => planningDir(cwd, null, 'foo/bar'), + /invalid path characters/ + ); + }); + + test('rejects backslash in project name', () => { + assert.throws( + () => planningDir(cwd, null, 'foo\\bar'), + /invalid path characters/ + ); + }); + + test('rejects path traversal in workstream name', () => { + assert.throws( + () => planningDir(cwd, '../../../tmp', null), + /invalid path characters/ + ); + }); +}); diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs new file mode 100644 index 000000000..3f17f9425 --- /dev/null +++ b/tests/health-validation.test.cjs @@ -0,0 +1,388 @@ +/** + * GSD Tools Tests - Health Validation + * + * Tests for fix/health-validation-1473c: + * - W011: STATE/ROADMAP cross-validation (phase divergence detection) + * - W012: branching_strategy validation + * - W013: context_window validation + * - W014: phase_branch_template placeholder validation + * - W015: milestone_branch_template placeholder validation + * - stateReplaceFieldWithFallback field-miss warning + * - Boundary conditions and edge cases + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function writeMinimalRoadmap(tmpDir, phases = ['1']) { + const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n${lines}\n` + ); +} + +function writeMinimalStateMd(tmpDir, content) { + const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + defaultContent + ); +} + +function writeMinimalProjectMd(tmpDir) { + const sections = ['## What This Is', '## Core Value', '## Requirements']; + const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + `# Project\n\n${content}` + ); +} + +function writeValidConfigJson(tmpDir, overrides = {}) { + const base = { model_profile: 'balanced', commit_docs: true }; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ ...base, ...overrides }, null, 2) + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. W011: STATE/ROADMAP cross-validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('W011: STATE/ROADMAP cross-validation', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('STATE says current phase but ROADMAP shows it as complete -> warning', () => { + writeMinimalProjectMd(tmpDir); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Session State\n\n**Current Phase:** 03\n**Current Phase Name:** Database Layer\n**Status:** In progress\n` + ); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W011'), + `Expected W011 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + test('STATE and ROADMAP agree (phase not checked off) -> no W011 warning', () => { + writeMinimalProjectMd(tmpDir); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n- [ ] Phase 2: API Layer\n\n### Phase 2: API Layer\n**Goal:** Build API\n` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Session State\n\n**Current Phase:** 2\n**Status:** In progress\n` + ); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api-layer'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + !output.warnings.some(w => w.code === 'W011'), + `Should not have W011: ${JSON.stringify(output.warnings)}` + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. W012-W015: Config field validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('config field validation', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('W012: invalid branching_strategy triggers warning', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir, { branching_strategy: 'banana' }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W012'), + `Expected W012 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + test('W013: negative context_window triggers warning', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir, { context_window: -500 }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W013'), + `Expected W013 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + test('W014: phase_branch_template missing {phase} triggers warning', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir, { phase_branch_template: 'gsd/no-placeholder-{slug}' }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W014'), + `Expected W014 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); + + test('W015: milestone_branch_template missing {milestone} triggers warning', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir, { milestone_branch_template: 'release/no-placeholder' }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + output.warnings.some(w => w.code === 'W015'), + `Expected W015 in warnings: ${JSON.stringify(output.warnings)}` + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Boundary conditions +// ───────────────────────────────────────────────────────────────────────────── + +describe('boundary conditions', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('context_window config accepts 500000 (boundary value)', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir, { context_window: 500000 }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + !output.warnings.some(w => w.code === 'W013'), + `Should not have W013 for context_window=500000: ${JSON.stringify(output.warnings)}` + ); + }); + + test('context_window config accepts 200000 (default value)', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir, { context_window: 200000 }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + !output.warnings.some(w => w.code === 'W013'), + `Should not have W013 for context_window=200000: ${JSON.stringify(output.warnings)}` + ); + }); + + test('W013 does NOT fire when context_window is absent from config', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok( + !output.warnings.some(w => w.code === 'W013'), + `Should not have W013 when context_window is absent: ${JSON.stringify(output.warnings)}` + ); + }); + + test('health check handles STATE.md with no Current Phase field (no W011 crash)', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir, ['1']); + writeMinimalStateMd(tmpDir, '# Session State\n\nSome content but no phase reference.\n'); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command should not crash: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(typeof output.status === 'string', 'should return a status string'); + assert.ok(Array.isArray(output.errors), 'should return errors array'); + assert.ok(Array.isArray(output.warnings), 'should return warnings array'); + }); + + test('health check handles empty ROADMAP.md (no crash)', () => { + writeMinimalProjectMd(tmpDir); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), ''); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n'); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `Command should not crash on empty ROADMAP.md: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(typeof output.status === 'string', 'should return a status string'); + assert.ok(Array.isArray(output.errors), 'should return errors array'); + assert.ok(Array.isArray(output.warnings), 'should return warnings array'); + }); + + test('config.json with trailing comma -- validate health reports parse error', () => { + writeMinimalProjectMd(tmpDir); + writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n'); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + '{"model_profile": "balanced",}' + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 1: Test Phase\n' + ); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success, `validate health should not crash on invalid JSON: ${result.error}`); + + const output = JSON.parse(result.output); + const hasE005 = output.errors.some(e => e.code === 'E005'); + assert.ok(hasE005, `Should report E005 for invalid config.json: ${JSON.stringify(output.errors)}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. stateReplaceFieldWithFallback warning +// ───────────────────────────────────────────────────────────────────────────── + +describe('stateReplaceFieldWithFallback field-miss warning', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('advance-plan completes even when fields are missing (non-fatal)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n` + ); + + const result = runGsdTools('state advance-plan', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete'); + }); + + test('validate health on 50-phase project completes in under 3000ms', () => { + // Stress test for the new health checks at scale + let roadmapContent = '# Roadmap v1.0\n\n'; + for (let i = 1; i <= 50; i++) { + roadmapContent += `- [${i <= 25 ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`; + } + roadmapContent += '\n'; + for (let i = 1; i <= 50; i++) { + const pad = String(i).padStart(2, '0'); + roadmapContent += `### Phase ${i}: Feature ${i}\n\n**Goal:** Build feature ${i}\n**Plans:** 1 plans\n\n`; + } + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent); + + writeMinimalProjectMd(tmpDir); + writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** Planning\n'); + writeValidConfigJson(tmpDir); + + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + for (let i = 1; i <= 50; i++) { + const pad = String(i).padStart(2, '0'); + const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Plan ${i}\n`); + if (i <= 25) { + fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Summary ${i}\n`); + } + } + + const { performance } = require('perf_hooks'); + const start = performance.now(); + const result = runGsdTools('validate health', tmpDir); + const elapsed = performance.now() - start; + + assert.ok(result.success, `validate health should succeed: ${result.error}`); + assert.ok(elapsed < 3000, `Should complete in under 3000ms, took ${elapsed.toFixed(0)}ms`); + + const output = JSON.parse(result.output); + assert.ok(typeof output.status === 'string', 'Should return a status string'); + }); +}); diff --git a/tests/hooks-opt-in.test.cjs b/tests/hooks-opt-in.test.cjs new file mode 100644 index 000000000..f2b9c07ec --- /dev/null +++ b/tests/hooks-opt-in.test.cjs @@ -0,0 +1,507 @@ +/** + * GSD Tools Tests - Community Hooks (opt-in) + * + * Tests for feat/hooks-opt-in-1473d: + * - Hook file existence and permissions + * - Installer hook registration in install.js + * - Hook execution with opt-in enabled and disabled + * - Negative security tests for hooks + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); +const isWindows = process.platform === 'win32'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function createTempProject(prefix = 'gsd-hook-test-') { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + return tmpDir; +} + +function cleanup(tmpDir) { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} +} + +function writeConfigWithHooks(tmpDir, enabled) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + hooks: { community: enabled } + }, null, 2) + ); +} + +function writeMinimalStateMd(tmpDir, content) { + const defaultContent = content || '# Session State\n\n**Current Phase:** 01\n**Status:** Active\n'; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + defaultContent + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Hook file existence and permissions +// ───────────────────────────────────────────────────────────────────────────── + +describe('hook file validation', () => { + test('gsd-session-state.sh exists', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + assert.ok(fs.existsSync(hookPath), 'gsd-session-state.sh should exist'); + }); + + test('gsd-validate-commit.sh exists', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + assert.ok(fs.existsSync(hookPath), 'gsd-validate-commit.sh should exist'); + }); + + test('gsd-phase-boundary.sh exists', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + assert.ok(fs.existsSync(hookPath), 'gsd-phase-boundary.sh should exist'); + }); + + test('gsd-session-state.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + const stat = fs.statSync(hookPath); + assert.ok((stat.mode & 0o111) !== 0, 'gsd-session-state.sh should be executable'); + }); + + test('gsd-validate-commit.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const stat = fs.statSync(hookPath); + assert.ok((stat.mode & 0o111) !== 0, 'gsd-validate-commit.sh should be executable'); + }); + + test('gsd-phase-boundary.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const stat = fs.statSync(hookPath); + assert.ok((stat.mode & 0o111) !== 0, 'gsd-phase-boundary.sh should be executable'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Installer hook registration +// ───────────────────────────────────────────────────────────────────────────── + +describe('installer hook registration', () => { + const installJsPath = path.join(__dirname, '..', 'bin', 'install.js'); + let installSource; + + beforeEach(() => { + installSource = fs.readFileSync(installJsPath, 'utf-8'); + }); + + test('install.js contains gsd-validate-commit registration block', () => { + assert.ok( + installSource.includes('gsd-validate-commit'), + 'install.js should contain gsd-validate-commit hook registration' + ); + assert.ok( + installSource.includes('validateCommitCommand'), + 'install.js should define validateCommitCommand variable' + ); + assert.ok( + installSource.includes('hasValidateCommitHook'), + 'install.js should check for existing validate-commit hook' + ); + }); + + test('install.js contains gsd-session-state registration block', () => { + assert.ok( + installSource.includes('gsd-session-state'), + 'install.js should contain gsd-session-state hook registration' + ); + assert.ok( + installSource.includes('sessionStateCommand'), + 'install.js should define sessionStateCommand variable' + ); + assert.ok( + installSource.includes('hasSessionStateHook'), + 'install.js should check for existing session-state hook' + ); + }); + + test('install.js contains gsd-phase-boundary registration block', () => { + assert.ok( + installSource.includes('gsd-phase-boundary'), + 'install.js should contain gsd-phase-boundary hook registration' + ); + assert.ok( + installSource.includes('phaseBoundaryCommand'), + 'install.js should define phaseBoundaryCommand variable' + ); + assert.ok( + installSource.includes('hasPhaseBoundaryHook'), + 'install.js should check for existing phase-boundary hook' + ); + }); + + test('install.js registers validate-commit with PreToolUse event and Bash matcher', () => { + assert.ok( + installSource.includes("settings.hooks[preToolEvent].push"), + 'validate-commit should be pushed to preToolEvent hooks array' + ); + const validateCommitBlock = installSource.substring( + installSource.indexOf('// Configure commit validation hook'), + installSource.indexOf('// Configure session state orientation hook') + ); + assert.ok( + validateCommitBlock.includes("matcher: 'Bash'"), + 'validate-commit hook should use Bash matcher' + ); + assert.ok( + validateCommitBlock.includes('preToolEvent'), + 'validate-commit hook should register on preToolEvent (PreToolUse)' + ); + }); + + test('install.js adds all 3 new hooks to the uninstall cleanup list', () => { + const gsdHooksMatch = installSource.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/); + assert.ok(gsdHooksMatch, 'install.js should define gsdHooks array for uninstall cleanup'); + + const gsdHooksContent = gsdHooksMatch[1]; + assert.ok( + gsdHooksContent.includes('gsd-session-state.sh'), + 'gsdHooks should include gsd-session-state.sh' + ); + assert.ok( + gsdHooksContent.includes('gsd-validate-commit.sh'), + 'gsdHooks should include gsd-validate-commit.sh' + ); + assert.ok( + gsdHooksContent.includes('gsd-phase-boundary.sh'), + 'gsdHooks should include gsd-phase-boundary.sh' + ); + }); + + test('install.js log messages indicate opt-in behavior', () => { + assert.ok( + installSource.includes('opt-in via config'), + 'install.js should mention opt-in in log messages' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Opt-in gating behavior +// ───────────────────────────────────────────────────────────────────────────── + +describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('validate-commit is a no-op when hooks.community is false', () => { + writeConfigWithHooks(tmpDir, false); + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + // Should exit 0 (no-op) even with a bad commit message + assert.strictEqual(result.status, 0, `Should be no-op when disabled, got ${result.status}`); + }); + + test('validate-commit is a no-op when config.json is absent', () => { + // No config.json at all + const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-bare-')); + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + try { + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: bareDir, + }); + + assert.strictEqual(result.status, 0, `Should be no-op without config.json, got ${result.status}`); + } finally { + fs.rmSync(bareDir, { recursive: true, force: true }); + } + }); + + test('session-state is a no-op when hooks.community is false', () => { + writeConfigWithHooks(tmpDir, false); + writeMinimalStateMd(tmpDir); + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + + const result = spawnSync('bash', [hookPath], { + input: '', + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + // Should NOT output state info when disabled + assert.ok( + !result.stdout.includes('Project State Reminder'), + `Should not output state reminder when disabled: ${result.stdout}` + ); + }); + + test('phase-boundary is a no-op when hooks.community is false', () => { + writeConfigWithHooks(tmpDir, false); + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const input = JSON.stringify({ + tool_input: { file_path: '.planning/STATE.md' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + !result.stdout.includes('.planning/ file modified'), + `Should not output warning when disabled: ${result.stdout}` + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Hook execution when enabled +// ───────────────────────────────────────────────────────────────────────────── + +describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + writeConfigWithHooks(tmpDir, true); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('validate-commit allows valid conventional commit', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "fix(core): add locking mechanism"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Valid commit should exit 0, got ${result.status}. stderr: ${result.stderr}`); + }); + + test('validate-commit blocks non-conventional commit', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`); + assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`); + assert.ok(result.stdout.includes('Conventional Commits'), `stdout should mention "Conventional Commits": ${result.stdout}`); + }); + + test('validate-commit allows non-commit commands', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git push origin main' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Non-commit command should exit 0, got ${result.status}`); + }); + + test('session-state outputs state info when enabled', () => { + writeMinimalStateMd(tmpDir); + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + + const result = spawnSync('bash', [hookPath], { + input: '', + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + result.stdout.includes('STATE.md exists'), + `stdout should contain "STATE.md exists": ${result.stdout}` + ); + }); + + test('session-state exits 0 without .planning/ (in enabled project)', () => { + // Create a dir with config but no STATE.md + const noStateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-nostate-')); + fs.mkdirSync(path.join(noStateDir, '.planning'), { recursive: true }); + writeConfigWithHooks(noStateDir, true); + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + + try { + const result = spawnSync('bash', [hookPath], { + input: '', + encoding: 'utf-8', + cwd: noStateDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + result.stdout.includes('No .planning/ found') || result.stdout.includes('Project State'), + `Should handle missing STATE.md gracefully: ${result.stdout}` + ); + } finally { + fs.rmSync(noStateDir, { recursive: true, force: true }); + } + }); + + test('phase-boundary detects .planning/ writes when enabled', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const input = JSON.stringify({ + tool_input: { file_path: '.planning/STATE.md' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + result.stdout.includes('.planning/ file modified'), + `stdout should contain ".planning/ file modified": ${result.stdout}` + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Negative security tests for hooks +// ───────────────────────────────────────────────────────────────────────────── + +describe('hook security tests', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + writeConfigWithHooks(tmpDir, true); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('validate-commit blocks message with shell metacharacters', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "$(rm -rf /)"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`); + assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`); + }); + + test('validate-commit blocks message with backtick injection', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "`whoami`"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`); + assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`); + }); + + test('validate-commit allows commit with scope containing special chars', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "fix(api/v2): handle edge case"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Valid commit with / in scope should be allowed: ${result.status}`); + }); + + test('phase-boundary handles malformed JSON input gracefully', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const input = 'not json at all'; + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should not crash on malformed JSON: ${result.stderr}`); + }); + + test('hooks handle config.json with broken JSON gracefully', () => { + // Write malformed JSON config + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + '{ broken json' + ); + + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + // Should exit 0 (treat malformed config as disabled) + assert.strictEqual(result.status, 0, `Malformed config should be treated as disabled: ${result.status}`); + }); +}); diff --git a/tests/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs index 9782bd6ca..8962facc5 100644 --- a/tests/multi-runtime-select.test.cjs +++ b/tests/multi-runtime-select.test.cjs @@ -26,9 +26,10 @@ const runtimeMap = { '6': 'copilot', '7': 'antigravity', '8': 'cursor', - '9': 'windsurf' + '9': 'windsurf', + '10': 'augment' }; -const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf']; +const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment']; /** * Simulate the parsing logic from promptRuntime without requiring readline. @@ -37,7 +38,7 @@ const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', function parseRuntimeInput(input) { input = input.trim() || '1'; - if (input === '10') { + if (input === '11') { return allRuntimes; } @@ -57,6 +58,7 @@ describe('multi-runtime selection parsing', () => { test('single choice returns single runtime', () => { assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('2'), ['kilo']); + assert.deepStrictEqual(parseRuntimeInput('3'), ['opencode']); assert.deepStrictEqual(parseRuntimeInput('4'), ['gemini']); assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']); assert.deepStrictEqual(parseRuntimeInput('8'), ['cursor']); @@ -82,8 +84,12 @@ describe('multi-runtime selection parsing', () => { assert.deepStrictEqual(parseRuntimeInput('9'), ['windsurf']); }); - test('choice 10 returns all runtimes', () => { - assert.deepStrictEqual(parseRuntimeInput('10'), allRuntimes); + test('single choice for augment', () => { + assert.deepStrictEqual(parseRuntimeInput('10'), ['augment']); + }); + + test('choice 11 returns all runtimes', () => { + assert.deepStrictEqual(parseRuntimeInput('11'), allRuntimes); }); test('empty input defaults to claude', () => { @@ -92,7 +98,7 @@ describe('multi-runtime selection parsing', () => { }); test('invalid choices are ignored, falls back to claude if all invalid', () => { - assert.deepStrictEqual(parseRuntimeInput('11'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('12'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']); }); @@ -114,7 +120,7 @@ describe('multi-runtime selection parsing', () => { }); describe('install.js source contains multi-select support', () => { - test('runtimeMap is defined with all 9 runtimes', () => { + test('runtimeMap is defined with all 10 runtimes', () => { for (const [key, name] of Object.entries(runtimeMap)) { assert.ok( installSrc.includes(`'${key}': '${name}'`), @@ -131,6 +137,24 @@ describe('install.js source contains multi-select support', () => { } }); + test('all shortcut uses option 11', () => { + assert.ok( + installSrc.includes("if (input === '11')"), + 'all shortcut uses option 11' + ); + }); + + test('prompt lists Augment as option 10 and All as option 11', () => { + assert.ok( + installSrc.includes('10${reset}) Augment'), + 'prompt lists Augment as option 10' + ); + assert.ok( + installSrc.includes('11${reset}) All'), + 'prompt lists All as option 11' + ); + }); + test('prompt text shows multi-select hint', () => { assert.ok( installSrc.includes('Select multiple'), diff --git a/tests/opencode-permissions.test.cjs b/tests/opencode-permissions.test.cjs index 6d1e6ff73..98c79ed49 100644 --- a/tests/opencode-permissions.test.cjs +++ b/tests/opencode-permissions.test.cjs @@ -1,8 +1,9 @@ /** - * GSD Tools Tests - OpenCode permission config + * Regression tests for OpenCode permission config handling. * - * Regression tests for OpenCode permission handling, including the valid - * top-level string form: "permission": "allow". + * Ensures the installer does not crash when opencode.json uses the valid + * top-level string form: "permission": "allow", and that path-specific + * permissions are written against the actual resolved install directory. */ process.env.GSD_TEST_MODE = '1'; @@ -17,6 +18,19 @@ const { configureOpencodePermissions } = require('../bin/install.js'); const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8'); +const envKeys = ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME']; +const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + +function restoreEnv(snapshot) { + for (const key of envKeys) { + if (snapshot[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = snapshot[key]; + } + } +} + describe('configureOpencodePermissions', () => { let configDir; @@ -26,9 +40,10 @@ describe('configureOpencodePermissions', () => { afterEach(() => { cleanup(configDir); + restoreEnv(originalEnv); }); - test('does not rewrite top-level string permissions', () => { + test('does not crash or rewrite top-level string permissions', () => { const configPath = path.join(configDir, 'opencode.json'); const original = JSON.stringify({ $schema: 'https://opencode.ai/config.json', @@ -37,6 +52,7 @@ describe('configureOpencodePermissions', () => { }, null, 2) + '\n'; fs.writeFileSync(configPath, original); + process.env.OPENCODE_CONFIG_DIR = configDir; assert.doesNotThrow(() => configureOpencodePermissions(true, configDir)); assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original); @@ -45,6 +61,7 @@ describe('configureOpencodePermissions', () => { test('adds path-specific read and external_directory permissions for object configs', () => { const configPath = path.join(configDir, 'opencode.json'); fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n'); + process.env.OPENCODE_CONFIG_DIR = configDir; configureOpencodePermissions(true, configDir); @@ -56,6 +73,9 @@ describe('configureOpencodePermissions', () => { }); test('finishInstall passes the actual config dir to OpenCode permissions', () => { - assert.ok(installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'), 'OpenCode permission config uses actual install dir'); + assert.ok( + installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'), + 'OpenCode permission config uses actual install dir' + ); }); }); diff --git a/tests/subagent-timeout.test.cjs b/tests/subagent-timeout.test.cjs new file mode 100644 index 000000000..fd16ded45 --- /dev/null +++ b/tests/subagent-timeout.test.cjs @@ -0,0 +1,254 @@ +/** + * GSD Tools Tests - subagent timeout configuration + * + * Validates that workflow.subagent_timeout is properly registered, + * loaded from config, and emitted in init context. + * + * Closes: #1472 + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ─── config key registration ───────────────────────────────────────────────── + +describe('workflow.subagent_timeout config key (#1472)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('subagent_timeout has correct default value (300000ms)', () => { + // Write a minimal config.json + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ model_profile: 'balanced' }, null, 2)); + + // Load config via init and check the value propagates + // Use config-get to verify the field is recognized + const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '600000'], tmpDir); + assert.ok(result.success, `config-set should accept workflow.subagent_timeout: ${result.error}`); + + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.strictEqual(config.workflow.subagent_timeout, 600000); + }); + + test('config-set rejects invalid config keys but accepts subagent_timeout', () => { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({}, null, 2)); + + // Valid key should succeed + const valid = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir); + assert.ok(valid.success, `workflow.subagent_timeout should be a valid key: ${valid.error}`); + + // Invalid key should fail + const invalid = runGsdTools(['config-set', 'workflow.nonexistent_key', 'true'], tmpDir); + assert.ok(!invalid.success, 'nonexistent key should be rejected'); + }); + + test('subagent_timeout appears in map-codebase init context', () => { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ + workflow: { subagent_timeout: 600000 } + }, null, 2)); + + const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init map-codebase should succeed: ${result.error}`); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.subagent_timeout, 600000, 'init context should include configured timeout'); + }); + + test('subagent_timeout defaults to 300000 when not configured', () => { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({}, null, 2)); + + const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init map-codebase should succeed: ${result.error}`); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.subagent_timeout, 300000, 'default should be 300000ms (5 minutes)'); + }); +}); + +describe('map-codebase workflow references configurable timeout (#1472)', () => { + test('workflow file references subagent_timeout from init context', () => { + const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md'); + const content = fs.readFileSync(workflowPath, 'utf8'); + + assert.ok( + content.includes('subagent_timeout'), + 'map-codebase.md should reference subagent_timeout from init context' + ); + assert.ok( + content.includes('workflow.subagent_timeout'), + 'map-codebase.md should document the config key' + ); + }); + + test('workflow file no longer has hardcoded 300000 timeout', () => { + const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md'); + const content = fs.readFileSync(workflowPath, 'utf8'); + + // The timeout line should reference the config variable, not a hardcoded value + const timeoutLines = content.split('\n').filter(l => l.includes('timeout:')); + for (const line of timeoutLines) { + assert.ok( + !line.match(/timeout:\s*300000\s*$/), + `found hardcoded timeout: "${line.trim()}". Should reference subagent_timeout from init context.` + ); + } + }); +}); + +describe('planning-config.md documents subagent_timeout (#1472)', () => { + test('reference doc includes subagent_timeout entry', () => { + const refPath = path.join(__dirname, '..', 'get-shit-done', 'references', 'planning-config.md'); + const content = fs.readFileSync(refPath, 'utf8'); + + assert.ok( + content.includes('workflow.subagent_timeout'), + 'planning-config.md should document workflow.subagent_timeout' + ); + assert.ok( + content.includes('300000'), + 'planning-config.md should document the default value (300000)' + ); + }); +}); + +// ─── init execute-phase includes context_window ───────────────────────────── + +describe('init execute-phase context_window (#1472)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('init execute-phase output includes context_window from config', () => { + // Write config with a custom context_window value (1M for Opus/Sonnet 4.6) + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ + context_window: 1000000, + }, null, 2)); + + // Create a phase directory with a plan so init execute-phase succeeds + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + + const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.context_window, 1000000, 'context_window should reflect configured value'); + }); + + test('init execute-phase uses default context_window when not configured', () => { + // Write minimal config without context_window + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({}, null, 2)); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + + const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.context_window, 200000, 'default context_window should be 200000'); + }); +}); + +// ─── config-get context_window ────────────────────────────────────────────── + +describe('config-get context_window (#1472)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('config-get context_window returns the configured value', () => { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ + context_window: 1000000, + }, null, 2)); + + const result = runGsdTools('config-get context_window', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output, 1000000); + }); + + test('config-get context_window errors when key is absent', () => { + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({}, null, 2)); + + const result = runGsdTools('config-get context_window', tmpDir); + assert.strictEqual(result.success, false); + assert.ok( + result.error.includes('Key not found'), + `Expected "Key not found" in error: ${result.error}` + ); + }); +}); + +// ─── config-set workflow.subagent_timeout numeric coercion ────────────────── + +describe('config-set workflow.subagent_timeout numeric values (#1472)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + const configPath = path.join(tmpDir, '.planning', 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({}, null, 2)); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('config-set workflow.subagent_timeout coerces string to number', () => { + const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.updated, true); + assert.strictEqual(output.key, 'workflow.subagent_timeout'); + assert.strictEqual(output.value, 900000); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.strictEqual(config.workflow.subagent_timeout, 900000); + assert.strictEqual(typeof config.workflow.subagent_timeout, 'number'); + }); + + test('config-set workflow.subagent_timeout round-trips through config-get', () => { + runGsdTools(['config-set', 'workflow.subagent_timeout', '1200000'], tmpDir); + + const result = runGsdTools('config-get workflow.subagent_timeout', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output, 1200000); + }); +});