diff --git a/.release-monitor.sh b/.release-monitor.sh new file mode 100755 index 000000000..200383398 --- /dev/null +++ b/.release-monitor.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Release monitor for gsd-build/get-shit-done +# Checks every 15 minutes, writes new release info to a signal file + +REPO="gsd-build/get-shit-done" +SIGNAL_FILE="/tmp/gsd-new-release.json" +STATE_FILE="/tmp/gsd-monitor-last-tag" +LOG_FILE="/tmp/gsd-monitor.log" + +# Initialize with current latest +echo "v1.25.1" > "$STATE_FILE" +rm -f "$SIGNAL_FILE" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +log "Monitor started. Watching $REPO for releases newer than v1.25.1" +log "Checking every 15 minutes..." + +while true; do + sleep 900 # 15 minutes + + LAST_KNOWN=$(cat "$STATE_FILE" 2>/dev/null) + + # Get latest release tag + LATEST=$(gh release list -R "$REPO" --limit 1 2>/dev/null | awk '{print $1}') + + if [ -z "$LATEST" ]; then + log "WARNING: Failed to fetch releases (network issue?)" + continue + fi + + if [ "$LATEST" != "$LAST_KNOWN" ]; then + log "NEW RELEASE DETECTED: $LATEST (was: $LAST_KNOWN)" + + # Fetch release notes + RELEASE_BODY=$(gh release view "$LATEST" -R "$REPO" --json tagName,name,body 2>/dev/null) + + # Write signal file for the agent to pick up + echo "$RELEASE_BODY" > "$SIGNAL_FILE" + echo "$LATEST" > "$STATE_FILE" + + log "Signal file written to $SIGNAL_FILE" + # Exit so the agent can process it, then restart + exit 0 + else + log "No new release. Latest is still $LATEST" + fi +done diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f7d2a69..43618437d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,44 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.26.0] - 2026-03-18 + ### Added -- **`/gsd:profile-user` command** — Developer behavioral profiling from session analysis across 8 dimensions (communication, decisions, debugging, UX, vendor choices, frustrations, learning style, explanation depth). Generates `USER-PROFILE.md`, `/gsd:dev-preferences`, and `CLAUDE.md` profile section for personalized responses. Includes `--questionnaire` fallback and `--refresh` for re-analysis -- **Execution hardening** — Three quality improvements to the execution pipeline: - - Pre-wave dependency check in `execute-phase`: verifies key-links from prior wave artifacts before spawning next wave - - Cross-Plan Data Contracts (Dimension 9) in plan-checker: detects incompatible transformations between plans sharing data pipelines - - Export-level spot check in `verify-phase`: catches dead stores that exist in wired files but are never called +- **Developer profiling pipeline** — `/gsd:profile-user` analyzes Claude Code session history to build behavioral profiles across 8 dimensions (communication, decisions, debugging, UX, vendor choices, frustrations, learning style, explanation depth). Generates `USER-PROFILE.md`, `/gsd:dev-preferences`, and `CLAUDE.md` profile section. Includes `--questionnaire` fallback and `--refresh` for re-analysis (#1084) +- **`/gsd:ship` command** — PR creation from verified phase work. Auto-generates rich PR body from planning artifacts, pushes branch, creates PR via `gh`, and updates STATE.md (#829) +- **`/gsd:next` command** — Automatic workflow advancement to the next logical step (#927) +- **Cross-phase regression gate** — Execute-phase runs prior phases' test suites after execution, catching regressions before they compound (#945) +- **Requirements coverage gate** — Plan-phase verifies all phase requirements are covered by at least one plan before proceeding (#984) +- **Structured session handoff artifact** — `/gsd:pause-work` writes `.planning/HANDOFF.json` for machine-readable cross-session continuity (#940) +- **WAITING.json signal file** — Machine-readable signal for decision points requiring user input (#1034) +- **Interactive executor mode** — Pair-programming style execution with step-by-step user involvement (#963) +- **MCP tool awareness** — GSD subagents can discover and use MCP server tools (#973) +- **Codex hooks support** — SessionStart hook support for Codex runtime (#1020) +- **Model alias-to-full-ID resolution** — Task API compatibility for model alias strings (#991) +- **Execution hardening** — Pre-wave dependency checks, cross-plan data contracts, and export-level spot checks (#1082) +- **Markdown normalization** — Generated markdown conforms to markdownlint standards (#1112) + +### Changed +- Test suite consolidated: runtime converters deduplicated, helpers standardized (#1169) +- Added test coverage for model-profiles, templates, profile-pipeline, profile-output (#1170) +- Documented `inherit` profile for non-Anthropic providers (#1036) ### Fixed -- **Requirements `mark-complete` is now idempotent** — Re-marking already-completed requirements returns `already_complete` instead of `not_found` (#948) +- Agent suggests non-existent `/gsd:transition` — replaced with real commands (#1081, #1100) +- PROJECT.md drift and phase completion counter accuracy (#956) +- Copilot executor stuck issue — runtime compatibility fallback added (#1128) +- Explicit agent type listings prevent fallback after `/clear` (#949) +- Nested Skill calls breaking AskUserQuestion (#1009) +- Negative-heuristic `stripShippedMilestones` replaced with positive milestone lookup (#1145) +- Hook version tracking, stale hook detection, stdin timeout, session-report command (#1153, #1157, #1161, #1162) +- Hook build script syntax validation (#1165) +- Verification examples use `fetch()` instead of `curl` for Windows compatibility (#899) +- Sequential fallback for `map-codebase` on runtimes without Task tool (#1174) +- Zsh word-splitting fix for RUNTIME_DIRS arrays (#1173) +- CRLF frontmatter parsing, duplicate cwd crash, STATE.md phase transitions (#1105) +- Requirements `mark-complete` made idempotent (#948) +- Profile template paths, field names, and evidence key corrections (#1095) +- Duplicate variable declaration removed (#1101) ## [1.25.0] - 2026-03-16 @@ -1536,7 +1565,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - YOLO mode for autonomous execution - Interactive mode with checkpoints -[Unreleased]: https://github.com/glittercowboy/get-shit-done/compare/v1.25.0...HEAD +[Unreleased]: https://github.com/glittercowboy/get-shit-done/compare/v1.26.0...HEAD +[1.26.0]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.26.0 [1.25.0]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.25.0 [1.24.0]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.24.0 [1.23.0]: https://github.com/glittercowboy/get-shit-done/releases/tag/v1.23.0 diff --git a/README.md b/README.md index ddcee19a7..13e96875f 100644 --- a/README.md +++ b/README.md @@ -342,19 +342,26 @@ If everything passes, you move on. If something's broken, you don't manually deb --- -### 6. Repeat → Complete → Next Milestone +### 6. Repeat → Ship → Complete → Next Milestone ``` /gsd:discuss-phase 2 /gsd:plan-phase 2 /gsd:execute-phase 2 /gsd:verify-work 2 +/gsd:ship 2 # Create PR from verified work ... /gsd:complete-milestone /gsd:new-milestone ``` -Loop **discuss → plan → execute → verify** until milestone complete. +Or let GSD figure out the next step automatically: + +``` +/gsd:next # Auto-detect and run next step +``` + +Loop **discuss → plan → execute → verify → ship** until milestone complete. If you want faster intake during discussion, use `/gsd:discuss-phase --batch` to answer a small grouped set of questions at once instead of one-by-one. @@ -491,6 +498,8 @@ You're never locked in. The system adapts. | `/gsd:plan-phase [N] [--auto]` | Research + plan + verify for a phase | | `/gsd:execute-phase ` | Execute all plans in parallel waves, verify when complete | | `/gsd:verify-work [N]` | Manual user acceptance testing ¹ | +| `/gsd:ship [N] [--draft]` | Create PR from verified phase work with auto-generated body | +| `/gsd:next` | Automatically advance to the next logical workflow step | | `/gsd:audit-milestone` | Verify milestone achieved its definition of done | | `/gsd:complete-milestone` | Archive milestone, tag release | | `/gsd:new-milestone [name]` | Start next version: questions → research → requirements → roadmap | @@ -507,6 +516,7 @@ You're never locked in. The system adapts. | Command | What it does | |---------|--------------| | `/gsd:progress` | Where am I? What's next? | +| `/gsd:next` | Auto-detect state and run the next step | | `/gsd:help` | Show all commands and usage guide | | `/gsd:update` | Update GSD with changelog preview | | `/gsd:join-discord` | Join the GSD Discord community | @@ -531,8 +541,9 @@ You're never locked in. The system adapts. | Command | What it does | |---------|--------------| -| `/gsd:pause-work` | Create handoff when stopping mid-phase | +| `/gsd:pause-work` | Create handoff when stopping mid-phase (writes HANDOFF.json) | | `/gsd:resume-work` | Restore from last session | +| `/gsd:session-report` | Generate session summary with work performed and outcomes | ### Utilities @@ -581,7 +592,7 @@ Switch profiles: /gsd:set-profile budget ``` -Use `inherit` to follow the current runtime model selection (for example OpenCode `/model`). +Use `inherit` when using non-Anthropic providers (OpenRouter, local models) or to follow the current runtime model selection (e.g. OpenCode `/model`). Or configure via `/gsd:settings`. diff --git a/bin/install.js b/bin/install.js index cff9bb5f8..62792da60 100755 --- a/bin/install.js +++ b/bin/install.js @@ -64,6 +64,7 @@ const hasGemini = args.includes('--gemini'); const hasCodex = args.includes('--codex'); const hasCopilot = args.includes('--copilot'); const hasAntigravity = args.includes('--antigravity'); +const hasCursor = args.includes('--cursor'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); @@ -71,7 +72,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u'); // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { - selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity']; + selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { @@ -81,6 +82,7 @@ if (hasAll) { if (hasCodex) selectedRuntimes.push('codex'); if (hasCopilot) selectedRuntimes.push('copilot'); if (hasAntigravity) selectedRuntimes.push('antigravity'); + if (hasCursor) selectedRuntimes.push('cursor'); } // WSL + Windows Node.js detection @@ -124,6 +126,7 @@ function getDirName(runtime) { if (runtime === 'gemini') return '.gemini'; if (runtime === 'codex') return '.codex'; if (runtime === 'antigravity') return '.agent'; + if (runtime === 'cursor') return '.cursor'; return '.claude'; } @@ -151,6 +154,7 @@ function getConfigDirFromHome(runtime, isGlobal) { if (!isGlobal) return "'.agent'"; return "'.gemini', 'antigravity'"; } + if (runtime === 'cursor') return "'.cursor'"; return "'.claude'"; } @@ -237,6 +241,18 @@ function getGlobalDir(runtime, explicitDir = null) { return path.join(os.homedir(), '.gemini', 'antigravity'); } + if (runtime === 'cursor') { + // Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor + if (explicitDir) { + return expandTilde(explicitDir); + } + if (process.env.CURSOR_CONFIG_DIR) { + return expandTilde(process.env.CURSOR_CONFIG_DIR); + } + return path.join(os.homedir(), '.cursor'); + } + + // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude if (explicitDir) { return expandTilde(explicitDir); @@ -257,7 +273,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, Codex, Copilot, and Antigravity by TÂCHES.\n'; + ' development system for Claude Code, OpenCode, Gemini, Codex, Copilot, Antigravity, and Cursor by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { @@ -295,7 +311,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}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for 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 all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Codex globally${reset}\n npx get-shit-done-cc --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_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}--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}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for 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 all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from 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 / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -694,6 +710,14 @@ function yamlQuote(value) { return JSON.stringify(value); } +function yamlIdentifier(value) { + const text = String(value).trim(); + if (/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(text)) { + return text; + } + return yamlQuote(text); +} + function extractFrontmatterAndBody(content) { if (!content.startsWith('---')) { return { frontmatter: null, body: content }; @@ -717,6 +741,121 @@ function extractFrontmatterField(frontmatter, fieldName) { return match[1].trim().replace(/^['"]|['"]$/g, ''); } +// Tool name mapping from Claude Code to Cursor CLI +const claudeToCursorTools = { + Bash: 'Shell', + Edit: 'StrReplace', + AskUserQuestion: null, // No direct equivalent — use conversational prompting + SlashCommand: null, // No equivalent — skills are auto-discovered +}; + +/** + * Convert a Claude Code tool name to Cursor CLI format + * @returns {string|null} Cursor tool name, or null if tool should be excluded + */ +function convertCursorToolName(claudeTool) { + if (claudeTool in claudeToCursorTools) { + return claudeToCursorTools[claudeTool]; + } + // MCP tools keep their format (Cursor supports MCP) + if (claudeTool.startsWith('mcp__')) { + return claudeTool; + } + // Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite) + return claudeTool; +} + +function convertSlashCommandsToCursorSkillMentions(content) { + // Keep leading "/" for slash commands; only normalize gsd: -> gsd-. + // This preserves rendered "next step" commands like "/gsd-execute-phase 17". + return content.replace(/gsd:/gi, 'gsd-'); +} + +function convertClaudeToCursorMarkdown(content) { + let converted = convertSlashCommandsToCursorSkillMentions(content); + // Replace tool name references in body text + converted = converted.replace(/\bBash\(/g, 'Shell('); + converted = converted.replace(/\bEdit\(/g, 'StrReplace('); + converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting'); + // Replace subagent_type from Claude to Cursor 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 Cursor equivalents + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.cursor/rules/`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.cursor/rules/'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.cursor/rules/`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.cursor/rules/'); + converted = converted.replace(/\.claude\/skills\//g, '.cursor/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 "Cursor" + converted = converted.replace(/\bClaude Code\b/g, 'Cursor'); + return converted; +} + +function getCursorSkillAdapterHeader(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 Cursor tools when executing GSD workflows: +- \`Shell\` for running commands (terminal operations) +- \`StrReplace\` for editing existing files +- \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed + +## D. Subagent Spawning +When the workflow needs to spawn a subagent: +- Use \`Task(subagent_type="generalPurpose", ...)\` +- The \`model\` parameter maps to Cursor's model options (e.g., "fast") +`; +} + +function convertClaudeCommandToCursorSkill(content, skillName) { + const converted = convertClaudeToCursorMarkdown(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 = getCursorSkillAdapterHeader(skillName); + + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; +} + +/** + * Convert Claude Code agent markdown to Cursor agent format. + * Strips frontmatter fields Cursor doesn't support (color, skills), + * converts tool references, and adds a role context header. + */ +function convertClaudeAgentToCursorAgent(content) { + let converted = convertClaudeToCursorMarkdown(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}`; +} + function convertSlashCommandsToCodexSkillMentions(content) { let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => { return `$gsd-${String(commandName).toLowerCase()}`; @@ -1228,6 +1367,13 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false } = {}) { continue; } + // Strip model: field — OpenCode doesn't support Claude Code model aliases + // like 'haiku', 'sonnet', 'opus', or 'inherit'. Omitting lets OpenCode use + // its configured default model. See #1156. + if (trimmed.startsWith('model:')) { + continue; + } + // Convert color names to hex for opencode (commands only; agents strip color above) if (trimmed.startsWith('color:')) { const colorValue = trimmed.substring(6).trim().toLowerCase(); @@ -1264,8 +1410,10 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false } = {}) { } // For agents: add required OpenCode agent fields + // Note: Do NOT add 'model: inherit' — OpenCode does not recognize the 'inherit' + // keyword and throws ProviderModelNotFoundError. Omitting model: lets OpenCode + // use its default model for subagents. See #1156. if (isAgent) { - newLines.push('model: inherit'); newLines.push('mode: subagent'); } @@ -1445,6 +1593,59 @@ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtim recurse(srcDir, prefix); } +function copyCommandsAsCursorSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { + if (!fs.existsSync(srcDir)) { + return; + } + + fs.mkdirSync(skillsDir, { recursive: true }); + + // Remove previous GSD Cursor 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 cursorDirRegex = /~\/\.cursor\//g; + content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, pathPrefix); + content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); + content = content.replace(cursorDirRegex, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + content = convertClaudeCommandToCursorSkill(content, skillName); + + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + } + } + + recurse(srcDir, prefix); +} + /** * Copy Claude commands as Copilot skills — one folder per skill with SKILL.md. * Applies CONV-01 (structure), CONV-02 (allowed-tools), CONV-06 (paths), CONV-07 (command names). @@ -1561,6 +1762,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; const isAntigravity = runtime === 'antigravity'; + const isCursor = runtime === 'cursor'; const dirName = getDirName(runtime); // Clean install: remove existing destination to prevent orphaned files @@ -1617,6 +1819,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = convertClaudeToAntigravityContent(content, isGlobal); content = processAttribution(content, getCommitAttribution(runtime)); fs.writeFileSync(destPath, content); + } else if (isCursor) { + content = convertClaudeToCursorMarkdown(content); + fs.writeFileSync(destPath, content); } else { fs.writeFileSync(destPath, content); } @@ -1630,6 +1835,14 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand let content = fs.readFileSync(srcPath, 'utf8'); content = convertClaudeToAntigravityContent(content, isGlobal); fs.writeFileSync(destPath, content); + } else if (isCursor && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { + // For Cursor, also convert Claude references in JS/CJS utility scripts + let jsContent = fs.readFileSync(srcPath, 'utf8'); + jsContent = jsContent.replace(/gsd:/gi, 'gsd-'); + jsContent = jsContent.replace(/\.claude\/skills\//g, '.cursor/skills/'); + jsContent = jsContent.replace(/CLAUDE\.md/g, '.cursor/rules/'); + jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor'); + fs.writeFileSync(destPath, jsContent); } else { fs.copyFileSync(srcPath, destPath); } @@ -1722,6 +1935,7 @@ function uninstall(isGlobal, runtime = 'claude') { const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; const isAntigravity = runtime === 'antigravity'; + const isCursor = runtime === 'cursor'; const dirName = getDirName(runtime); // Get the target directory based on runtime and install type @@ -1739,6 +1953,7 @@ function uninstall(isGlobal, runtime = 'claude') { if (runtime === 'codex') runtimeLabel = 'Codex'; if (runtime === 'copilot') runtimeLabel = 'Copilot'; if (runtime === 'antigravity') runtimeLabel = 'Antigravity'; + if (runtime === 'cursor') runtimeLabel = 'Cursor'; console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -1765,8 +1980,8 @@ function uninstall(isGlobal, runtime = 'claude') { } console.log(` ${green}✓${reset} Removed GSD commands from command/`); } - } else if (isCodex) { - // Codex: remove skills/gsd-*/SKILL.md skill directories + } else if (isCodex || isCursor) { + // Codex/Cursor: remove skills/gsd-*/SKILL.md skill directories const skillsDir = path.join(targetDir, 'skills'); if (fs.existsSync(skillsDir)) { let skillCount = 0; @@ -1779,11 +1994,12 @@ function uninstall(isGlobal, runtime = 'claude') { } if (skillCount > 0) { removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} Codex skills`); + console.log(` ${green}✓${reset} Removed ${skillCount} ${runtimeLabel} skills`); } } - // Codex: remove GSD agent .toml config files + // Codex-only: remove GSD agent .toml config files and config.toml sections + if (isCodex) { const codexAgentsDir = path.join(targetDir, 'agents'); if (fs.existsSync(codexAgentsDir)) { const tomlFiles = fs.readdirSync(codexAgentsDir); @@ -1816,6 +2032,7 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Cleaned GSD sections from config.toml`); } } + } } else if (isCopilot) { // Copilot: remove skills/gsd-*/ directories (same layout as Codex skills) const skillsDir = path.join(targetDir, 'skills'); @@ -1866,6 +2083,23 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Removed ${skillCount} Antigravity skills`); } } + } else if (isCursor) { + // Cursor: remove skills/gsd-*/ directories (same layout as Codex skills) + const skillsDir = path.join(targetDir, 'skills'); + if (fs.existsSync(skillsDir)) { + let skillCount = 0; + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); + skillCount++; + } + } + if (skillCount > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${skillCount} Cursor skills`); + } + } } else { const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(gsdCommandsDir)) { @@ -2274,6 +2508,7 @@ function writeManifest(configDir, runtime = 'claude') { const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; const isAntigravity = runtime === 'antigravity'; + const isCursor = runtime === 'cursor'; const gsdDir = path.join(configDir, 'get-shit-done'); const commandsDir = path.join(configDir, 'commands', 'gsd'); const opencodeCommandDir = path.join(configDir, 'command'); @@ -2285,7 +2520,7 @@ function writeManifest(configDir, runtime = 'claude') { for (const [rel, hash] of Object.entries(gsdHashes)) { manifest.files['get-shit-done/' + rel] = hash; } - if (!isOpencode && !isCodex && !isCopilot && !isAntigravity && fs.existsSync(commandsDir)) { + if (!isOpencode && !isCodex && !isCopilot && !isAntigravity && !isCursor && fs.existsSync(commandsDir)) { const cmdHashes = generateManifest(commandsDir); for (const [rel, hash] of Object.entries(cmdHashes)) { manifest.files['commands/gsd/' + rel] = hash; @@ -2298,7 +2533,7 @@ function writeManifest(configDir, runtime = 'claude') { } } } - if ((isCodex || isCopilot || isAntigravity) && fs.existsSync(codexSkillsDir)) { + if ((isCodex || isCopilot || isAntigravity || isCursor) && fs.existsSync(codexSkillsDir)) { for (const skillName of listCodexSkillNames(codexSkillsDir)) { const skillRoot = path.join(codexSkillsDir, skillName); const skillHashes = generateManifest(skillRoot); @@ -2376,7 +2611,9 @@ function reportLocalPatches(configDir, runtime = 'claude') { ? '/gsd-reapply-patches' : runtime === 'codex' ? '$gsd-reapply-patches' - : '/gsd:reapply-patches'; + : runtime === 'cursor' + ? 'gsd-reapply-patches (mention the skill name)' + : '/gsd:reapply-patches'; console.log(''); console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):'); for (const f of meta.files) { @@ -2397,6 +2634,7 @@ function install(isGlobal, runtime = 'claude') { const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; const isAntigravity = runtime === 'antigravity'; + const isCursor = runtime === 'cursor'; const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -2421,6 +2659,7 @@ function install(isGlobal, runtime = 'claude') { if (isCodex) runtimeLabel = 'Codex'; if (isCopilot) runtimeLabel = 'Copilot'; if (isAntigravity) runtimeLabel = 'Antigravity'; + if (isCursor) runtimeLabel = 'Cursor'; console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`); @@ -2488,6 +2727,16 @@ function install(isGlobal, runtime = 'claude') { } else { failures.push('skills/gsd-*'); } + } else if (isCursor) { + const skillsDir = path.join(targetDir, 'skills'); + const gsdSrc = path.join(src, 'commands', 'gsd'); + copyCommandsAsCursorSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); + const installedSkillNames = listCodexSkillNames(skillsDir); // reuse — same dir structure + if (installedSkillNames.length > 0) { + console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } } else { // Claude Code & Gemini: nested structure in commands/ directory const commandsDir = path.join(targetDir, 'commands'); @@ -2552,6 +2801,8 @@ function install(isGlobal, runtime = 'claude') { content = convertClaudeAgentToCopilotAgent(content, isGlobal); } else if (isAntigravity) { content = convertClaudeAgentToAntigravityAgent(content, isGlobal); + } else if (isCursor) { + content = convertClaudeAgentToCursorAgent(content); } const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; fs.writeFileSync(path.join(agentsDest, destName), content); @@ -2585,7 +2836,7 @@ function install(isGlobal, runtime = 'claude') { failures.push('VERSION'); } - if (!isCodex && !isCopilot) { + if (!isCodex && !isCopilot && !isCursor) { // Write package.json to force CommonJS mode for GSD scripts // Prevents "require is not defined" errors when project has "type": "module" // Node.js walks up looking for package.json - this stops inheritance from project @@ -2606,10 +2857,14 @@ function install(isGlobal, runtime = 'claude') { if (fs.statSync(srcFile).isFile()) { const destFile = path.join(hooksDest, entry); // Template .js files to replace '.claude' with runtime-specific config dir + // and stamp the current GSD version into the hook version header if (entry.endsWith('.js')) { let content = fs.readFileSync(srcFile, 'utf8'); content = content.replace(/'\.claude'/g, configDirReplacement); + content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version); fs.writeFileSync(destFile, content); + // Ensure hook files are executable (fixes #1162 — missing +x permission) + try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ } } else { fs.copyFileSync(srcFile, destFile); } @@ -2689,6 +2944,36 @@ function install(isGlobal, runtime = 'claude') { const agentCount = installCodexConfig(targetDir, agentsSrc); console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`); console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`); + + // Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag + const configPath = path.join(targetDir, 'config.toml'); + try { + let configContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : ''; + + // Enable hooks feature flag if not present + if (!configContent.includes('codex_hooks')) { + const featuresSection = '[features]\ncodex_hooks = true\n'; + if (configContent.includes('[features]')) { + configContent = configContent.replace(/\[features\]\n/, featuresSection); + } else { + configContent = featuresSection + '\n' + configContent; + } + } + + // Add SessionStart hook for update checking + const updateCheckScript = path.resolve(targetDir, 'get-shit-done', 'hooks', 'gsd-update-check.js').replace(/\\/g, '/'); + const hookBlock = `\n# GSD Hooks\n[[hooks]]\nevent = "SessionStart"\ncommand = "node ${updateCheckScript}"\n`; + + if (!configContent.includes('gsd-update-check')) { + configContent += hookBlock; + } + + fs.writeFileSync(configPath, configContent, 'utf-8'); + console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart)`); + } catch (e) { + console.warn(` ${yellow}⚠${reset} Could not configure Codex hooks: ${e.message}`); + } + return { settingsPath: null, settings: null, statuslineCommand: null, runtime }; } @@ -2705,6 +2990,11 @@ function install(isGlobal, runtime = 'claude') { return { settingsPath: null, settings: null, statuslineCommand: null, runtime }; } + if (isCursor) { + // Cursor uses skills — no config.toml, no settings.json hooks needed + return { settingsPath: null, settings: null, statuslineCommand: null, runtime }; + } + // Configure statusline and hooks in settings.json // Gemini and Antigravity use AfterTool instead of PostToolUse for post-tool hooks const postToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse'; @@ -2788,8 +3078,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; + const isCursor = runtime === 'cursor'; - if (shouldInstallStatusline && !isOpencode && !isCodex && !isCopilot) { + if (shouldInstallStatusline && !isOpencode && !isCodex && !isCopilot && !isCursor) { settings.statusLine = { type: 'command', command: statuslineCommand @@ -2798,7 +3089,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS } // Write settings when runtime supports settings.json - if (!isCodex && !isCopilot) { + if (!isCodex && !isCopilot && !isCursor) { writeSettings(settingsPath, settings); } @@ -2813,12 +3104,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'codex') program = 'Codex'; if (runtime === 'copilot') program = 'Copilot'; if (runtime === 'antigravity') program = 'Antigravity'; + if (runtime === 'cursor') program = 'Cursor'; let command = '/gsd:new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; if (runtime === 'codex') command = '$gsd-new-project'; if (runtime === 'copilot') command = '/gsd-new-project'; if (runtime === 'antigravity') command = '/gsd-new-project'; + if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)'; console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. @@ -2902,15 +3195,18 @@ function promptRuntime(callback) { ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset} ${cyan}5${reset}) Copilot ${dim}(~/.copilot)${reset} ${cyan}6${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} - ${cyan}7${reset}) All + ${cyan}7${reset}) Cursor ${dim}(~/.cursor)${reset} + ${cyan}8${reset}) All `); rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { answered = true; rl.close(); const choice = answer.trim() || '1'; - if (choice === '7') { - callback(['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity']); + if (choice === '8') { + callback(['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor']); + } else if (choice === '7') { + callback(['cursor']); } else if (choice === '6') { callback(['antigravity']); } else if (choice === '5') { @@ -3010,7 +3306,10 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { // Test-only exports — skip main logic when loaded as a module for testing if (process.env.GSD_TEST_MODE) { module.exports = { + yamlIdentifier, getCodexSkillAdapterHeader, + convertClaudeCommandToCursorSkill, + convertClaudeAgentToCursorAgent, convertClaudeToGeminiAgent, convertClaudeAgentToCodexAgent, generateCodexAgentToml, diff --git a/commands/gsd/discuss-phase.md b/commands/gsd/discuss-phase.md index b5c926021..75ebde603 100644 --- a/commands/gsd/discuss-phase.md +++ b/commands/gsd/discuss-phase.md @@ -1,7 +1,7 @@ --- name: gsd:discuss-phase description: Gather phase context through adaptive questioning before planning. Use --auto to skip interactive questions (Claude picks recommended defaults). -argument-hint: " [--auto]" +argument-hint: " [--auto] [--batch] [--analyze]" allowed-tools: - Read - Write diff --git a/commands/gsd/execute-phase.md b/commands/gsd/execute-phase.md index 1a798471f..b0741db0c 100644 --- a/commands/gsd/execute-phase.md +++ b/commands/gsd/execute-phase.md @@ -1,7 +1,7 @@ --- name: gsd:execute-phase description: Execute all plans in a phase with wave-based parallelization -argument-hint: " [--gaps-only]" +argument-hint: " [--gaps-only] [--interactive]" allowed-tools: - Read - Write @@ -31,6 +31,7 @@ Phase: $ARGUMENTS **Flags:** - `--gaps-only` — Execute only gap closure plans (plans with `gap_closure: true` in frontmatter). Use after verify-work creates fix plans. +- `--interactive` — Execute plans sequentially inline (no subagents) with user checkpoints between tasks. Lower token usage, pair-programming style. Best for small phases, bug fixes, and verification gaps. Context files are resolved inside the workflow via `gsd-tools init execute-phase` and per-subagent `` blocks. diff --git a/commands/gsd/fast.md b/commands/gsd/fast.md new file mode 100644 index 000000000..4121f9f75 --- /dev/null +++ b/commands/gsd/fast.md @@ -0,0 +1,30 @@ +--- +name: gsd:fast +description: Execute a trivial task inline — no subagents, no planning overhead +argument-hint: "[task description]" +allowed-tools: + - Read + - Write + - Edit + - Bash + - Grep + - Glob +--- + + +Execute a trivial task directly in the current context without spawning subagents +or generating PLAN.md files. For tasks too small to justify planning overhead: +typo fixes, config changes, small refactors, forgotten commits, simple additions. + +This is NOT a replacement for /gsd:quick — use /gsd:quick for anything that +needs research, multi-step planning, or verification. /gsd:fast is for tasks +you could describe in one sentence and execute in under 2 minutes. + + + +@~/.claude/get-shit-done/workflows/fast.md + + + +Execute the fast workflow from @~/.claude/get-shit-done/workflows/fast.md end-to-end. + diff --git a/commands/gsd/next.md b/commands/gsd/next.md new file mode 100644 index 000000000..e7d81c747 --- /dev/null +++ b/commands/gsd/next.md @@ -0,0 +1,24 @@ +--- +name: gsd:next +description: Automatically advance to the next logical step in the GSD workflow +allowed-tools: + - Read + - Bash + - Grep + - Glob + - SlashCommand +--- + +Detect the current project state and automatically invoke the next logical GSD workflow step. +No arguments needed — reads STATE.md, ROADMAP.md, and phase directories to determine what comes next. + +Designed for rapid multi-project workflows where remembering which phase/step you're on is overhead. + + + +@~/.claude/get-shit-done/workflows/next.md + + + +Execute the next workflow from @~/.claude/get-shit-done/workflows/next.md end-to-end. + diff --git a/commands/gsd/session-report.md b/commands/gsd/session-report.md new file mode 100644 index 000000000..a0eb1d6ef --- /dev/null +++ b/commands/gsd/session-report.md @@ -0,0 +1,19 @@ +--- +name: gsd:session-report +description: Generate a session report with token usage estimates, work summary, and outcomes +allowed-tools: + - Read + - Bash + - Write +--- + +Generate a structured SESSION_REPORT.md document capturing session outcomes, work performed, and estimated resource usage. Provides a shareable artifact for post-session review. + + + +@~/.claude/get-shit-done/workflows/session-report.md + + + +Execute the session-report workflow from @~/.claude/get-shit-done/workflows/session-report.md end-to-end. + diff --git a/commands/gsd/ship.md b/commands/gsd/ship.md new file mode 100644 index 000000000..124695553 --- /dev/null +++ b/commands/gsd/ship.md @@ -0,0 +1,23 @@ +--- +name: gsd:ship +description: Create PR, run review, and prepare for merge after verification passes +argument-hint: "[phase number or milestone, e.g., '4' or 'v1.0']" +allowed-tools: + - Read + - Bash + - Grep + - Glob + - Write + - AskUserQuestion +--- + +Bridge local completion → merged PR. After /gsd:verify-work passes, ship the work: push branch, create PR with auto-generated body, optionally trigger review, and track the merge. + +Closes the plan → execute → verify → ship loop. + + + +@~/.claude/get-shit-done/workflows/ship.md + + +Execute the ship workflow from @~/.claude/get-shit-done/workflows/ship.md end-to-end. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0163b0cc0..3d8b4108a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -247,6 +247,14 @@ Each executor gets: - Project context (PROJECT.md, STATE.md) - Phase context (CONTEXT.md, RESEARCH.md if available) +#### Parallel Commit Safety + +When multiple executors run within the same wave, two mechanisms prevent conflicts: + +1. **`--no-verify` commits** — Parallel agents skip pre-commit hooks (which can cause build lock contention, e.g., cargo lock fights in Rust projects). The orchestrator runs `git hook run pre-commit` once after each wave completes. + +2. **STATE.md file locking** — All `writeStateMd()` calls use lockfile-based mutual exclusion (`STATE.md.lock` with `O_EXCL` atomic creation). This prevents the read-modify-write race condition where two agents read STATE.md, modify different fields, and the last writer overwrites the other's changes. Includes stale lock detection (10s timeout) and spin-wait with jitter. + --- ## Data Flow @@ -358,7 +366,7 @@ Equivalent paths for other runtimes: ``` .planning/ -├── PROJECT.md # Project vision, constraints, decisions +├── PROJECT.md # Project vision, constraints, decisions, evolution rules ├── REQUIREMENTS.md # Scoped requirements (v1/v2/out-of-scope) ├── ROADMAP.md # Phase breakdown with status tracking ├── STATE.md # Living memory: position, decisions, blockers, metrics diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index f0228496b..2621e7769 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -331,7 +331,10 @@ node gsd-tools.cjs progress [json|table|bar] node gsd-tools.cjs todo complete # Git commit with config checks -node gsd-tools.cjs commit [--files f1 f2] [--amend] +node gsd-tools.cjs commit [--files f1 f2] [--amend] [--no-verify] +``` + +> **`--no-verify`**: Skips pre-commit hooks. Used by parallel executor agents during wave-based execution to avoid build lock contention (e.g., cargo lock fights in Rust projects). The orchestrator runs hooks once after each wave completes. Do not use `--no-verify` during sequential execution — let hooks run normally. # Web search (requires Brave API key) node gsd-tools.cjs websearch [--limit N] [--freshness day|week|month] diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 5f1c9c9fe..1e2870342 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -132,6 +132,71 @@ User acceptance testing with auto-diagnosis. --- +### `/gsd:next` + +Automatically advance to the next logical workflow step. Reads project state and runs the appropriate command. + +**Prerequisites:** `.planning/` directory exists +**Behavior:** +- No project → suggests `/gsd:new-project` +- Phase needs discussion → runs `/gsd:discuss-phase` +- Phase needs planning → runs `/gsd:plan-phase` +- Phase needs execution → runs `/gsd:execute-phase` +- Phase needs verification → runs `/gsd:verify-work` +- All phases complete → suggests `/gsd:complete-milestone` + +```bash +/gsd:next # Auto-detect and run next step +``` + +--- + +### `/gsd:session-report` + +Generate a session report with work summary, outcomes, and estimated resource usage. + +**Prerequisites:** Active project with recent work +**Produces:** `.planning/reports/SESSION_REPORT.md` + +```bash +/gsd:session-report # Generate post-session summary +``` + +**Report includes:** +- Work performed (commits, plans executed, phases progressed) +- Outcomes and deliverables +- Blockers and decisions made +- Estimated token/cost usage +- Next steps recommendation + +--- + +### `/gsd:ship` + +Create PR from completed phase work with auto-generated body. + +| Argument | Required | Description | +|----------|----------|-------------| +| `N` | No | Phase number or milestone version (e.g., `4` or `v1.0`) | +| `--draft` | No | Create as draft PR | + +**Prerequisites:** Phase verified (`/gsd:verify-work` passed), `gh` CLI installed and authenticated +**Produces:** GitHub PR with rich body from planning artifacts, STATE.md updated + +```bash +/gsd:ship 4 # Ship phase 4 +/gsd:ship 4 --draft # Ship as draft PR +``` + +**PR body includes:** +- Phase goal from ROADMAP.md +- Changes summary from SUMMARY.md files +- Requirements addressed (REQ-IDs) +- Verification status +- Key decisions + +--- + ### `/gsd:ui-review` Retroactive 6-pillar visual audit of implemented frontend. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3d1631752..ce991803b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -133,6 +133,8 @@ To keep planning artifacts out of git: | `parallelization.max_concurrent_agents` | number | `3` | Maximum simultaneous agents | | `parallelization.min_plans_for_parallel` | number | `2` | Minimum plans to trigger parallel execution | +> **Pre-commit hooks and parallel execution**: When parallelization is enabled, executor agents commit with `--no-verify` to avoid build lock contention (e.g., cargo lock fights in Rust projects). The orchestrator validates hooks once after each wave completes. STATE.md writes are protected by file-level locking to prevent concurrent write corruption. If you need hooks to run per-commit, set `parallelization.enabled: false`. + --- ## Git Branching @@ -246,7 +248,7 @@ Valid override values: `opus`, `sonnet`, `haiku`, `inherit` | `quality` | Opus for all decision-making, Sonnet for verification | Quota available, critical architecture work | | `balanced` | Opus for planning only, Sonnet for everything else | Normal development (default) | | `budget` | Sonnet for code-writing, Haiku for research/verification | High-volume work, less critical phases | -| `inherit` | All agents use current session model | Dynamic model switching (OpenCode `/model`) | +| `inherit` | All agents use current session model | Dynamic model switching, **non-Anthropic providers** (OpenRouter, local models) | --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 27afed54e..f62553412 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -20,33 +20,38 @@ - [Quick Mode](#10-quick-mode) - [Autonomous Mode](#11-autonomous-mode) - [Freeform Routing](#12-freeform-routing) + - [Note Capture](#13-note-capture) + - [Auto-Advance (Next)](#14-auto-advance-next) - [Quality Assurance Features](#quality-assurance-features) - - [Nyquist Validation](#13-nyquist-validation) - - [Plan Checking](#14-plan-checking) - - [Post-Execution Verification](#15-post-execution-verification) - - [Node Repair](#16-node-repair) - - [Health Validation](#17-health-validation) + - [Nyquist Validation](#15-nyquist-validation) + - [Plan Checking](#16-plan-checking) + - [Post-Execution Verification](#17-post-execution-verification) + - [Node Repair](#18-node-repair) + - [Health Validation](#19-health-validation) + - [Cross-Phase Regression Gate](#20-cross-phase-regression-gate) + - [Requirements Coverage Gate](#21-requirements-coverage-gate) - [Context Engineering Features](#context-engineering-features) - - [Context Window Monitoring](#18-context-window-monitoring) - - [Session Management](#19-session-management) - - [Multi-Agent Orchestration](#20-multi-agent-orchestration) - - [Model Profiles](#21-model-profiles) + - [Context Window Monitoring](#22-context-window-monitoring) + - [Session Management](#23-session-management) + - [Session Reporting](#24-session-reporting) + - [Multi-Agent Orchestration](#25-multi-agent-orchestration) + - [Model Profiles](#26-model-profiles) - [Brownfield Features](#brownfield-features) - - [Codebase Mapping](#22-codebase-mapping) + - [Codebase Mapping](#27-codebase-mapping) - [Utility Features](#utility-features) - - [Debug System](#23-debug-system) - - [Todo Management](#24-todo-management) - - [Statistics Dashboard](#25-statistics-dashboard) - - [Update System](#26-update-system) - - [Settings Management](#27-settings-management) - - [Test Generation](#28-test-generation) + - [Debug System](#28-debug-system) + - [Todo Management](#29-todo-management) + - [Statistics Dashboard](#30-statistics-dashboard) + - [Update System](#31-update-system) + - [Settings Management](#32-settings-management) + - [Test Generation](#33-test-generation) - [Infrastructure Features](#infrastructure-features) - - [Git Integration](#29-git-integration) - - [CLI Tools](#30-cli-tools) - - [Multi-Runtime Support](#31-multi-runtime-support) - - [Hook System](#32-hook-system) - - [Developer Profiling](#33-developer-profiling) - - [Execution Hardening](#34-execution-hardening) + - [Git Integration](#34-git-integration) + - [CLI Tools](#35-cli-tools) + - [Multi-Runtime Support](#36-multi-runtime-support) + - [Hook System](#37-hook-system) + - [Developer Profiling](#38-developer-profiling) + - [Execution Hardening](#39-execution-hardening) --- @@ -70,7 +75,7 @@ **Produces:** | Artifact | Description | |----------|-------------| -| `PROJECT.md` | Project vision, constraints, technical decisions | +| `PROJECT.md` | Project vision, constraints, technical decisions, evolution rules | | `REQUIREMENTS.md` | Scoped requirements with unique IDs (REQ-XX) | | `ROADMAP.md` | Phase breakdown with status tracking and requirement mapping | | `STATE.md` | Initial project state with position, decisions, metrics | @@ -171,6 +176,7 @@ - REQ-PLAN-06: System MUST support `--skip-research` flag to bypass research phase - REQ-PLAN-07: System MUST prompt user to run `/gsd:ui-phase` if frontend phase detected and no UI-SPEC.md exists (UI safety gate) - REQ-PLAN-08: System MUST include Nyquist validation mapping when `workflow.nyquist_validation` is enabled +- REQ-PLAN-09: System MUST verify all phase requirements are covered by at least one plan before planning completes (requirements coverage gate) **Produces:** | Artifact | Description | @@ -220,6 +226,7 @@ - REQ-EXEC-06: System MUST run post-execution verifier to check phase goals were met - REQ-EXEC-07: System MUST support git branching strategies (`none`, `phase`, `milestone`) - REQ-EXEC-08: System MUST invoke node repair operator on task verification failure (when enabled) +- REQ-EXEC-09: System MUST run prior phases' test suites before verification to catch cross-phase regressions **Produces:** | Artifact | Description | @@ -238,9 +245,14 @@ - Reads PLAN.md with full task instructions - Has access to PROJECT.md, STATE.md, CONTEXT.md, RESEARCH.md - Commits each task atomically with structured commit messages +- Uses `--no-verify` on commits during parallel execution to avoid build lock contention - Handles checkpoint types: `auto`, `checkpoint:human-verify`, `checkpoint:decision`, `checkpoint:human-action` - Reports deviations from plan in SUMMARY.md +**Parallel Safety:** +- **Pre-commit hooks**: Skipped by parallel agents (`--no-verify`), run once by orchestrator after each wave +- **STATE.md locking**: File-level lockfile prevents concurrent write corruption across agents + --- ### 6. Work Verification @@ -261,6 +273,25 @@ --- +### 6.5. Ship + +**Command:** `/gsd:ship [N] [--draft]` + +**Purpose:** Bridge local completion → merged PR. After verification passes, push branch, create PR with auto-generated body from planning artifacts, optionally trigger review, and track in STATE.md. + +**Requirements:** +- REQ-SHIP-01: System MUST verify phase has passed verification before shipping +- REQ-SHIP-02: System MUST push branch and create PR via `gh` CLI +- REQ-SHIP-03: System MUST auto-generate PR body from SUMMARY.md, VERIFICATION.md, and REQUIREMENTS.md +- REQ-SHIP-04: System MUST update STATE.md with shipping status and PR number +- REQ-SHIP-05: System MUST support `--draft` flag for draft PRs + +**Prerequisites:** Phase verified, `gh` CLI installed and authenticated, work on feature branch + +**Produces:** GitHub PR with rich body, STATE.md updated + +--- + ### 7. UI Review **Command:** `/gsd:ui-review [N]` @@ -387,9 +418,34 @@ --- +### 14. Auto-Advance (Next) + +**Command:** `/gsd:next` + +**Purpose:** Automatically detect current project state and advance to the next logical workflow step, eliminating the need to remember which phase/step you're on. + +**Requirements:** +- REQ-NEXT-01: System MUST read STATE.md, ROADMAP.md, and phase directories to determine current position +- REQ-NEXT-02: System MUST detect whether discuss, plan, execute, or verify is needed +- REQ-NEXT-03: System MUST invoke the correct command automatically +- REQ-NEXT-04: System MUST suggest `/gsd:new-project` if no project exists +- REQ-NEXT-05: System MUST suggest `/gsd:complete-milestone` when all phases are complete + +**State Detection Logic:** +| State | Action | +|-------|--------| +| No `.planning/` directory | Suggest `/gsd:new-project` | +| Phase has no CONTEXT.md | Run `/gsd:discuss-phase` | +| Phase has no PLAN.md files | Run `/gsd:plan-phase` | +| Phase has plans but no SUMMARY.md | Run `/gsd:execute-phase` | +| Phase executed but no VERIFICATION.md | Run `/gsd:verify-work` | +| All phases complete | Suggest `/gsd:complete-milestone` | + +--- + ## Quality Assurance Features -### 14. Nyquist Validation +### 15. Nyquist Validation **Purpose:** Map automated test coverage to phase requirements before any code is written. Named after the Nyquist sampling theorem — ensures a feedback signal exists for every requirement. @@ -412,7 +468,7 @@ --- -### 15. Plan Checking +### 16. Plan Checking **Purpose:** Goal-backward verification that plans will achieve phase objectives before execution. @@ -424,7 +480,7 @@ --- -### 16. Post-Execution Verification +### 17. Post-Execution Verification **Purpose:** Automated check that the codebase delivers what the phase promised. @@ -436,7 +492,7 @@ --- -### 17. Node Repair +### 18. Node Repair **Purpose:** Autonomous recovery when task verification fails during execution. @@ -450,7 +506,7 @@ --- -### 18. Health Validation +### 19. Health Validation **Command:** `/gsd:health [--repair]` @@ -465,9 +521,37 @@ --- +### 20. Cross-Phase Regression Gate + +**Purpose:** Prevent regressions from compounding across phases by running prior phases' test suites after execution. + +**Requirements:** +- REQ-REGR-01: System MUST run test suites from all completed prior phases after phase execution +- REQ-REGR-02: System MUST report any test failures as cross-phase regressions +- REQ-REGR-03: Regressions MUST be surfaced before post-execution verification +- REQ-REGR-04: System MUST identify which prior phase's tests were broken + +**When:** Runs automatically during `/gsd:execute-phase` before the verifier step. + +--- + +### 21. Requirements Coverage Gate + +**Purpose:** Ensure all phase requirements are covered by at least one plan before planning completes. + +**Requirements:** +- REQ-COVGATE-01: System MUST extract all requirement IDs assigned to the phase from ROADMAP.md +- REQ-COVGATE-02: System MUST verify each requirement appears in at least one PLAN.md +- REQ-COVGATE-03: Uncovered requirements MUST block planning completion +- REQ-COVGATE-04: System MUST report which specific requirements lack plan coverage + +**When:** Runs automatically at the end of `/gsd:plan-phase` after the plan checker loop. + +--- + ## Context Engineering Features -### 19. Context Window Monitoring +### 22. Context Window Monitoring **Purpose:** Prevent context rot by alerting both user and agent when context is running low. @@ -487,22 +571,49 @@ --- -### 20. Session Management +### 23. Session Management **Commands:** `/gsd:pause-work`, `/gsd:resume-work`, `/gsd:progress` **Purpose:** Maintain project continuity across context resets and sessions. **Requirements:** -- REQ-SESSION-01: Pause MUST save current position and next steps to `continue-here.md` -- REQ-SESSION-02: Resume MUST restore full project context from state files +- REQ-SESSION-01: Pause MUST save current position and next steps to `continue-here.md` and structured `HANDOFF.json` +- REQ-SESSION-02: Resume MUST restore full project context from HANDOFF.json (preferred) or state files (fallback) - REQ-SESSION-03: Progress MUST show current position, next action, and overall completion - REQ-SESSION-04: Progress MUST read all state files (STATE.md, ROADMAP.md, phase directories) - REQ-SESSION-05: All session operations MUST work after `/clear` (context reset) +- REQ-SESSION-06: HANDOFF.json MUST include blockers, human actions pending, and in-progress task state +- REQ-SESSION-07: Resume MUST surface human actions and blockers immediately on session start --- -### 21. Multi-Agent Orchestration +### 24. Session Reporting + +**Command:** `/gsd:session-report` + +**Purpose:** Generate a structured post-session summary document capturing work performed, outcomes achieved, and estimated resource usage. + +**Requirements:** +- REQ-REPORT-01: System MUST gather data from STATE.md, git log, and plan/summary files +- REQ-REPORT-02: System MUST include commits made, plans executed, and phases progressed +- REQ-REPORT-03: System MUST estimate token usage and cost based on session activity +- REQ-REPORT-04: System MUST include active blockers and decisions made +- REQ-REPORT-05: System MUST recommend next steps + +**Produces:** `.planning/reports/SESSION_REPORT.md` + +**Report Sections:** +- Session overview (duration, milestone, phase) +- Work performed (commits, plans, phases) +- Outcomes and deliverables +- Blockers and decisions +- Resource estimates (tokens, cost) +- Next steps recommendation + +--- + +### 25. Multi-Agent Orchestration **Purpose:** Coordinate specialized agents with fresh context windows for each task. @@ -516,7 +627,7 @@ --- -### 22. Model Profiles +### 26. Model Profiles **Command:** `/gsd:set-profile ` @@ -527,6 +638,7 @@ - REQ-MODEL-02: Each profile MUST define model tier per agent (see profile table) - REQ-MODEL-03: Per-agent overrides MUST take precedence over profile - REQ-MODEL-04: `inherit` profile MUST defer to runtime's current model selection +- REQ-MODEL-04a: `inherit` profile MUST be used when running non-Anthropic providers (OpenRouter, local models) to avoid unexpected API costs - REQ-MODEL-05: Profile switch MUST be programmatic (script, not LLM-driven) - REQ-MODEL-06: Model resolution MUST happen once per orchestration, not per spawn @@ -551,7 +663,7 @@ ## Brownfield Features -### 23. Codebase Mapping +### 27. Codebase Mapping **Command:** `/gsd:map-codebase [area]` @@ -579,7 +691,7 @@ ## Utility Features -### 24. Debug System +### 28. Debug System **Command:** `/gsd:debug [description]` @@ -597,7 +709,7 @@ --- -### 25. Todo Management +### 29. Todo Management **Commands:** `/gsd:add-todo [desc]`, `/gsd:check-todos` @@ -611,7 +723,7 @@ --- -### 26. Statistics Dashboard +### 30. Statistics Dashboard **Command:** `/gsd:stats` @@ -625,7 +737,7 @@ --- -### 27. Update System +### 31. Update System **Command:** `/gsd:update` @@ -640,7 +752,7 @@ --- -### 28. Settings Management +### 32. Settings Management **Command:** `/gsd:settings` @@ -673,7 +785,7 @@ --- -### 29. Test Generation +### 33. Test Generation **Command:** `/gsd:add-tests [N]` @@ -688,7 +800,7 @@ ## Infrastructure Features -### 30. Git Integration +### 34. Git Integration **Purpose:** Atomic commits, branching strategies, and clean history management. @@ -714,7 +826,7 @@ fix(03-01): correct auth token expiry --- -### 31. CLI Tools +### 35. CLI Tools **Purpose:** Programmatic utilities for workflows and agents, replacing repetitive inline bash patterns. @@ -729,7 +841,7 @@ fix(03-01): correct auth token expiry --- -### 32. Multi-Runtime Support +### 36. Multi-Runtime Support **Purpose:** Run GSD across 6 different AI coding agent runtimes. @@ -752,7 +864,7 @@ fix(03-01): correct auth token expiry --- -### 33. Hook System +### 37. Hook System **Purpose:** Runtime event hooks for context monitoring, status display, and update checking. @@ -772,7 +884,7 @@ fix(03-01): correct auth token expiry Color coding: <50% green, <65% yellow, <80% orange, ≥80% red with skull emoji -### 33. Developer Profiling +### 38. Developer Profiling **Command:** `/gsd:profile-user [--questionnaire] [--refresh]` @@ -808,7 +920,7 @@ Color coding: <50% green, <65% yellow, <80% orange, ≥80% red with skull emoji - REQ-PROF-03: Questionnaire MUST be available as fallback when no session history exists - REQ-PROF-04: Generated artifacts MUST be discoverable by Claude Code (CLAUDE.md integration) -### 34. Execution Hardening +### 39. Execution Hardening **Purpose:** Three additive quality improvements to the execution pipeline that catch cross-plan failures before they cascade. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 5458cedc0..948fa9899 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -50,6 +50,10 @@ A detailed reference for workflows, troubleshooting, and configuration. For quic │ │ /gsd:verify-work │ │ <- Manual UAT │ └──────────┬─────────┘ │ │ │ │ + │ ┌──────────▼─────────┐ │ + │ │ /gsd:ship │ │ <- Create PR (optional) + │ └──────────┬─────────┘ │ + │ │ │ │ Next Phase?────────────┘ │ │ No └─────────────┼──────────────┘ @@ -284,6 +288,8 @@ Controlled by `workflow.ui_safety_gate` config toggle. | `/gsd:plan-phase [N]` | Research + plan + verify | Before executing a phase | | `/gsd:execute-phase ` | Execute all plans in parallel waves | After planning is complete | | `/gsd:verify-work [N]` | Manual UAT with auto-diagnosis | After execution completes | +| `/gsd:ship [N]` | Create PR from verified work | After verification passes | +| `/gsd:next` | Auto-detect state and run next step | Anytime — "what should I do next?" | | `/gsd:ui-review [N]` | Retroactive 6-pillar visual audit | After execution or verify-work (frontend projects) | | `/gsd:audit-milestone` | Verify milestone met its definition of done | Before completing milestone | | `/gsd:complete-milestone` | Archive milestone, tag release | All phases verified | @@ -295,7 +301,8 @@ Controlled by `workflow.ui_safety_gate` config toggle. |---------|---------|-------------| | `/gsd:progress` | Show status and next steps | Anytime -- "where am I?" | | `/gsd:resume-work` | Restore full context from last session | Starting a new session | -| `/gsd:pause-work` | Save context handoff | Stopping mid-phase | +| `/gsd:pause-work` | Save structured handoff (HANDOFF.json + continue-here.md) | Stopping mid-phase | +| `/gsd:session-report` | Generate session summary with work and outcomes | End of session, stakeholder sharing | | `/gsd:help` | Show all commands | Quick reference | | `/gsd:update` | Update GSD with changelog preview | Check for new versions | | `/gsd:join-discord` | Open Discord community invite | Questions or community | @@ -363,7 +370,7 @@ GSD stores project settings in `.planning/config.json`. Configure during `/gsd:n |---------|---------|---------|------------------| | `mode` | `interactive`, `yolo` | `interactive` | `yolo` auto-approves decisions; `interactive` confirms at each step | | `granularity` | `coarse`, `standard`, `fine` | `standard` | Phase granularity: how finely scope is sliced (3-5, 5-8, or 8-12 phases) | -| `model_profile` | `quality`, `balanced`, `budget`, `inherit` | `balanced` | Model tier for each agent (see table below) | +| `model_profile` | `quality`, `balanced`, `budget`, `inherit` | `balanced` | Model tier for each agent (see table below) | ### Planning Settings @@ -407,25 +414,25 @@ Disable these to speed up phases in familiar domains or when conserving tokens. ### Model Profiles (Per-Agent Breakdown) -| Agent | `quality` | `balanced` | `budget` | `inherit` | -|-------|-----------|------------|----------|-----------| -| gsd-planner | Opus | Opus | Sonnet | Inherit | -| gsd-roadmapper | Opus | Sonnet | Sonnet | Inherit | -| gsd-executor | Opus | Sonnet | Sonnet | Inherit | -| gsd-phase-researcher | Opus | Sonnet | Haiku | Inherit | -| gsd-project-researcher | Opus | Sonnet | Haiku | Inherit | -| gsd-research-synthesizer | Sonnet | Sonnet | Haiku | Inherit | -| gsd-debugger | Opus | Sonnet | Sonnet | Inherit | -| gsd-codebase-mapper | Sonnet | Haiku | Haiku | Inherit | -| gsd-verifier | Sonnet | Sonnet | Haiku | Inherit | -| gsd-plan-checker | Sonnet | Sonnet | Haiku | Inherit | -| gsd-integration-checker | Sonnet | Sonnet | Haiku | Inherit | +| Agent | `quality` | `balanced` | `budget` | `inherit` | +|-------|-----------|------------|----------|-----------| +| gsd-planner | Opus | Opus | Sonnet | Inherit | +| gsd-roadmapper | Opus | Sonnet | Sonnet | Inherit | +| gsd-executor | Opus | Sonnet | Sonnet | Inherit | +| gsd-phase-researcher | Opus | Sonnet | Haiku | Inherit | +| gsd-project-researcher | Opus | Sonnet | Haiku | Inherit | +| gsd-research-synthesizer | Sonnet | Sonnet | Haiku | Inherit | +| gsd-debugger | Opus | Sonnet | Sonnet | Inherit | +| gsd-codebase-mapper | Sonnet | Haiku | Haiku | Inherit | +| gsd-verifier | Sonnet | Sonnet | Haiku | Inherit | +| gsd-plan-checker | Sonnet | Sonnet | Haiku | Inherit | +| gsd-integration-checker | Sonnet | Sonnet | Haiku | Inherit | **Profile philosophy:** -- **quality** -- Opus for all decision-making agents, Sonnet for read-only verification. Use when quota is available and the work is critical. -- **balanced** -- Opus only for planning (where architecture decisions happen), Sonnet for everything else. The default for good reason. -- **budget** -- Sonnet for anything that writes code, Haiku for research and verification. Use for high-volume work or less critical phases. -- **inherit** -- All agents use the current session model. Best when switching models dynamically (for example OpenCode `/model`). +- **quality** -- Opus for all decision-making agents, Sonnet for read-only verification. Use when quota is available and the work is critical. +- **balanced** -- Opus only for planning (where architecture decisions happen), Sonnet for everything else. The default for good reason. +- **budget** -- Sonnet for anything that writes code, Haiku for research and verification. Use for high-volume work or less critical phases. +- **inherit** -- All agents use the current session model. Best when switching models dynamically (e.g. OpenCode `/model`), or **required** when using non-Anthropic providers (OpenRouter, local models) to avoid unexpected API costs. --- @@ -442,12 +449,14 @@ claude --dangerously-skip-permissions /gsd:plan-phase 1 # Research + plan + verify /gsd:execute-phase 1 # Parallel execution /gsd:verify-work 1 # Manual UAT +/gsd:ship 1 # Create PR from verified work /gsd:ui-review 1 # Visual audit (frontend phases) /clear -/gsd:discuss-phase 2 # Repeat for each phase +/gsd:next # Auto-detect and run next step ... /gsd:audit-milestone # Check everything shipped /gsd:complete-milestone # Archive, tag, done +/gsd:session-report # Generate session summary ``` ### New Project from Existing Document @@ -539,6 +548,10 @@ Do not re-run `/gsd:execute-phase`. Use `/gsd:quick` for targeted fixes, or `/gs Switch to budget profile: `/gsd:set-profile budget`. Disable research and plan-check agents via `/gsd:settings` if the domain is familiar to you (or to Claude). +### Using Non-Anthropic Models (OpenRouter, Local) + +If GSD subagents call Anthropic models and you're paying through OpenRouter or a local provider, switch to the `inherit` profile: `/gsd:set-profile inherit`. This makes all agents use your current session model instead of specific Anthropic models. See also `/gsd:settings` → Model Profile → Inherit. + ### Working on a Sensitive/Private Project Set `commit_docs: false` during `/gsd:new-project` or via `/gsd:settings`. Add `.planning/` to your `.gitignore`. Planning artifacts stay local and never touch git. @@ -551,6 +564,21 @@ Since v1.17, the installer backs up locally modified files to `gsd-local-patches A known workaround exists for a Claude Code classification bug. GSD's orchestrators (execute-phase, quick) spot-check actual output before reporting failure. If you see a failure message but commits were made, check `git log` -- the work may have succeeded. +### Parallel Execution Causes Build Lock Errors + +If you see pre-commit hook failures, cargo lock contention, or 30+ minute execution times during parallel wave execution, this is caused by multiple agents triggering build tools simultaneously. GSD handles this automatically since v1.26 — parallel agents use `--no-verify` on commits and the orchestrator runs hooks once after each wave. If you're on an older version, add this to your project's `CLAUDE.md`: + +```markdown +## Git Commit Rules for Agents +All subagent/executor commits MUST use `--no-verify`. +``` + +To disable parallel execution entirely: `/gsd:settings` → set `parallelization.enabled` to `false`. + +### Windows: Installation Crashes on Protected Directories + +If the installer crashes with `EPERM: operation not permitted, scandir` on Windows, this is caused by OS-protected directories (e.g., Chromium browser profiles). Fixed since v1.24 — update to the latest version. As a workaround, temporarily rename the problematic directory before running the installer. + --- ## Recovery Quick Reference @@ -566,6 +594,9 @@ A known workaround exists for a Claude Code classification bug. GSD's orchestrat | Plan doesn't match your vision | `/gsd:discuss-phase [N]` then re-plan | | Costs running high | `/gsd:set-profile budget` and `/gsd:settings` to toggle agents off | | Update broke local changes | `/gsd:reapply-patches` | +| Want session summary for stakeholder | `/gsd:session-report` | +| Don't know what step is next | `/gsd:next` | +| Parallel execution build errors | Update GSD or set `parallelization.enabled: false` | --- @@ -581,7 +612,9 @@ For reference, here is what GSD creates in your project: STATE.md # Decisions, blockers, session memory config.json # Workflow configuration MILESTONES.md # Completed milestone archive + HANDOFF.json # Structured session handoff (from /gsd:pause-work) research/ # Domain research from /gsd:new-project + reports/ # Session reports (from /gsd:session-report) todos/ pending/ # Captured ideas awaiting work done/ # Completed todos diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index f0246b741..5947957a2 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -15,9 +15,11 @@ * state get [section] Get STATE.md content or section * state patch --field val ... Batch update STATE.md fields * state begin-phase --phase N --name S --plans C Update STATE.md for new phase start + * state signal-waiting --type T --question Q --options "A|B" --phase P Write WAITING.json signal + * state signal-resume Remove WAITING.json signal * resolve-model Get model for agent based on profile * find-phase Find phase directory by number - * commit [--files f1 f2] Commit planning docs + * commit [--files f1 f2] [--no-verify] Commit planning docs * verify-summary Verify a SUMMARY.md file * generate-slug Convert text to URL-safe slug * current-timestamp [format] Get timestamp (full|date|filename) @@ -255,6 +257,21 @@ async function main() { plansIdx !== -1 ? parseInt(args[plansIdx + 1], 10) : null, raw ); + } else if (subcommand === 'signal-waiting') { + const typeIdx = args.indexOf('--type'); + const qIdx = args.indexOf('--question'); + const optIdx = args.indexOf('--options'); + const phaseIdx = args.indexOf('--phase'); + state.cmdSignalWaiting( + cwd, + typeIdx !== -1 ? args[typeIdx + 1] : null, + qIdx !== -1 ? args[qIdx + 1] : null, + optIdx !== -1 ? args[optIdx + 1] : null, + phaseIdx !== -1 ? args[phaseIdx + 1] : null, + raw + ); + } else if (subcommand === 'signal-resume') { + state.cmdSignalResume(cwd, raw); } else { state.cmdStateLoad(cwd, raw); } @@ -273,6 +290,7 @@ async function main() { case 'commit': { const amend = args.includes('--amend'); + const noVerify = args.includes('--no-verify'); const filesIndex = args.indexOf('--files'); // Collect all positional args between command name and first flag, // then join them — handles both quoted ("multi word msg") and @@ -281,7 +299,7 @@ async function main() { const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--')); const message = messageArgs.join(' ') || undefined; const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : []; - commands.cmdCommit(cwd, message, files, raw, amend); + commands.cmdCommit(cwd, message, files, raw, amend, noVerify); break; } @@ -522,8 +540,10 @@ async function main() { const subcommand = args[1]; if (subcommand === 'complete') { commands.cmdTodoComplete(cwd, args[2], raw); + } else if (subcommand === 'match-phase') { + commands.cmdTodoMatchPhase(cwd, args[2], raw); } else { - error('Unknown todo subcommand. Available: complete'); + error('Unknown todo subcommand. Available: complete, match-phase'); } break; } diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 73decc8bd..feeb7557f 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs'); +const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, getRoadmapPhaseInternal } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { MODEL_PROFILES } = require('./model-profiles.cjs'); @@ -71,9 +71,9 @@ function cmdListTodos(cwd, area, raw) { area: todoArea, path: toPosixPath(path.join('.planning', 'todos', 'pending', file)), }); - } catch {} + } catch { /* intentionally empty */ } } - } catch {} + } catch { /* intentionally empty */ } const result = { count, todos }; output(result, raw, count.toString()); @@ -120,7 +120,7 @@ function cmdHistoryDigest(cwd, raw) { for (const dir of currentDirs) { allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null }); } - } catch {} + } catch { /* intentionally empty */ } } if (allPhaseDirs.length === 0) { @@ -214,7 +214,7 @@ function cmdResolveModel(cwd, agentType, raw) { output(result, raw, model); } -function cmdCommit(cwd, message, files, raw, amend) { +function cmdCommit(cwd, message, files, raw, amend, noVerify) { if (!message && !amend) { error('commit message required'); } @@ -241,8 +241,9 @@ function cmdCommit(cwd, message, files, raw, amend) { execGit(cwd, ['add', file]); } - // Commit + // Commit (--no-verify skips pre-commit hooks, used by parallel executor agents) const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message]; + if (noVerify) commitArgs.push('--no-verify'); const commitResult = execGit(cwd, commitArgs); if (commitResult.exitCode !== 0) { if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) { @@ -412,7 +413,7 @@ function cmdProgressRender(cwd, format, raw) { phases.push({ number: phaseNum, name: phaseName, plans, summaries, status }); } - } catch {} + } catch { /* intentionally empty */ } const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0; @@ -448,6 +449,130 @@ function cmdProgressRender(cwd, format, raw) { } } +/** + * Match pending todos against a phase's goal/name/requirements. + * Returns todos with relevance scores based on keyword, area, and file overlap. + * Used by discuss-phase to surface relevant todos before scope-setting. + */ +function cmdTodoMatchPhase(cwd, phase, raw) { + if (!phase) { error('phase required for todo match-phase'); } + + const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); + const todos = []; + + // Load pending todos + try { + const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md')); + for (const file of files) { + try { + const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8'); + const titleMatch = content.match(/^title:\s*(.+)$/m); + const areaMatch = content.match(/^area:\s*(.+)$/m); + const filesMatch = content.match(/^files:\s*(.+)$/m); + const body = content.replace(/^(title|area|files|created|priority):.*$/gm, '').trim(); + + todos.push({ + file, + title: titleMatch ? titleMatch[1].trim() : 'Untitled', + area: areaMatch ? areaMatch[1].trim() : 'general', + files: filesMatch ? filesMatch[1].trim().split(/[,\s]+/).filter(Boolean) : [], + body: body.slice(0, 200), // first 200 chars for context + }); + } catch {} + } + } catch {} + + if (todos.length === 0) { + output({ phase, matches: [], todo_count: 0 }, raw); + return; + } + + // Load phase goal/name from ROADMAP + const phaseInfo = getRoadmapPhaseInternal(cwd, phase); + const phaseName = phaseInfo ? (phaseInfo.phase_name || '') : ''; + const phaseGoal = phaseInfo ? (phaseInfo.goal || '') : ''; + const phaseSection = phaseInfo ? (phaseInfo.section || '') : ''; + + // Build keyword set from phase name + goal + section text + const phaseText = `${phaseName} ${phaseGoal} ${phaseSection}`.toLowerCase(); + const stopWords = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'will', 'are', 'was', 'has', 'have', 'been', 'not', 'but', 'all', 'can', 'into', 'each', 'when', 'any', 'use', 'new']); + const phaseKeywords = new Set( + phaseText.split(/[\s\-_/.,;:()\[\]{}|]+/) + .map(w => w.replace(/[^a-z0-9]/g, '')) + .filter(w => w.length > 2 && !stopWords.has(w)) + ); + + // Find phase directory to get expected file paths + const phaseInfoDisk = findPhaseInternal(cwd, phase); + const phasePlans = []; + if (phaseInfoDisk && phaseInfoDisk.found) { + try { + const phaseDir = path.join(cwd, phaseInfoDisk.directory); + const planFiles = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md')); + for (const pf of planFiles) { + try { + const planContent = fs.readFileSync(path.join(phaseDir, pf), 'utf-8'); + const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/); + if (fmFiles) { + phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)); + } + } catch {} + } + } catch {} + } + + // Score each todo for relevance + const matches = []; + for (const todo of todos) { + let score = 0; + const reasons = []; + + // Keyword match: todo title/body terms in phase text + const todoWords = `${todo.title} ${todo.body}`.toLowerCase() + .split(/[\s\-_/.,;:()\[\]{}|]+/) + .map(w => w.replace(/[^a-z0-9]/g, '')) + .filter(w => w.length > 2 && !stopWords.has(w)); + + const matchedKeywords = todoWords.filter(w => phaseKeywords.has(w)); + if (matchedKeywords.length > 0) { + score += Math.min(matchedKeywords.length * 0.2, 0.6); + reasons.push(`keywords: ${[...new Set(matchedKeywords)].slice(0, 5).join(', ')}`); + } + + // Area match: todo area appears in phase text + if (todo.area !== 'general' && phaseText.includes(todo.area.toLowerCase())) { + score += 0.3; + reasons.push(`area: ${todo.area}`); + } + + // File match: todo files overlap with phase plan files + if (todo.files.length > 0 && phasePlans.length > 0) { + const fileOverlap = todo.files.filter(f => + phasePlans.some(pf => pf.includes(f) || f.includes(pf)) + ); + if (fileOverlap.length > 0) { + score += 0.4; + reasons.push(`files: ${fileOverlap.slice(0, 3).join(', ')}`); + } + } + + if (score > 0) { + matches.push({ + file: todo.file, + title: todo.title, + area: todo.area, + score: Math.round(score * 100) / 100, + reasons, + }); + } + } + + // Sort by score descending + matches.sort((a, b) => b.score - a.score); + + output({ phase, matches, todo_count: todos.length }, raw); +} + function cmdTodoComplete(cwd, filename, raw) { if (!filename) { error('filename required for todo complete'); @@ -547,7 +672,7 @@ function cmdStats(cwd, format, raw) { let totalSummaries = 0; try { - const roadmapContent = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8')); + const roadmapContent = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd); const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; let match; while ((match = headingPattern.exec(roadmapContent)) !== null) { @@ -559,7 +684,7 @@ function cmdStats(cwd, format, raw) { status: 'Not Started', }); } - } catch {} + } catch { /* intentionally empty */ } try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); @@ -595,7 +720,7 @@ function cmdStats(cwd, format, raw) { status, }); } - } catch {} + } catch { /* intentionally empty */ } const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number)); const completedPhases = phases.filter(p => p.status === 'Complete').length; @@ -613,7 +738,7 @@ function cmdStats(cwd, format, raw) { requirementsComplete = checked ? checked.length : 0; requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0); } - } catch {} + } catch { /* intentionally empty */ } // Last activity from STATE.md let lastActivity = null; @@ -626,7 +751,7 @@ function cmdStats(cwd, format, raw) { || stateContent.match(/^Last activity:\s*(.+)$/im); if (activityMatch) lastActivity = activityMatch[1].trim(); } - } catch {} + } catch { /* intentionally empty */ } // Git stats let gitCommits = 0; @@ -704,6 +829,7 @@ module.exports = { cmdWebsearch, cmdProgressRender, cmdTodoComplete, + cmdTodoMatchPhase, cmdScaffold, cmdStats, }; diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 0625d80dc..cf4459bd0 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -71,7 +71,7 @@ function buildNewProjectConfig(userChoices) { delete userDefaults.depth; try { fs.writeFileSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2), 'utf-8'); - } catch {} + } catch { /* intentionally empty */ } } } } catch { diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 8c1b427f8..66918b018 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -64,6 +64,8 @@ function loadConfig(cwd) { nyquist_validation: true, parallelization: true, brave_search: false, + resolve_model_ids: false, // when true, resolve aliases (opus/sonnet/haiku) to full model IDs + context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models }; try { @@ -75,7 +77,7 @@ function loadConfig(cwd) { const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth; delete parsed.depth; - try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {} + try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ } } const get = (key, nested) => { @@ -106,6 +108,8 @@ function loadConfig(cwd) { nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation, parallelization, brave_search: get('brave_search') ?? defaults.brave_search, + resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids, + context_window: get('context_window') ?? defaults.context_window, model_overrides: parsed.model_overrides || null, }; } catch { @@ -248,6 +252,27 @@ function execGit(cwd, args) { }; } +// ─── Common path helpers ────────────────────────────────────────────────────── + +/** Get the .planning directory path */ +function planningDir(cwd) { + return path.join(cwd, '.planning'); +} + +/** Get common .planning file paths */ +function planningPaths(cwd) { + const base = path.join(cwd, '.planning'); + return { + planning: base, + state: path.join(base, 'STATE.md'), + roadmap: path.join(base, 'ROADMAP.md'), + project: path.join(base, 'PROJECT.md'), + config: path.join(base, 'config.json'), + phases: path.join(base, 'phases'), + requirements: path.join(base, 'REQUIREMENTS.md'), + }; +} + // ─── Phase utilities ────────────────────────────────────────────────────────── function escapeRegex(value) { @@ -368,7 +393,7 @@ function findPhaseInternal(cwd, phase) { return result; } } - } catch {} + } catch { /* intentionally empty */ } return null; } @@ -403,7 +428,7 @@ function getArchivedPhaseDirs(cwd) { }); } } - } catch {} + } catch { /* intentionally empty */ } return results; } @@ -420,6 +445,91 @@ function stripShippedMilestones(content) { return content.replace(/
[\s\S]*?<\/details>/gi, ''); } +/** + * Extract the current milestone section from ROADMAP.md by positive lookup. + * + * Instead of stripping
blocks (negative heuristic that breaks if + * agents wrap the current milestone in
), this finds the section + * matching the current milestone version and returns only that content. + * + * Falls back to stripShippedMilestones() if: + * - cwd is not provided + * - STATE.md doesn't exist or has no milestone field + * - Version can't be found in ROADMAP.md + * + * @param {string} content - Full ROADMAP.md content + * @param {string} [cwd] - Working directory for reading STATE.md + * @returns {string} Content scoped to current milestone + */ +function extractCurrentMilestone(content, cwd) { + if (!cwd) return stripShippedMilestones(content); + + // 1. Get current milestone version from STATE.md frontmatter + let version = null; + try { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (fs.existsSync(statePath)) { + const stateRaw = fs.readFileSync(statePath, 'utf-8'); + const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); + if (milestoneMatch) { + version = milestoneMatch[1].trim(); + } + } + } catch {} + + // 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself + if (!version) { + // Check for 🚧 in-progress marker + const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/); + if (inProgressMatch) { + version = 'v' + inProgressMatch[1]; + } + } + + if (!version) return stripShippedMilestones(content); + + // 3. Find the section matching this version + // Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc. + const escapedVersion = escapeRegex(version); + const sectionPattern = new RegExp( + `(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, + 'mi' + ); + const sectionMatch = content.match(sectionPattern); + + if (!sectionMatch) return stripShippedMilestones(content); + + const sectionStart = sectionMatch.index; + + // Find the end: next milestone heading at same or higher level, or EOF + // Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc. + const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length; + const restContent = content.slice(sectionStart + sectionMatch[0].length); + const nextMilestonePattern = new RegExp( + `^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, + 'mi' + ); + const nextMatch = restContent.match(nextMilestonePattern); + + let sectionEnd; + if (nextMatch) { + sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index; + } else { + sectionEnd = content.length; + } + + // Return everything before the current milestone section (non-milestone content + // like title, overview) plus the current milestone section + const beforeMilestones = content.slice(0, sectionStart); + const currentSection = content.slice(sectionStart, sectionEnd); + + // Also include any content before the first milestone heading (title, overview, etc.) + // but strip any
blocks in it (these are definitely shipped) + const preamble = beforeMilestones.replace(/
[\s\S]*?<\/details>/gi, ''); + + return preamble + currentSection; +} + /** * Replace a pattern only in the current milestone section of ROADMAP.md * (everything after the last
close tag). Used for write operations @@ -444,7 +554,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) { if (!fs.existsSync(roadmapPath)) return null; try { - const content = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8')); + const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd); const escapedPhase = escapeRegex(phaseNum.toString()); const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i'); const headerMatch = content.match(phasePattern); @@ -472,6 +582,19 @@ function getRoadmapPhaseInternal(cwd, phaseNum) { } } +// ─── Model alias resolution ─────────────────────────────────────────────────── + +/** + * Map short model aliases to full model IDs. + * Updated each release to match current model versions. + * Users can override with model_overrides in config.json for custom/latest models. + */ +const MODEL_ALIAS_MAP = { + 'opus': 'claude-opus-4-0', + 'sonnet': 'claude-sonnet-4-5', + 'haiku': 'claude-haiku-3-5', +}; + function resolveModelInternal(cwd, agentType) { const config = loadConfig(cwd); @@ -486,7 +609,15 @@ function resolveModelInternal(cwd, agentType) { const agentModels = MODEL_PROFILES[agentType]; if (!agentModels) return 'sonnet'; if (profile === 'inherit') return 'inherit'; - return agentModels[profile] || agentModels['balanced'] || 'sonnet'; + const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet'; + + // If resolve_model_ids is true, map alias to full model ID + // This prevents 404s when the Task tool passes aliases directly to the API + if (config.resolve_model_ids) { + return MODEL_ALIAS_MAP[alias] || alias; + } + + return alias; } // ─── Misc utilities ─────────────────────────────────────────────────────────── @@ -549,13 +680,13 @@ function getMilestoneInfo(cwd) { function getMilestonePhaseFilter(cwd) { const milestonePhaseNums = new Set(); try { - const roadmap = stripShippedMilestones(fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8')); + const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'), cwd); const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmap)) !== null) { milestonePhaseNums.add(m[1]); } - } catch {} + } catch { /* intentionally empty */ } if (milestonePhaseNums.size === 0) { const passAll = () => true; @@ -597,6 +728,10 @@ module.exports = { getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, + extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, + MODEL_ALIAS_MAP, + planningDir, + planningPaths, }; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index d29e533c8..6a1fbc886 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs'); +const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs'); function cmdInitExecutePhase(cwd, phase, raw) { if (!phase) { @@ -31,6 +31,7 @@ function cmdInitExecutePhase(cwd, phase, raw) { // Config flags commit_docs: config.commit_docs, parallelization: config.parallelization, + context_window: config.context_window, branching_strategy: config.branching_strategy, phase_branch_template: config.phase_branch_template, milestone_branch_template: config.milestone_branch_template, @@ -153,7 +154,7 @@ function cmdInitPlanPhase(cwd, phase, raw) { if (uatFile) { result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile)); } - } catch {} + } catch { /* intentionally empty */ } } output(result, raw); @@ -167,17 +168,26 @@ function cmdInitNewProject(cwd, raw) { const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key'); const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile)); - // Detect existing code + // Detect existing code (cross-platform — no Unix `find` dependency) let hasCode = false; let hasPackageFile = false; try { - const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - hasCode = files.trim().length > 0; - } catch {} + const codeExtensions = new Set(['.ts', '.js', '.py', '.go', '.rs', '.swift', '.java']); + const skipDirs = new Set(['node_modules', '.git', '.planning', '.claude', '__pycache__', 'target', 'dist', 'build']); + function findCodeFiles(dir, depth) { + if (depth > 3) return false; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return false; } + for (const entry of entries) { + if (entry.isFile() && codeExtensions.has(path.extname(entry.name))) return true; + if (entry.isDirectory() && !skipDirs.has(entry.name)) { + if (findCodeFiles(path.join(dir, entry.name), depth + 1)) return true; + } + } + return false; + } + hasCode = findCodeFiles(cwd, 0); + } catch { /* intentionally empty — best-effort detection */ } hasPackageFile = pathExistsInternal(cwd, 'package.json') || pathExistsInternal(cwd, 'requirements.txt') || @@ -307,7 +317,7 @@ function cmdInitResume(cwd, raw) { let interruptedAgentId = null; try { interruptedAgentId = fs.readFileSync(path.join(cwd, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); - } catch {} + } catch { /* intentionally empty */ } const result = { // File existence @@ -459,7 +469,7 @@ function cmdInitPhaseOp(cwd, phase, raw) { if (uatFile) { result.uat_path = toPosixPath(path.join(phaseInfo.directory, uatFile)); } - } catch {} + } catch { /* intentionally empty */ } } output(result, raw); @@ -494,9 +504,9 @@ function cmdInitTodos(cwd, area, raw) { area: todoArea, path: '.planning/todos/pending/' + file, }); - } catch {} + } catch { /* intentionally empty */ } } - } catch {} + } catch { /* intentionally empty */ } const result = { // Config @@ -543,9 +553,9 @@ function cmdInitMilestoneOp(cwd, raw) { const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); if (hasSummary) completedPhases++; - } catch {} + } catch { /* intentionally empty */ } } - } catch {} + } catch { /* intentionally empty */ } // Check archive const archiveDir = path.join(cwd, '.planning', 'archive'); @@ -554,7 +564,7 @@ function cmdInitMilestoneOp(cwd, raw) { archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name); - } catch {} + } catch { /* intentionally empty */ } const result = { // Config @@ -593,7 +603,7 @@ function cmdInitMapCodebase(cwd, raw) { let existingMaps = []; try { existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md')); - } catch {} + } catch { /* intentionally empty */ } const result = { // Models @@ -633,8 +643,8 @@ function cmdInitProgress(cwd, raw) { const roadmapPhaseNums = new Set(); const roadmapPhaseNames = new Map(); try { - const roadmapContent = stripShippedMilestones( - fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8') + const roadmapContent = extractCurrentMilestone( + fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'), cwd ); const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; let hm; @@ -642,7 +652,7 @@ function cmdInitProgress(cwd, raw) { roadmapPhaseNums.add(hm[1]); roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim()); } - } catch {} + } catch { /* intentionally empty */ } const isDirInMilestone = getMilestonePhaseFilter(cwd); const seenPhaseNums = new Set(); @@ -695,7 +705,7 @@ function cmdInitProgress(cwd, raw) { nextPhase = phaseInfo; } } - } catch {} + } catch { /* intentionally empty */ } // Add phases defined in ROADMAP but not yet scaffolded to disk for (const [num, name] of roadmapPhaseNames) { @@ -726,7 +736,7 @@ function cmdInitProgress(cwd, raw) { const state = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8'); const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/); if (pauseMatch) pausedAt = pauseMatch[1].trim(); - } catch {} + } catch { /* intentionally empty */ } const result = { // Models diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index 6fd032798..ba56a0727 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -137,10 +137,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) { // Count tasks const taskMatches = content.match(/##\s*Task\s*\d+/gi) || []; totalTasks += taskMatches.length; - } catch {} + } catch { /* intentionally empty */ } } } - } catch {} + } catch { /* intentionally empty */ } // Archive ROADMAP.md if (fs.existsSync(roadmapPath)) { @@ -220,7 +220,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) { archivedCount++; } phasesArchived = archivedCount > 0; - } catch {} + } catch { /* intentionally empty */ } } const result = { diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index d88be9459..dc2c52f99 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, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, replaceInCurrentMilestone, toPosixPath, output, error } = require('./core.cjs'); +const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd } = require('./state.cjs'); @@ -319,7 +319,7 @@ function cmdPhaseAdd(cwd, description, raw) { } const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); - const content = stripShippedMilestones(rawContent); + const content = extractCurrentMilestone(rawContent, cwd); const slug = generateSlugInternal(description); // Find highest integer phase number (in current milestone only) @@ -376,7 +376,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { } const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); - const content = stripShippedMilestones(rawContent); + const content = extractCurrentMilestone(rawContent, cwd); const slug = generateSlugInternal(description); // Normalize input then strip leading zeros for flexible matching @@ -401,7 +401,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { const dm = dir.match(decimalPattern); if (dm) existingDecimals.push(parseInt(dm[1], 10)); } - } catch {} + } catch { /* intentionally empty */ } const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; const decimalPhase = `${normalizedBase}.${nextDecimal}`; @@ -470,7 +470,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized); - } catch {} + } catch { /* intentionally empty */ } // Check for executed work (SUMMARY.md files) if (targetDir && !force) { @@ -538,7 +538,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { } } } - } catch {} + } catch { /* intentionally empty */ } } else { // Integer removal: renumber all subsequent integer phases @@ -598,7 +598,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { } } } - } catch {} + } catch { /* intentionally empty */ } } // Update ROADMAP.md @@ -760,7 +760,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { if (fs.existsSync(reqPath)) { // Extract the current phase section from roadmap (scoped to avoid cross-phase matching) const phaseEsc = escapeRegex(phaseNum); - const currentMilestoneRoadmap = stripShippedMilestones(roadmapContent); + 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') ); @@ -818,13 +818,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { } } } - } catch {} + } catch { /* intentionally empty */ } // Fallback: if filesystem found no next phase, check ROADMAP.md // for phases that are defined but not yet planned (no directory on disk) if (isLastPhase && fs.existsSync(roadmapPath)) { try { - const roadmapForPhases = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8')); + const roadmapForPhases = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd); const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; let pm; while ((pm = phasePattern.exec(roadmapForPhases)) !== null) { @@ -835,7 +835,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { break; } } - } catch {} + } catch { /* intentionally empty */ } } // Update STATE.md @@ -880,6 +880,34 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { `$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}` ); + // Increment Completed Phases counter (#956) + const completedMatch = stateContent.match(/\*\*Completed Phases:\*\*\s*(\d+)/); + if (completedMatch) { + const newCompleted = parseInt(completedMatch[1], 10) + 1; + stateContent = stateContent.replace( + /(\*\*Completed Phases:\*\*\s*)\d+/, + `$1${newCompleted}` + ); + + // Recalculate percent based on completed / total (#956) + const totalMatch = stateContent.match(/\*\*Total Phases:\*\*\s*(\d+)/); + if (totalMatch) { + const totalPhases = parseInt(totalMatch[1], 10); + if (totalPhases > 0) { + const newPercent = Math.round((newCompleted / totalPhases) * 100); + stateContent = stateContent.replace( + /(\*\*Progress:\*\*\s*)\d+%/, + `$1${newPercent}%` + ); + // Also update percent field if it exists separately + stateContent = stateContent.replace( + /(percent:\s*)\d+/, + `$1${newPercent}` + ); + } + } + } + writeStateMd(statePath, stateContent, cwd); } diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 3164b702c..0e6d900e7 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, output, error, findPhaseInternal, stripShippedMilestones, replaceInCurrentMilestone } = require('./core.cjs'); +const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs'); function cmdRoadmapGetPhase(cwd, phaseNum, raw) { const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); @@ -15,7 +15,7 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) { } try { - const content = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8')); + const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd); // Escape special regex chars in phase number, handle decimal const escapedPhase = escapeRegex(phaseNum); @@ -99,7 +99,7 @@ function cmdRoadmapAnalyze(cwd, raw) { } const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); - const content = stripShippedMilestones(rawContent); + const content = extractCurrentMilestone(rawContent, cwd); const phasesDir = path.join(cwd, '.planning', 'phases'); // Extract all phase headings: ## Phase N: Name or ### Phase N: Name @@ -151,7 +151,7 @@ function cmdRoadmapAnalyze(cwd, raw) { else if (hasContext) diskStatus = 'discussed'; else diskStatus = 'empty'; } - } catch {} + } catch { /* intentionally empty */ } // Check ROADMAP checkbox status const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s]`, 'i'); diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 40bf8d2cc..08aa84862 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -4,9 +4,14 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, output, error } = require('./core.cjs'); +const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningPaths, output, error } = require('./core.cjs'); const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs'); +/** Shorthand — every state command needs this path */ +function getStatePath(cwd) { + return planningPaths(cwd).state; +} + // Shared helper: extract a field value from STATE.md content. // Supports both **Field:** bold and plain Field: format. function stateExtractField(content, fieldName) { @@ -26,7 +31,7 @@ function cmdStateLoad(cwd, raw) { let stateRaw = ''; try { stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8'); - } catch {} + } catch { /* intentionally empty */ } const configExists = fs.existsSync(path.join(planningDir, 'config.json')); const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md')); @@ -75,7 +80,7 @@ function cmdStateGet(cwd, section, raw) { } // Try to find markdown section or field - const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const fieldEscaped = escapeRegex(section); // Check for **field:** value (bold format) const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i'); @@ -125,7 +130,7 @@ function cmdStatePatch(cwd, patches, raw) { const results = { updated: [], failed: [] }; for (const [field, value] of Object.entries(patches)) { - const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + 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'); @@ -159,7 +164,7 @@ function cmdStateUpdate(cwd, field, value) { const statePath = path.join(cwd, '.planning', 'STATE.md'); try { let content = fs.readFileSync(statePath, 'utf-8'); - const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + 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'); @@ -180,21 +185,10 @@ function cmdStateUpdate(cwd, field, value) { } // ─── State Progression Engine ──────────────────────────────────────────────── - -function stateExtractField(content, fieldName) { - const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Try **Field:** bold format first - const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i'); - const boldMatch = content.match(boldPattern); - if (boldMatch) return boldMatch[1].trim(); - // Fall back to plain Field: format - const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im'); - const plainMatch = content.match(plainPattern); - return plainMatch ? plainMatch[1].trim() : null; -} +// stateExtractField is defined above (shared helper) — do not duplicate. function stateReplaceField(content, fieldName, newValue) { - const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escaped = escapeRegex(fieldName); // Try **Field:** bold format first, then plain Field: format const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'); if (boldPattern.test(content)) { @@ -576,7 +570,7 @@ function buildStateFrontmatter(bodyContent, cwd) { const info = getMilestoneInfo(cwd); milestone = info.version; milestoneName = info.name; - } catch {} + } catch { /* intentionally empty */ } } let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; @@ -611,7 +605,7 @@ function buildStateFrontmatter(bodyContent, cwd) { totalPlans = diskTotalPlans; completedPlans = diskTotalSummaries; } - } catch {} + } catch { /* intentionally empty */ } } let progressPercent = null; @@ -677,10 +671,54 @@ function syncStateFrontmatter(content, cwd) { /** * Write STATE.md with synchronized YAML frontmatter. * All STATE.md writes should use this instead of raw writeFileSync. + * Uses a simple lockfile to prevent parallel agents from overwriting + * each other's changes (race condition with read-modify-write cycle). */ function writeStateMd(statePath, content, cwd) { const synced = syncStateFrontmatter(content, cwd); - fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8'); + 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 + } + } + + try { + fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8'); + } finally { + try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ } + } } function cmdStateJson(cwd, raw) { @@ -778,6 +816,53 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) { output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false'); } +/** + * Write a WAITING.json signal file when GSD hits a decision point. + * External watchers (fswatch, polling, orchestrators) can detect this. + * File is written to .planning/WAITING.json (or .gsd/WAITING.json if .gsd exists). + * Fixes #1034. + */ +function cmdSignalWaiting(cwd, type, question, options, phase, raw) { + const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : path.join(cwd, '.planning'); + const waitingPath = path.join(gsdDir, 'WAITING.json'); + + const signal = { + status: 'waiting', + type: type || 'decision_point', + question: question || null, + options: options ? options.split('|').map(o => o.trim()) : [], + since: new Date().toISOString(), + phase: phase || null, + }; + + try { + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(waitingPath, JSON.stringify(signal, null, 2), 'utf-8'); + output({ signaled: true, path: waitingPath }, raw, 'true'); + } catch (e) { + output({ signaled: false, error: e.message }, raw, 'false'); + } +} + +/** + * Remove the WAITING.json signal file when user answers and agent resumes. + */ +function cmdSignalResume(cwd, raw) { + const paths = [ + path.join(cwd, '.gsd', 'WAITING.json'), + path.join(cwd, '.planning', 'WAITING.json'), + ]; + + let removed = false; + for (const p of paths) { + if (fs.existsSync(p)) { + try { fs.unlinkSync(p); removed = true; } catch {} + } + } + + output({ resumed: true, removed }, raw, removed ? 'true' : 'false'); +} + module.exports = { stateExtractField, stateReplaceField, @@ -796,4 +881,6 @@ module.exports = { cmdStateSnapshot, cmdStateJson, cmdStateBeginPhase, + cmdSignalWaiting, + cmdSignalResume, }; diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 9f8a08546..5b3a12c13 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, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, output, error } = require('./core.cjs'); +const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error } = require('./core.cjs'); const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs'); const { writeStateMd } = require('./state.cjs'); @@ -409,7 +409,7 @@ function cmdValidateConsistency(cwd, raw) { } const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8'); - const roadmapContent = stripShippedMilestones(roadmapContentRaw); + const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd); // Extract phases from ROADMAP (archived milestones already stripped) const roadmapPhases = new Set(); @@ -428,7 +428,7 @@ function cmdValidateConsistency(cwd, raw) { const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (dm) diskPhases.add(dm[1]); } - } catch {} + } catch { /* intentionally empty */ } // Check: phases in ROADMAP but not on disk for (const p of roadmapPhases) { @@ -490,7 +490,7 @@ function cmdValidateConsistency(cwd, raw) { } } } - } catch {} + } catch { /* intentionally empty */ } // Check: frontmatter in plans has required fields try { @@ -510,7 +510,7 @@ function cmdValidateConsistency(cwd, raw) { } } } - } catch {} + } catch { /* intentionally empty */ } const passed = errors.length === 0; output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed'); @@ -599,7 +599,7 @@ function cmdValidateHealth(cwd, options, raw) { if (m) diskPhases.add(m[1]); } } - } catch {} + } catch { /* intentionally empty */ } // Check for invalid references for (const ref of phaseRefs) { const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0'); @@ -641,7 +641,7 @@ function cmdValidateHealth(cwd, options, raw) { addIssue('warning', 'W008', 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', 'Run /gsd:health --repair to add key', true); if (!repairs.includes('addNyquistKey')) repairs.push('addNyquistKey'); } - } catch {} + } catch { /* intentionally empty */ } } // ─── Check 6: Phase directory naming (NN-name format) ───────────────────── @@ -652,7 +652,7 @@ function cmdValidateHealth(cwd, options, raw) { addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)'); } } - } catch {} + } catch { /* intentionally empty */ } // ─── Check 7: Orphaned plans (PLAN without SUMMARY) ─────────────────────── try { @@ -671,7 +671,7 @@ function cmdValidateHealth(cwd, options, raw) { } } } - } catch {} + } catch { /* intentionally empty */ } // ─── Check 7b: Nyquist VALIDATION.md consistency ──────────────────────── try { @@ -689,13 +689,13 @@ function cmdValidateHealth(cwd, options, raw) { } } } - } catch {} + } catch { /* intentionally empty */ } // ─── Check 8: Run existing consistency checks ───────────────────────────── // Inline subset of cmdValidateConsistency if (fs.existsSync(roadmapPath)) { const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8'); - const roadmapContent = stripShippedMilestones(roadmapContentRaw); + const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd); const roadmapPhases = new Set(); const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; let m; @@ -712,7 +712,7 @@ function cmdValidateHealth(cwd, options, raw) { if (dm) diskPhases.add(dm[1]); } } - } catch {} + } catch { /* intentionally empty */ } // Phases in ROADMAP but not on disk for (const p of roadmapPhases) { diff --git a/get-shit-done/references/checkpoints.md b/get-shit-done/references/checkpoints.md index 232817480..23f90e99c 100644 --- a/get-shit-done/references/checkpoints.md +++ b/get-shit-done/references/checkpoints.md @@ -50,7 +50,7 @@ Plans execute autonomously. Checkpoints formalize interaction points where human Start dev server for verification Run `npm run dev` in background, wait for "ready" message, capture port - curl http://localhost:3000 returns 200 + fetch http://localhost:3000 returns 200 Dev server running at http://localhost:3000 @@ -240,7 +240,7 @@ Plans execute autonomously. Checkpoints formalize interaction points where human Deploy to Vercel .vercel/, vercel.json Run `vercel --yes` to deploy - vercel ls shows deployment, curl returns 200 + vercel ls shows deployment, fetch returns 200 @@ -261,7 +261,7 @@ Plans execute autonomously. Checkpoints formalize interaction points where human Retry Vercel deployment Run `vercel --yes` (now authenticated) - vercel ls shows deployment, curl returns 200 + vercel ls shows deployment, fetch returns 200 ``` @@ -455,8 +455,8 @@ I'll verify: vercel whoami returns your account npm run dev & DEV_SERVER_PID=$! -# Wait for ready (max 30s) -timeout 30 bash -c 'until curl -s localhost:3000 > /dev/null 2>&1; do sleep 1; done' +# Wait for ready (max 30s) — uses fetch() for cross-platform compatibility +timeout 30 bash -c 'until node -e "fetch(\"http://localhost:3000\").then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))" 2>/dev/null; do sleep 1; done' ``` **Port conflicts:** Kill stale process (`lsof -ti:3000 | xargs kill`) or use alternate port (`--port 3001`). @@ -489,7 +489,9 @@ timeout 30 bash -c 'until curl -s localhost:3000 > /dev/null 2>&1; do sleep 1; d | Auth error | Create auth gate checkpoint | | Network timeout | Retry with backoff, then checkpoint if persistent | -**Never present a checkpoint with broken verification environment.** If `curl localhost:3000` fails, don't ask user to "visit localhost:3000". +**Never present a checkpoint with broken verification environment.** If the local server isn't responding, don't ask user to "visit localhost:3000". + +> **Cross-platform note:** Use `node -e "fetch('http://localhost:3000').then(r=>console.log(r.status))"` instead of `curl` for health checks. `curl` is broken on Windows MSYS/Git Bash due to SSL/path mangling issues. ```xml @@ -502,7 +504,7 @@ timeout 30 bash -c 'until curl -s localhost:3000 > /dev/null 2>&1; do sleep 1; d Fix server startup issue Investigate error, fix root cause, restart server - curl http://localhost:3000 returns 200 + fetch http://localhost:3000 returns 200 @@ -608,7 +610,7 @@ timeout 30 bash -c 'until curl -s localhost:3000 > /dev/null 2>&1; do sleep 1; d Start dev server for auth testing Run `npm run dev` in background, wait for ready signal - curl http://localhost:3000 returns 200 + fetch http://localhost:3000 returns 200 Dev server running at http://localhost:3000 @@ -651,7 +653,7 @@ timeout 30 bash -c 'until curl -s localhost:3000 > /dev/null 2>&1; do sleep 1; d Start dev server Run `npm run dev` in background - curl localhost:3000 returns 200 + fetch http://localhost:3000 returns 200 @@ -677,7 +679,7 @@ timeout 30 bash -c 'until curl -s localhost:3000 > /dev/null 2>&1; do sleep 1; d Deploy to Vercel Run `vercel --yes`. Capture URL. - vercel ls shows deployment, curl returns 200 + vercel ls shows deployment, fetch returns 200 diff --git a/get-shit-done/references/git-integration.md b/get-shit-done/references/git-integration.md index 1e0a9d1b4..d9bbecac2 100644 --- a/get-shit-done/references/git-integration.md +++ b/get-shit-done/references/git-integration.md @@ -61,6 +61,10 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: initialize [p Each task gets its own commit immediately after completion. +> **Parallel agents:** When running as a parallel executor (spawned by execute-phase), +> use `--no-verify` on all commits to avoid pre-commit hook lock contention. +> The orchestrator validates hooks once after all agents complete. + ``` {type}({phase}-{plan}): {task-name} diff --git a/get-shit-done/references/model-profiles.md b/get-shit-done/references/model-profiles.md index e4d5f0649..97a92b75d 100644 --- a/get-shit-done/references/model-profiles.md +++ b/get-shit-done/references/model-profiles.md @@ -40,8 +40,26 @@ Model profiles control which Claude model each GSD agent uses. This allows balan **inherit** - Follow the current session model - All agents resolve to `inherit` - Best when you switch models interactively (for example OpenCode `/model`) +- **Required when using non-Anthropic providers** (OpenRouter, local models, etc.) — otherwise GSD may call Anthropic models directly, incurring unexpected costs - Use when: you want GSD to follow your currently selected runtime model +## Using Non-Anthropic Models (OpenRouter, Local, etc.) + +If you're using Claude Code with OpenRouter, a local model, or any non-Anthropic provider, set the `inherit` profile to prevent GSD from calling Anthropic models for subagents: + +```bash +# Via settings command +/gsd:settings +# → Select "Inherit" for model profile + +# Or manually in .planning/config.json +{ + "model_profile": "inherit" +} +``` + +Without `inherit`, GSD's default `balanced` profile spawns specific Anthropic models (`opus`, `sonnet`, `haiku`) for each agent type, which can result in additional API costs through your non-Anthropic provider. + ## Resolution Logic Orchestrators resolve model before spawning: diff --git a/get-shit-done/templates/phase-prompt.md b/get-shit-done/templates/phase-prompt.md index 6d23160dd..b242dc15e 100644 --- a/get-shit-done/templates/phase-prompt.md +++ b/get-shit-done/templates/phase-prompt.md @@ -341,7 +341,7 @@ Output: User model, API endpoints, and UI components. Task 2: Create User API endpoints src/features/user/api.ts GET /users (list), GET /users/:id (single), POST /users (create). Use User type from model. - curl tests pass for all endpoints + fetch tests pass for all endpoints All CRUD operations work @@ -407,7 +407,7 @@ Output: Working dashboard component. Start dev server Run `npm run dev` in background, wait for ready - curl localhost:3000 returns 200 + fetch http://localhost:3000 returns 200 diff --git a/get-shit-done/templates/project.md b/get-shit-done/templates/project.md index 8971f4528..37a986c70 100644 --- a/get-shit-done/templates/project.md +++ b/get-shit-done/templates/project.md @@ -127,6 +127,8 @@ Common types: Tech stack, Timeline, Budget, Dependencies, Compatibility, Perform PROJECT.md evolves throughout the project lifecycle. +These rules are embedded in the generated PROJECT.md (## Evolution section) +and implemented by workflows/transition.md and workflows/complete-milestone.md. **After each phase transition:** 1. Requirements invalidated? → Move to Out of Scope with reason diff --git a/get-shit-done/workflows/discuss-phase.md b/get-shit-done/workflows/discuss-phase.md index d8f7a6f40..75cd055c1 100644 --- a/get-shit-done/workflows/discuss-phase.md +++ b/get-shit-done/workflows/discuss-phase.md @@ -242,6 +242,47 @@ Structure the extracted information: **If no prior context exists:** Continue without — this is expected for early phases. + +Check if any pending todos are relevant to this phase's scope. Surfaces backlog items that might otherwise be missed. + +**Load and match todos:** +```bash +TODO_MATCHES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" todo match-phase "${PHASE_NUMBER}") +``` + +Parse JSON for: `todo_count`, `matches[]` (each with `file`, `title`, `area`, `score`, `reasons`). + +**If `todo_count` is 0 or `matches` is empty:** Skip silently — no workflow slowdown. + +**If matches found:** + +Present matched todos to the user. Show each match with its title, area, and why it matched: + +``` +📋 Found {N} pending todo(s) that may be relevant to Phase {X}: + +{For each match:} +- **{title}** (area: {area}, relevance: {score}) — matched on {reasons} +``` + +Use AskUserQuestion (multiSelect) asking which todos to fold into this phase's scope: + +``` +Which of these todos should be folded into Phase {X} scope? +(Select any that apply, or none to skip) +``` + +**For selected (folded) todos:** +- Store internally as `` for inclusion in CONTEXT.md `` section +- These become additional scope items that downstream agents (researcher, planner) will see + +**For unselected (reviewed but not folded) todos:** +- Store internally as `` for inclusion in CONTEXT.md `` section +- This prevents future phases from re-surfacing the same todos as "missed" + +**Auto mode (`--auto`):** Fold all todos with score >= 0.4 automatically. Log the selection. + + Lightweight scan of existing code to inform gray area identification and discussion. Uses ~10% context — acceptable for an interactive session. @@ -406,6 +447,30 @@ For each selected area, conduct a focused discussion loop. **Batch mode support:** Parse optional `--batch` from `$ARGUMENTS`. - Accept `--batch`, `--batch=N`, or `--batch N` + +**Analyze mode support:** Parse optional `--analyze` from `$ARGUMENTS`. +When `--analyze` is active, before presenting each question (or question group in batch mode), provide a brief **trade-off analysis** for the decision: +- 2-3 options with pros/cons based on codebase context and common patterns +- A recommended approach with reasoning +- Known pitfalls or constraints from prior phases + +Example with `--analyze`: +``` +**Trade-off analysis: Authentication strategy** + +| Approach | Pros | Cons | +|----------|------|------| +| Session cookies | Simple, httpOnly prevents XSS | Requires CSRF protection, sticky sessions | +| JWT (stateless) | Scalable, no server state | Token size, revocation complexity | +| OAuth 2.0 + PKCE | Industry standard for SPAs | More setup, redirect flow UX | + +💡 Recommended: OAuth 2.0 + PKCE — your app has social login in requirements (REQ-04) and this aligns with the existing NextAuth setup in `src/lib/auth.ts`. + +How should users authenticate? +``` + +This gives the user context to make informed decisions without extra prompting. When `--analyze` is absent, present questions directly as before. +- Accept `--batch`, `--batch=N`, or `--batch N` - Default to 4 questions per batch when no number is provided - Clamp explicit sizes to 2-5 so a batch stays answerable - If `--batch` is absent, keep the existing one-question-at-a-time flow @@ -544,6 +609,11 @@ mkdir -p ".planning/phases/${padded_phase}-${phase_slug}" ### Claude's Discretion [Areas where user said "you decide" — note that Claude has flexibility here] +### Folded Todos +[If any todos were folded into scope from the cross_reference_todos step, list them here. +Each entry should include the todo title, original problem, and how it fits this phase's scope. +If no todos were folded: omit this subsection entirely.] + @@ -595,6 +665,12 @@ Every entry needs a full relative path — not just a name.] [Ideas that came up but belong in other phases. Don't lose them.] +### Reviewed Todos (not folded) +[If any todos were reviewed in cross_reference_todos but not folded into scope, +list them here so future phases know they were considered. +Each entry: todo title + reason it was deferred (out of scope, belongs in Phase Y, etc.) +If no reviewed-but-deferred todos: omit this subsection entirely.] + [If none: "None — discussion stayed within phase scope"] diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index af9027722..552afb318 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -6,10 +6,42 @@ Execute all plans in a phase using wave-based parallel execution. Orchestrator s Orchestrator coordinates, not executes. Each subagent loads the full execute-plan context. Orchestrator: discover plans → analyze deps → group waves → spawn agents → handle checkpoints → collect results. + +**Subagent spawning is runtime-specific:** +- **Claude Code:** Uses `Task(subagent_type="gsd-executor", ...)` — blocks until complete, returns result +- **Copilot:** Uses `@gsd-executor` agent reference — if subagent spawning hangs or fails to return, + fall back to **sequential inline execution**: read and follow execute-plan.md directly for each plan + instead of spawning parallel agents. This is slower but reliable. +- **Other runtimes (Gemini, Codex, OpenCode):** If Task/subagent API is unavailable, use sequential + inline execution as the fallback. + +**Fallback rule:** If a spawned agent completes its work (commits visible, SUMMARY.md exists) but +the orchestrator never receives the completion signal, treat it as successful based on spot-checks +and continue to the next wave/plan. + + Read STATE.md before any operation to load project context. + +These are the valid GSD subagent types registered in .claude/agents/ (or equivalent for your runtime). +Always use the exact name from this list — do not fall back to 'general-purpose' or other built-in types: + +- gsd-executor — Executes plan tasks, commits, creates SUMMARY.md +- gsd-verifier — Verifies phase completion, checks quality gates +- gsd-planner — Creates detailed plans from phase scope +- gsd-phase-researcher — Researches technical approaches for a phase +- gsd-plan-checker — Reviews plan quality before execution +- gsd-debugger — Diagnoses and fixes issues +- gsd-codebase-mapper — Maps project structure and dependencies +- gsd-integration-checker — Checks cross-phase integration +- gsd-nyquist-auditor — Validates verification coverage +- gsd-ui-researcher — Researches UI/UX approaches +- gsd-ui-checker — Reviews UI implementation quality +- gsd-ui-auditor — Audits UI against design requirements + + @@ -37,6 +69,54 @@ fi ``` + +**Parse `--interactive` flag from $ARGUMENTS.** + +**If `--interactive` flag present:** Switch to interactive execution mode. + +Interactive mode executes plans sequentially **inline** (no subagent spawning) with user +checkpoints between tasks. The user can review, modify, or redirect work at any point. + +**Interactive execution flow:** + +1. Load plan inventory as normal (discover_and_group_plans) +2. For each plan (sequentially, ignoring wave grouping): + + a. **Present the plan to the user:** + ``` + ## Plan {plan_id}: {plan_name} + + Objective: {from plan file} + Tasks: {task_count} + + Options: + - Execute (proceed with all tasks) + - Review first (show task breakdown before starting) + - Skip (move to next plan) + - Stop (end execution, save progress) + ``` + + b. **If "Review first":** Read and display the full plan file. Ask again: Execute, Modify, Skip. + + c. **If "Execute":** Read and follow `~/.claude/get-shit-done/workflows/execute-plan.md` **inline** + (do NOT spawn a subagent). Execute tasks one at a time. + + d. **After each task:** Pause briefly. If the user intervenes (types anything), stop and address + their feedback before continuing. Otherwise proceed to next task. + + e. **After plan complete:** Show results, commit, create SUMMARY.md, then present next plan. + +3. After all plans: proceed to verification (same as normal mode). + +**Benefits of interactive mode:** +- No subagent overhead — dramatically lower token usage +- User catches mistakes early — saves costly verification cycles +- Maintains GSD's planning/tracking structure +- Best for: small phases, bug fixes, verification gaps, learning GSD + +**Skip to handle_branching step** (interactive plans execute inline after grouping). + + Check `branching_strategy` from init: @@ -111,8 +191,9 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true` 2. **Spawn executor agents:** - Pass paths only — executors read files themselves with their fresh 200k context. - This keeps orchestrator context lean (~10-15%). + Pass paths only — executors read files themselves with their fresh context window. + For 200k models, this keeps orchestrator context lean (~10-15%). + For 1M+ models (Opus 4.6, Sonnet 4.6), richer context can be passed directly. ``` Task( @@ -124,6 +205,14 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true` Commit each task atomically. Create SUMMARY.md. Update STATE.md and ROADMAP.md. + + You are running as a PARALLEL executor agent. Use --no-verify on all git + commits to avoid pre-commit hook contention with other agents. The + orchestrator validates hooks once after all agents complete. + For gsd-tools commits: add --no-verify flag. + For direct git commits: use git commit --no-verify -m "..." + + @~/.claude/get-shit-done/workflows/execute-plan.md @~/.claude/get-shit-done/templates/summary.md @@ -134,12 +223,20 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true` Read these files at execution start using the Read tool: - {phase_dir}/{plan_file} (Plan) + - .planning/PROJECT.md (Project context — core value, requirements, evolution rules) - .planning/STATE.md (State) - .planning/config.json (Config, if exists) - ./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) + + If CLAUDE.md or project instructions reference MCP tools (e.g. jCodeMunch, context7, + or other MCP servers), prefer those tools over Grep/Glob for code navigation when available. + MCP tools often save significant tokens by providing structured code indexes. + Check tool availability first — if MCP tools are not accessible, fall back to Grep/Glob. + + - [ ] All tasks executed - [ ] Each task committed individually @@ -153,7 +250,17 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true` 3. **Wait for all agents in wave to complete.** -4. **Report completion — spot-check claims first:** +4. **Post-wave hook validation (parallel mode only):** + + When agents committed with `--no-verify`, run pre-commit hooks once after the wave: + ```bash + # Run project's pre-commit hooks on the current state + git diff --cached --quiet || git stash # stash any unstaged changes + git hook run pre-commit 2>&1 || echo "⚠ Pre-commit hooks failed — review before continuing" + ``` + If hooks fail: report the failure and ask "Fix hook issues now?" or "Continue to next wave?" + +5. **Report completion — spot-check claims first:** For each SUMMARY.md: - Verify first 2 files from `key-files.created` exist on disk @@ -328,6 +435,67 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(phase-${PARENT ``` + +Run prior phases' test suites to catch cross-phase regressions BEFORE verification. + +**Skip if:** This is the first phase (no prior phases), or no prior VERIFICATION.md files exist. + +**Step 1: Discover prior phases' test files** +```bash +# Find all VERIFICATION.md files from prior phases in current milestone +PRIOR_VERIFICATIONS=$(find .planning/phases/ -name "*-VERIFICATION.md" ! -path "*${PHASE_NUMBER}*" 2>/dev/null) +``` + +**Step 2: Extract test file lists from prior verifications** + +For each VERIFICATION.md found, look for test file references: +- Lines containing `test`, `spec`, or `__tests__` paths +- The "Test Suite" or "Automated Checks" section +- File patterns from `key-files.created` in corresponding SUMMARY.md files that match `*.test.*` or `*.spec.*` + +Collect all unique test file paths into `REGRESSION_FILES`. + +**Step 3: Run regression tests (if any found)** + +```bash +# Detect test runner and run prior phase tests +if [ -f "package.json" ]; then + # Node.js — use project's test runner + npx jest ${REGRESSION_FILES} --passWithNoTests --no-coverage -q 2>&1 || npx vitest run ${REGRESSION_FILES} 2>&1 +elif [ -f "Cargo.toml" ]; then + cargo test 2>&1 +elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then + python -m pytest ${REGRESSION_FILES} -q --tb=short 2>&1 +fi +``` + +**Step 4: Report results** + +If all tests pass: +``` +✓ Regression gate: {N} prior-phase test files passed — no regressions detected +``` +→ Proceed to verify_phase_goal + +If any tests fail: +``` +## ⚠ Cross-Phase Regression Detected + +Phase {X} execution may have broken functionality from prior phases. + +| Test File | Phase | Status | Detail | +|-----------|-------|--------|--------| +| {file} | {origin_phase} | FAILED | {first_failure_line} | + +Options: +1. Fix regressions before verification (recommended) +2. Continue to verification anyway (regressions will compound) +3. Abort phase — roll back and re-plan +``` + +Use AskUserQuestion to present the options. + + Verify phase achieved its GOAL, not just completed tasks. @@ -412,6 +580,28 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(phase-{X}): co ``` + +**Evolve PROJECT.md to reflect phase completion (prevents planning document drift — #956):** + +PROJECT.md tracks validated requirements, decisions, and current state. Without this step, +PROJECT.md falls behind silently over multiple phases. + +1. Read `.planning/PROJECT.md` +2. If the file exists and has a `## Validated Requirements` or `## Requirements` section: + - Move any requirements validated by this phase from Active → Validated + - Add a brief note: `Validated in Phase {X}: {Name}` +3. If the file has a `## Current State` or similar section: + - Update it to reflect this phase's completion (e.g., "Phase {X} complete — {one-liner}") +4. Update the `Last updated:` footer to today's date +5. Commit the change: + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(phase-{X}): evolve PROJECT.md after phase completion" --files .planning/PROJECT.md +``` + +**Skip this step if** `.planning/PROJECT.md` does not exist. + + **Exception:** If `gaps_found`, the `verify_phase_goal` step already presents the gap-closure path (`/gsd:plan-phase {X} --gaps`). No additional routing needed — skip auto-advance. @@ -465,6 +655,8 @@ Read and follow `~/.claude/get-shit-done/workflows/transition.md`, passing throu **STOP. Do not auto-advance. Do not execute transition. Do not plan next phase. Present options to the user and wait.** +**IMPORTANT: There is NO `/gsd:transition` command. Never suggest it. The transition workflow is internal only.** + ``` ## ✓ Phase {X}: {Name} Complete @@ -473,12 +665,20 @@ Read and follow `~/.claude/get-shit-done/workflows/transition.md`, passing throu /gsd:plan-phase {next} — plan next phase /gsd:execute-phase {next} — execute next phase ``` + +Only suggest the commands listed above. Do not invent or hallucinate command names. -Orchestrator: ~10-15% context. Subagents: fresh 200k each. No polling (Task blocks). No context bleed. +Orchestrator: ~10-15% context for 200k windows, can use more for 1M+ windows. +Subagents: fresh context each (200k-1M depending on model). No polling (Task blocks). No context bleed. + +For 1M+ context models, consider: +- Passing richer context (code snippets, dependency outputs) directly to executors instead of just file paths +- Running small phases (≤3 plans, no dependencies) inline without subagent spawning overhead +- Relaxing /clear recommendations — context rot onset is much further out with 5x window diff --git a/get-shit-done/workflows/execute-plan.md b/get-shit-done/workflows/execute-plan.md index d47b6a18f..0e5d8e8a5 100644 --- a/get-shit-done/workflows/execute-plan.md +++ b/get-shit-done/workflows/execute-plan.md @@ -135,7 +135,8 @@ If previous SUMMARY has unresolved "Issues Encountered" or "Next Phase Readiness Deviations are normal — handle via rules below. 1. Read @context files from prompt -2. Per task: +2. **MCP tools:** If CLAUDE.md or project instructions reference MCP tools (e.g. jCodeMunch for code navigation), prefer them over Grep/Glob when available. Fall back to Grep/Glob if MCP tools are not accessible. +3. Per task: - **MANDATORY read_first gate:** If the task has a `` field, you MUST read every listed file BEFORE making any edits. This is not optional. Do not skip files because you "already know" what's in them — read them. The read_first files establish ground truth for the task. - `type="auto"`: if `tdd="true"` → TDD execution. Implement with deviation rules + auth gates. Verify done criteria. Commit (see task_commit). Track hash for Summary. - `type="checkpoint:*"`: STOP → checkpoint_protocol → wait for user → continue only after confirmation. @@ -233,6 +234,10 @@ See `~/.claude/get-shit-done/references/tdd.md` for structure. Your commits may trigger pre-commit hooks. Auto-fix hooks handle themselves transparently — files get fixed and re-staged automatically. +**If running as a parallel executor agent (spawned by execute-phase):** +Use `--no-verify` on all commits. Pre-commit hooks cause build lock contention when multiple agents commit simultaneously (e.g., cargo lock fights in Rust projects). The orchestrator validates once after all agents complete. + +**If running as the sole executor (sequential mode):** If a commit is BLOCKED by a hook: 1. The `git commit` command fails with hook error output @@ -240,9 +245,7 @@ If a commit is BLOCKED by a hook: 3. Fix the issue (type error, lint violation, secret leak, etc.) 4. `git add` the fixed files 5. Retry the commit -6. Do NOT use `--no-verify` - -This is normal and expected. Budget 1-2 retry cycles per commit. +6. Budget 1-2 retry cycles per commit @@ -370,7 +373,7 @@ One-liner SUBSTANTIVE: "JWT auth with refresh rotation using jose library" not " Include: duration, start/end times, task count, file count. -Next: more plans → "Ready for {next-plan}" | last → "Phase complete, ready for transition". +Next: more plans → "Ready for {next-plan}" | last → "Phase complete, ready for next step". diff --git a/get-shit-done/workflows/fast.md b/get-shit-done/workflows/fast.md new file mode 100644 index 000000000..729bcc32e --- /dev/null +++ b/get-shit-done/workflows/fast.md @@ -0,0 +1,105 @@ + +Execute a trivial task inline without subagent overhead. No PLAN.md, no Task spawning, +no research, no plan checking. Just: understand → do → commit → log. + +For tasks like: fix a typo, update a config value, add a missing import, rename a +variable, commit uncommitted work, add a .gitignore entry, bump a version number. + +Use /gsd:quick for anything that needs multi-step planning or research. + + + + + +Parse `$ARGUMENTS` for the task description. + +If empty, ask: +``` +What's the quick fix? (one sentence) +``` + +Store as `$TASK`. + + + +**Before doing anything, verify this is actually trivial.** + +A task is trivial if it can be completed in: +- ≤ 3 file edits +- ≤ 1 minute of work +- No new dependencies or architecture changes +- No research needed + +If the task seems non-trivial (multi-file refactor, new feature, needs research), +say: + +``` +This looks like it needs planning. Use /gsd:quick instead: + /gsd:quick "{task description}" +``` + +And stop. + + + +Do the work directly: + +1. Read the relevant file(s) +2. Make the change(s) +3. Verify the change works (run existing tests if applicable, or do a quick sanity check) + +**No PLAN.md.** Just do it. + + + +Commit the change atomically: + +```bash +git add -A +git commit -m "fix: {concise description of what changed}" +``` + +Use conventional commit format: `fix:`, `feat:`, `docs:`, `chore:`, `refactor:` as appropriate. + + + +If `.planning/STATE.md` exists, append to the "Quick Tasks Completed" table. +If the table doesn't exist, skip this step silently. + +```bash +# Check if STATE.md has quick tasks table +if grep -q "Quick Tasks Completed" .planning/STATE.md 2>/dev/null; then + # Append entry — workflow handles the format + echo "| $(date +%Y-%m-%d) | fast | $TASK | ✅ |" >> .planning/STATE.md +fi +``` + + + +Report completion: + +``` +✅ Done: {what was changed} + Commit: {short hash} + Files: {list of changed files} +``` + +No next-step suggestions. No workflow routing. Just done. + + + + + +- NEVER spawn a Task/subagent — this runs inline +- NEVER create PLAN.md or SUMMARY.md files +- NEVER run research or plan-checking +- If the task takes more than 3 file edits, STOP and redirect to /gsd:quick +- If you're unsure how to implement it, STOP and redirect to /gsd:quick + + + +- [ ] Task completed in current context (no subagents) +- [ ] Atomic git commit with conventional message +- [ ] STATE.md updated if it exists +- [ ] Total operation under 2 minutes wall time + diff --git a/get-shit-done/workflows/health.md b/get-shit-done/workflows/health.md index 54b7a1423..0cb95fd1b 100644 --- a/get-shit-done/workflows/health.md +++ b/get-shit-done/workflows/health.md @@ -157,3 +157,25 @@ Report final status. - Orphaned plan cleanup + + +**Windows-specific:** Check for stale Claude Code task directories that accumulate on crash/freeze. +These are left behind when subagents are force-killed and consume disk space. + +When `--repair` is active, detect and clean up: + +```bash +# Check for stale task directories (older than 24 hours) +TASKS_DIR="$HOME/.claude/tasks" +if [ -d "$TASKS_DIR" ]; then + STALE_COUNT=$(find "$TASKS_DIR" -maxdepth 1 -type d -mtime +1 2>/dev/null | wc -l) + if [ "$STALE_COUNT" -gt 0 ]; then + echo "⚠️ Found $STALE_COUNT stale task directories in ~/.claude/tasks/" + echo " These are leftover from crashed subagent sessions." + echo " Run: rm -rf ~/.claude/tasks/* (safe — only affects dead sessions)" + fi +fi +``` + +Report as info diagnostic: `I002 | info | Stale subagent task directories found | Yes (--repair removes them)` + diff --git a/get-shit-done/workflows/help.md b/get-shit-done/workflows/help.md index 058d4a810..cdd60638e 100644 --- a/get-shit-done/workflows/help.md +++ b/get-shit-done/workflows/help.md @@ -151,6 +151,21 @@ Usage: `/gsd:quick` Usage: `/gsd:quick --research --full` Result: Creates `.planning/quick/NNN-slug/PLAN.md`, `.planning/quick/NNN-slug/SUMMARY.md` +--- + +**`/gsd:fast [description]`** +Execute a trivial task inline — no subagents, no planning files, no overhead. + +For tasks too small to justify planning: typo fixes, config changes, forgotten commits, simple additions. Runs in the current context, makes the change, commits, and logs to STATE.md. + +- No PLAN.md or SUMMARY.md created +- No subagent spawned (runs inline) +- ≤ 3 file edits — redirects to `/gsd:quick` if task is non-trivial +- Atomic commit with conventional message + +Usage: `/gsd:fast "fix the typo in README"` +Usage: `/gsd:fast "add .env to gitignore"` + ### Roadmap Management **`/gsd:add-phase `** @@ -308,6 +323,20 @@ Validate built features through conversational UAT. Usage: `/gsd:verify-work 3` +### Ship Work + +**`/gsd:ship [phase]`** +Create a PR from completed phase work with an auto-generated body. + +- Pushes branch to remote +- Creates PR with summary from SUMMARY.md, VERIFICATION.md, REQUIREMENTS.md +- Optionally requests code review +- Updates STATE.md with shipping status + +Prerequisites: Phase verified, `gh` CLI installed and authenticated. + +Usage: `/gsd:ship 4` or `/gsd:ship 4 --draft` + ### Milestone Auditing **`/gsd:audit-milestone [version]`** diff --git a/get-shit-done/workflows/map-codebase.md b/get-shit-done/workflows/map-codebase.md index 901b14511..6398e5d8b 100644 --- a/get-shit-done/workflows/map-codebase.md +++ b/get-shit-done/workflows/map-codebase.md @@ -82,12 +82,25 @@ mkdir -p .planning/codebase Continue to spawn_agents. - + +Before spawning agents, detect whether the current runtime supports the `Task` tool for subagent delegation. + +**Runtimes with Task tool:** Claude Code, Cursor (native subagent support) +**Runtimes WITHOUT Task tool:** Antigravity, Gemini CLI, OpenCode, Codex, and others + +**How to detect:** Check if you have access to a `Task` tool. If you do NOT have a `Task` tool (or only have tools like `browser_subagent` which is for web browsing, NOT code analysis): + +→ **Skip `spawn_agents` and `collect_confirmations`** — go directly to `sequential_mapping` instead. + +**CRITICAL:** Never use `browser_subagent` or `Explore` as a substitute for `Task`. The `browser_subagent` tool is exclusively for web page interaction and will fail for codebase analysis. If `Task` is unavailable, perform the mapping sequentially in-context. + + + Spawn 4 parallel gsd-codebase-mapper agents. Use Task tool with `subagent_type="gsd-codebase-mapper"`, `model="{mapper_model}"`, and `run_in_background=true` for parallel execution. -**CRITICAL:** Use the dedicated `gsd-codebase-mapper` agent, NOT `Explore`. The mapper agent writes documents directly. +**CRITICAL:** Use the dedicated `gsd-codebase-mapper` agent, NOT `Explore` or `browser_subagent`. The mapper agent writes documents directly. **Agent 1: Tech Focus** @@ -195,6 +208,37 @@ If any agent failed, note the failure and continue with successful documents. Continue to verify_output. + +When the `Task` tool is unavailable, perform codebase mapping sequentially in the current context. This replaces `spawn_agents` and `collect_confirmations`. + +**IMPORTANT:** Do NOT use `browser_subagent`, `Explore`, or any browser-based tool. Use only file system tools (Read, Bash, Write, Grep, Glob, list_dir, view_file, grep_search, or equivalent tools available in your runtime). + +Perform all 4 mapping passes sequentially: + +**Pass 1: Tech Focus** +- Explore package.json/Cargo.toml/go.mod/requirements.txt, config files, dependency trees +- Write `.planning/codebase/STACK.md` — Languages, runtime, frameworks, dependencies, configuration +- Write `.planning/codebase/INTEGRATIONS.md` — External APIs, databases, auth providers, webhooks + +**Pass 2: Architecture Focus** +- Explore directory structure, entry points, module boundaries, data flow +- Write `.planning/codebase/ARCHITECTURE.md` — Pattern, layers, data flow, abstractions, entry points +- Write `.planning/codebase/STRUCTURE.md` — Directory layout, key locations, naming conventions + +**Pass 3: Quality Focus** +- Explore code style, error handling patterns, test files, CI config +- Write `.planning/codebase/CONVENTIONS.md` — Code style, naming, patterns, error handling +- Write `.planning/codebase/TESTING.md` — Framework, structure, mocking, coverage + +**Pass 4: Concerns Focus** +- Explore TODOs, known issues, fragile areas, security patterns +- Write `.planning/codebase/CONCERNS.md` — Tech debt, bugs, security, performance, fragile areas + +Use the same document templates as the `gsd-codebase-mapper` agent. Include actual file paths formatted with backticks. + +Continue to verify_output. + + Verify all documents created successfully: @@ -307,10 +351,10 @@ End workflow. - .planning/codebase/ directory created -- 4 parallel gsd-codebase-mapper agents spawned with run_in_background=true -- Agents write documents directly (orchestrator doesn't receive document contents) -- Read agent output files to collect confirmations +- If Task tool available: 4 parallel gsd-codebase-mapper agents spawned with run_in_background=true +- If Task tool NOT available: 4 sequential mapping passes performed inline (never using browser_subagent) - All 7 codebase documents exist +- No empty documents (each should have >20 lines) - Clear completion summary with line counts - User offered clear next steps in GSD style diff --git a/get-shit-done/workflows/new-milestone.md b/get-shit-done/workflows/new-milestone.md index 59b894bbb..492108ef3 100644 --- a/get-shit-done/workflows/new-milestone.md +++ b/get-shit-done/workflows/new-milestone.md @@ -54,6 +54,27 @@ Add/update: Update Active requirements section and "Last updated" footer. +Ensure the `## Evolution` section exists in PROJECT.md. If missing (projects created before this feature), add it before the footer: + +```markdown +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd:transition`): +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone** (via `/gsd:complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state +``` + ## 5. Update STATE.md ```markdown diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index 893de103e..d2043ab66 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -323,6 +323,27 @@ Initialize with any decisions made during questioning: *Last updated: [date] after initialization* ``` +**Evolution section** (include at the end of PROJECT.md, before the footer): + +```markdown +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd:transition`): +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone** (via `/gsd:complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state +``` + Do not compress. Capture everything gathered. **Commit PROJECT.md:** diff --git a/get-shit-done/workflows/next.md b/get-shit-done/workflows/next.md new file mode 100644 index 000000000..80e2f3622 --- /dev/null +++ b/get-shit-done/workflows/next.md @@ -0,0 +1,97 @@ + +Detect current project state and automatically advance to the next logical GSD workflow step. +Reads project state to determine: discuss → plan → execute → verify → complete progression. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + + +Read project state to determine current position: + +```bash +# Get state snapshot +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state json 2>/dev/null || echo "{}" +``` + +Also read: +- `.planning/STATE.md` — current phase, progress, plan counts +- `.planning/ROADMAP.md` — milestone structure and phase list + +Extract: +- `current_phase` — which phase is active +- `plan_of` / `plans_total` — plan execution progress +- `progress` — overall percentage +- `status` — active, paused, etc. + +If no `.planning/` directory exists: +``` +No GSD project detected. Run `/gsd:new-project` to get started. +``` +Exit. + + + +Apply routing rules based on state: + +**Route 1: No phases exist yet → discuss** +If ROADMAP has phases but no phase directories exist on disk: +→ Next action: `/gsd:discuss-phase ` + +**Route 2: Phase exists but has no CONTEXT.md or RESEARCH.md → discuss** +If the current phase directory exists but has neither CONTEXT.md nor RESEARCH.md: +→ Next action: `/gsd:discuss-phase ` + +**Route 3: Phase has context but no plans → plan** +If the current phase has CONTEXT.md (or RESEARCH.md) but no PLAN.md files: +→ Next action: `/gsd:plan-phase ` + +**Route 4: Phase has plans but incomplete summaries → execute** +If plans exist but not all have matching summaries: +→ Next action: `/gsd:execute-phase ` + +**Route 5: All plans have summaries → verify and complete** +If all plans in the current phase have summaries: +→ Next action: `/gsd:verify-work` then `/gsd:complete-phase` + +**Route 6: Phase complete, next phase exists → advance** +If the current phase is complete and the next phase exists in ROADMAP: +→ Next action: `/gsd:discuss-phase ` + +**Route 7: All phases complete → complete milestone** +If all phases are complete: +→ Next action: `/gsd:complete-milestone` + +**Route 8: Paused → resume** +If STATE.md shows paused_at: +→ Next action: `/gsd:resume-work` + + + +Display the determination: + +``` +## GSD Next + +**Current:** Phase [N] — [name] | [progress]% +**Status:** [status description] + +▶ **Next step:** `/gsd:[command] [args]` + [One-line explanation of why this is the next step] +``` + +Then immediately invoke the determined command via SlashCommand. +Do not ask for confirmation — the whole point of `/gsd:next` is zero-friction advancement. + + + + + +- [ ] Project state correctly detected +- [ ] Next action correctly determined from routing rules +- [ ] Command invoked immediately without user confirmation +- [ ] Clear status shown before invoking + diff --git a/get-shit-done/workflows/pause-work.md b/get-shit-done/workflows/pause-work.md index f723ef81a..ccdba267d 100644 --- a/get-shit-done/workflows/pause-work.md +++ b/get-shit-done/workflows/pause-work.md @@ -1,5 +1,5 @@ -Create `.continue-here.md` handoff file to preserve complete work state across sessions. Enables seamless resumption with full context restoration. +Create structured `.planning/HANDOFF.json` and `.continue-here.md` handoff files to preserve complete work state across sessions. The JSON provides machine-readable state for `/gsd:resume-work`; the markdown provides human-readable context. @@ -27,10 +27,61 @@ If no active phase detected, ask user which phase they're pausing work on. 3. **Work remaining**: What's left in current plan/phase 4. **Decisions made**: Key decisions and rationale 5. **Blockers/issues**: Anything stuck -6. **Mental context**: The approach, next steps, "vibe" -7. **Files modified**: What's changed but not committed +6. **Human actions pending**: Things that need manual intervention (MCP setup, API keys, approvals, manual testing) +7. **Background processes**: Any running servers/watchers that were part of the workflow +8. **Files modified**: What's changed but not committed Ask user for clarifications if needed via conversational questions. + +**Also inspect SUMMARY.md files for false completions:** +```bash +# Check for placeholder content in existing summaries +grep -l "To be filled\|placeholder\|TBD" .planning/phases/*/*.md 2>/dev/null +``` +Report any summaries with placeholder content as incomplete items. + + + +**Write structured handoff to `.planning/HANDOFF.json`:** + +```bash +timestamp=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" current-timestamp full --raw) +``` + +```json +{ + "version": "1.0", + "timestamp": "{timestamp}", + "phase": "{phase_number}", + "phase_name": "{phase_name}", + "phase_dir": "{phase_dir}", + "plan": {current_plan_number}, + "task": {current_task_number}, + "total_tasks": {total_task_count}, + "status": "paused", + "completed_tasks": [ + {"id": 1, "name": "{task_name}", "status": "done", "commit": "{short_hash}"}, + {"id": 2, "name": "{task_name}", "status": "done", "commit": "{short_hash}"}, + {"id": 3, "name": "{task_name}", "status": "in_progress", "progress": "{what_done}"} + ], + "remaining_tasks": [ + {"id": 4, "name": "{task_name}", "status": "not_started"}, + {"id": 5, "name": "{task_name}", "status": "not_started"} + ], + "blockers": [ + {"description": "{blocker}", "type": "technical|human_action|external", "workaround": "{if any}"} + ], + "human_actions_pending": [ + {"action": "{what needs to be done}", "context": "{why}", "blocking": true} + ], + "decisions": [ + {"decision": "{what}", "rationale": "{why}", "phase": "{phase_number}"} + ], + "uncommitted_files": [], + "next_action": "{specific first action when resuming}", + "context_notes": "{mental state, approach, what you were thinking}" +} +``` @@ -92,19 +143,22 @@ timestamp=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" current-timesta ```bash -node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "wip: [phase-name] paused at task [X]/[Y]" --files .planning/phases/*/.continue-here.md +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "wip: [phase-name] paused at task [X]/[Y]" --files .planning/phases/*/.continue-here.md .planning/HANDOFF.json ``` ``` -✓ Handoff created: .planning/phases/[XX-name]/.continue-here.md +✓ Handoff created: + - .planning/HANDOFF.json (structured, machine-readable) + - .planning/phases/[XX-name]/.continue-here.md (human-readable) Current state: - Phase: [XX-name] - Task: [X] of [Y] - Status: [in_progress/blocked] +- Blockers: [count] ({human_actions_pending count} need human action) - Committed as WIP To resume: /gsd:resume-work diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index f7114c5a8..26697dc3e 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -8,6 +8,13 @@ Read all files referenced by the invoking prompt's execution_context before star @~/.claude/get-shit-done/references/ui-brand.md + +Valid GSD subagent types (use exact names — do not fall back to 'general-purpose'): +- gsd-phase-researcher — Researches technical approaches for a phase +- gsd-planner — Creates detailed plans from phase scope +- gsd-plan-checker — Reviews plan quality before execution + + ## 1. Initialize @@ -170,7 +177,16 @@ Use AskUserQuestion: - "Run discuss-phase first" — Capture design decisions before planning If "Continue without context": Proceed to step 5. -If "Run discuss-phase first": Display `/gsd:discuss-phase {X}` and exit workflow. +If "Run discuss-phase first": + **IMPORTANT:** Do NOT invoke discuss-phase as a nested Skill/Task call — AskUserQuestion + does not work correctly in nested subcontexts (#1009). Instead, display the command + and exit so the user runs it as a top-level command: + ``` + Run this command first, then re-run /gsd:plan-phase {X}: + + /gsd:discuss-phase {X} + ``` + **Exit the plan-phase workflow. Do not continue.** ## 5. Handle Research @@ -571,11 +587,62 @@ Display: `Max iterations reached. {N} issues remain:` + issue list Offer: 1) Force proceed, 2) Provide guidance and retry, 3) Abandon -## 13. Present Final Status +## 13. Requirements Coverage Gate + +After plans pass the checker (or checker is skipped), verify that all phase requirements are covered by at least one plan. + +**Skip if:** `phase_req_ids` is null or TBD (no requirements mapped to this phase). + +**Step 1: Extract requirement IDs claimed by plans** +```bash +# Collect all requirement IDs from plan frontmatter +PLAN_REQS=$(grep -h "requirements_addressed\|requirements:" ${PHASE_DIR}/*-PLAN.md 2>/dev/null | tr -d '[]' | tr ',' '\n' | sed 's/^[[:space:]]*//' | sort -u) +``` + +**Step 2: Compare against phase requirements from ROADMAP** + +For each REQ-ID in `phase_req_ids`: +- If REQ-ID appears in `PLAN_REQS` → covered ✓ +- If REQ-ID does NOT appear in any plan → uncovered ✗ + +**Step 3: Check CONTEXT.md features against plan objectives** + +Read CONTEXT.md `` section. Extract feature/capability names. Check each against plan `` blocks. Features not mentioned in any plan objective → potentially dropped. + +**Step 4: Report** + +If all requirements covered and no dropped features: +``` +✓ Requirements coverage: {N}/{N} REQ-IDs covered by plans +``` +→ Proceed to step 14. + +If gaps found: +``` +## ⚠ Requirements Coverage Gap + +{M} of {N} phase requirements are not assigned to any plan: + +| REQ-ID | Description | Plans | +|--------|-------------|-------| +| {id} | {from REQUIREMENTS.md} | None | + +{K} CONTEXT.md features not found in plan objectives: +- {feature_name} — described in CONTEXT.md but no plan covers it + +Options: +1. Re-plan to include missing requirements (recommended) +2. Move uncovered requirements to next phase +3. Proceed anyway — accept coverage gaps +``` + +Use AskUserQuestion to present the options. + +## 14. Present Final Status Route to `` OR `auto_advance` depending on flags/config. -## 14. Auto-Advance Check +## 15. Auto-Advance Check Check for auto-advance trigger: @@ -670,6 +737,30 @@ Verification: {Passed | Passed with override | Skipped} ─────────────────────────────────────────────────────────────── + +**Windows users:** If plan-phase freezes during agent spawning (common on Windows due to +stdio deadlocks with MCP servers — see Claude Code issue anthropics/claude-code#28126): + +1. **Force-kill:** Close the terminal (Ctrl+C may not work) +2. **Clean up orphaned processes:** + ```powershell + # Kill orphaned node processes from stale MCP servers + Get-Process node -ErrorAction SilentlyContinue | Where-Object {$_.StartTime -lt (Get-Date).AddHours(-1)} | Stop-Process -Force + ``` +3. **Clean up stale task directories:** + ```powershell + # Remove stale subagent task dirs (Claude Code never cleans these on crash) + Remove-Item -Recurse -Force "$env:USERPROFILE\.claude\tasks\*" -ErrorAction SilentlyContinue + ``` +4. **Reduce MCP server count:** Temporarily disable non-essential MCP servers in settings.json +5. **Retry:** Restart Claude Code and run `/gsd:plan-phase` again + +If freezes persist, try `--skip-research` to reduce the agent chain from 3 to 2 agents: +``` +/gsd:plan-phase N --skip-research +``` + + - [ ] .planning/ directory validated - [ ] Phase validated against roadmap diff --git a/get-shit-done/workflows/resume-project.md b/get-shit-done/workflows/resume-project.md index 00ce54df0..a8dafcf2c 100644 --- a/get-shit-done/workflows/resume-project.md +++ b/get-shit-done/workflows/resume-project.md @@ -63,6 +63,9 @@ cat .planning/PROJECT.md Look for incomplete work that needs attention: ```bash +# Check for structured handoff (preferred — machine-readable) +cat .planning/HANDOFF.json 2>/dev/null + # Check for continue-here files (mid-plan resumption) ls .planning/phases/*/.continue-here*.md 2>/dev/null @@ -78,7 +81,18 @@ if [ "$has_interrupted_agent" = "true" ]; then fi ``` -**If .continue-here file exists:** +**If HANDOFF.json exists:** + +- This is the primary resumption source — structured data from `/gsd:pause-work` +- Parse `status`, `phase`, `plan`, `task`, `total_tasks`, `next_action` +- Check `blockers` and `human_actions_pending` — surface these immediately +- Check `completed_tasks` for `in_progress` items — these need attention first +- Validate `uncommitted_files` against `git status` — flag divergence +- Use `context_notes` to restore mental model +- Flag: "Found structured handoff — resuming from task {task}/{total_tasks}" +- **After successful resumption, delete HANDOFF.json** (it's a one-shot artifact) + +**If .continue-here file exists (fallback):** - This is a mid-plan resumption point - Read the file for specific resumption context @@ -145,8 +159,12 @@ Based on project state, determine the most logical next action: → Primary: Resume interrupted agent (Task tool with resume parameter) → Option: Start fresh (abandon agent work) +**If HANDOFF.json exists:** +→ Primary: Resume from structured handoff (highest priority — specific task/blocker context) +→ Option: Discard handoff and reassess from files + **If .continue-here file exists:** -→ Primary: Resume from checkpoint +→ Fallback: Resume from checkpoint → Option: Start fresh on current plan **If incomplete plan (PLAN without SUMMARY):** @@ -154,7 +172,7 @@ Based on project state, determine the most logical next action: → Option: Abandon and move on **If phase in progress, all plans complete:** -→ Primary: Transition to next phase +→ Primary: Advance to next phase (via internal transition workflow) → Option: Review completed work **If phase ready to plan:** @@ -242,7 +260,7 @@ Based on user selection, route to appropriate workflow: --- ``` -- **Transition** → ./transition.md +- **Advance to next phase** → ./transition.md (internal workflow, invoked inline — NOT a user command) - **Check todos** → Read .planning/todos/pending/, present summary - **Review alignment** → Read PROJECT.md, compare to current state - **Something else** → Ask what they need diff --git a/get-shit-done/workflows/session-report.md b/get-shit-done/workflows/session-report.md new file mode 100644 index 000000000..f336edc08 --- /dev/null +++ b/get-shit-done/workflows/session-report.md @@ -0,0 +1,146 @@ + +Generate a post-session summary document capturing work performed, outcomes achieved, and estimated resource usage. Writes SESSION_REPORT.md to .planning/reports/ for human review and stakeholder sharing. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + + +Collect session data from available sources: + +1. **STATE.md** — current phase, milestone, progress, blockers, decisions +2. **Git log** — commits made during this session (last 24h or since last report) +3. **Plan/Summary files** — plans executed, summaries written +4. **ROADMAP.md** — milestone context and phase goals + +```bash +# Get recent commits (last 24 hours) +git log --oneline --since="24 hours ago" --no-merges 2>/dev/null || echo "No recent commits" + +# Count files changed +git diff --stat HEAD~10 HEAD 2>/dev/null | tail -1 || echo "No diff available" +``` + +Read `.planning/STATE.md` to get: +- Current milestone and phase +- Progress percentage +- Active blockers +- Recent decisions + +Read `.planning/ROADMAP.md` to get milestone name and goals. + +Check for existing reports: +```bash +ls -la .planning/reports/SESSION_REPORT*.md 2>/dev/null || echo "No previous reports" +``` + + + +Estimate token usage from observable signals: + +- Count of tool calls is not directly available, so estimate from git activity and file operations +- Note: This is an **estimate** — exact token counts require API-level instrumentation not available to hooks + +Estimation heuristics: +- Each commit ≈ 1 plan cycle (research + plan + execute + verify) +- Each plan file ≈ 2,000-5,000 tokens of agent context +- Each summary file ≈ 1,000-2,000 tokens generated +- Subagent spawns multiply by ~1.5x per agent type used + + + +Create the report directory and file: + +```bash +mkdir -p .planning/reports +``` + +Write `.planning/reports/SESSION_REPORT.md` (or `.planning/reports/YYYYMMDD-session-report.md` if previous reports exist): + +```markdown +# GSD Session Report + +**Generated:** [timestamp] +**Project:** [from PROJECT.md title or directory name] +**Milestone:** [N] — [milestone name from ROADMAP.md] + +--- + +## Session Summary + +**Duration:** [estimated from first to last commit timestamp, or "Single session"] +**Phase Progress:** [from STATE.md] +**Plans Executed:** [count of summaries written this session] +**Commits Made:** [count from git log] + +## Work Performed + +### Phases Touched +[List phases worked on with brief description of what was done] + +### Key Outcomes +[Bullet list of concrete deliverables: files created, features implemented, bugs fixed] + +### Decisions Made +[From STATE.md decisions table, if any were added this session] + +## Files Changed + +[Summary of files modified, created, deleted — from git diff stat] + +## Blockers & Open Items + +[Active blockers from STATE.md] +[Any TODO items created during session] + +## Estimated Resource Usage + +| Metric | Estimate | +|--------|----------| +| Commits | [N] | +| Files changed | [N] | +| Plans executed | [N] | +| Subagents spawned | [estimated] | + +> **Note:** Token and cost estimates require API-level instrumentation. +> These metrics reflect observable session activity only. + +--- + +*Generated by `/gsd:session-report`* +``` + + + +Show the user: + +``` +## Session Report Generated + +📄 `.planning/reports/[filename].md` + +### Highlights +- **Commits:** [N] +- **Files changed:** [N] +- **Phase progress:** [X]% +- **Plans executed:** [N] +``` + +If this is the first report, mention: +``` +💡 Run `/gsd:session-report` at the end of each session to build a history of project activity. +``` + + + + + +- [ ] Session data gathered from STATE.md, git log, and plan files +- [ ] Report written to .planning/reports/ +- [ ] Report includes work summary, outcomes, and file changes +- [ ] Filename includes date to prevent overwrites +- [ ] Result summary displayed to user + diff --git a/get-shit-done/workflows/settings.md b/get-shit-done/workflows/settings.md index 7fc344559..760056918 100644 --- a/get-shit-done/workflows/settings.md +++ b/get-shit-done/workflows/settings.md @@ -49,7 +49,7 @@ AskUserQuestion([ { label: "Quality", description: "Opus everywhere except verification (highest cost)" }, { label: "Balanced (Recommended)", description: "Opus for planning, Sonnet for research/execution/verification" }, { label: "Budget", description: "Sonnet for writing, Haiku for research/verification (lowest cost)" }, - { label: "Inherit", description: "Use current session model for all agents (best for OpenCode /model)" } + { label: "Inherit", description: "Use current session model for all agents (best for OpenRouter, local models, or runtime model switching)" } ] }, { @@ -160,7 +160,8 @@ Merge new settings into existing config.json: "branching_strategy": "none" | "phase" | "milestone" }, "hooks": { - "context_warnings": true/false + "context_warnings": true/false, + "workflow_guard": true/false } } ``` diff --git a/get-shit-done/workflows/ship.md b/get-shit-done/workflows/ship.md new file mode 100644 index 000000000..3c29de1ff --- /dev/null +++ b/get-shit-done/workflows/ship.md @@ -0,0 +1,228 @@ + +Create a pull request from completed phase/milestone work, generate a rich PR body from planning artifacts, optionally run code review, and prepare for merge. Closes the plan → execute → verify → ship loop. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + + +Parse arguments and load project state: + +```bash +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op "${PHASE_ARG}") +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi +``` + +Parse from init JSON: `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `padded_phase`, `commit_docs`. + +Also load config for branching strategy: +```bash +CONFIG=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state load) +``` + +Extract: `branching_strategy`, `branch_name`. + + + +Verify the work is ready to ship: + +1. **Verification passed?** + ```bash + VERIFICATION=$(cat ${PHASE_DIR}/*-VERIFICATION.md 2>/dev/null) + ``` + Check for `status: passed` or `status: human_needed` (with human approval). + If no VERIFICATION.md or status is `gaps_found`: warn and ask user to confirm. + +2. **Clean working tree?** + ```bash + git status --short + ``` + If uncommitted changes exist: ask user to commit or stash first. + +3. **On correct branch?** + ```bash + CURRENT_BRANCH=$(git branch --show-current) + ``` + If on `main`/`master`: warn — should be on a feature branch. + If branching_strategy is `none`: offer to create a branch now. + +4. **Remote configured?** + ```bash + git remote -v | head -2 + ``` + Detect `origin` remote. If no remote: error — can't create PR. + +5. **`gh` CLI available?** + ```bash + which gh && gh auth status 2>&1 + ``` + If `gh` not found or not authenticated: provide setup instructions and exit. + + + +Push the current branch to remote: + +```bash +git push origin ${CURRENT_BRANCH} 2>&1 +``` + +If push fails (e.g., no upstream): set upstream: +```bash +git push --set-upstream origin ${CURRENT_BRANCH} 2>&1 +``` + +Report: "Pushed `{branch}` to origin ({commit_count} commits ahead of main)" + + + +Auto-generate a rich PR body from planning artifacts: + +**1. Title:** +``` +Phase {phase_number}: {phase_name} +``` +Or for milestone: `Milestone {version}: {name}` + +**2. Summary section:** +Read ROADMAP.md for phase goal. Read VERIFICATION.md for verification status. + +```markdown +## Summary + +**Phase {N}: {Name}** +**Goal:** {goal from ROADMAP.md} +**Status:** Verified ✓ + +{One paragraph synthesized from SUMMARY.md files — what was built} +``` + +**3. Changes section:** +For each SUMMARY.md in the phase directory: +```markdown +## Changes + +### Plan {plan_id}: {plan_name} +{one_liner from SUMMARY.md frontmatter} + +**Key files:** +{key-files.created and key-files.modified from SUMMARY.md frontmatter} +``` + +**4. Requirements section:** +```markdown +## Requirements Addressed + +{REQ-IDs from plan frontmatter, linked to REQUIREMENTS.md descriptions} +``` + +**5. Testing section:** +```markdown +## Verification + +- [x] Automated verification: {pass/fail from VERIFICATION.md} +- {human verification items from VERIFICATION.md, if any} +``` + +**6. Decisions section:** +```markdown +## Key Decisions + +{Decisions from STATE.md accumulated context relevant to this phase} +``` + + + +Create the PR using the generated body: + +```bash +gh pr create \ + --title "Phase ${PHASE_NUMBER}: ${PHASE_NAME}" \ + --body "${PR_BODY}" \ + --base main +``` + +If `--draft` flag was passed: add `--draft`. + +Report: "PR #{number} created: {url}" + + + +Ask if user wants to trigger a code review: + +``` +AskUserQuestion: + question: "PR created. Run a code review before merge?" + options: + - label: "Skip review" + description: "PR is ready — merge when CI passes" + - label: "Self-review" + description: "I'll review the diff in the PR myself" + - label: "Request review" + description: "Request review from a teammate" +``` + +**If "Request review":** +```bash +gh pr edit ${PR_NUMBER} --add-reviewer "${REVIEWER}" +``` + +**If "Self-review":** +Report the PR URL and suggest: "Review the diff at {url}/files" + + + +Update STATE.md to reflect the shipping action: + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state update "Last Activity" "$(date +%Y-%m-%d)" +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state update "Status" "Phase ${PHASE_NUMBER} shipped — PR #${PR_NUMBER}" +``` + +If `commit_docs` is true: +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${padded_phase}): ship phase ${PHASE_NUMBER} — PR #${PR_NUMBER}" --files .planning/STATE.md +``` + + + +``` +─────────────────────────────────────────────────────────────── + +## ✓ Phase {X}: {Name} — Shipped + +PR: #{number} ({url}) +Branch: {branch} → main +Commits: {count} +Verification: ✓ Passed +Requirements: {N} REQ-IDs addressed + +Next steps: +- Review/approve PR +- Merge when CI passes +- /gsd:complete-milestone (if last phase in milestone) +- /gsd:progress (to see what's next) + +─────────────────────────────────────────────────────────────── +``` + + + + + +After shipping: + +- /gsd:complete-milestone — if all phases in milestone are done +- /gsd:progress — see overall project state +- /gsd:execute-phase {next} — continue to next phase + + + +- [ ] Preflight checks passed (verification, clean tree, branch, remote, gh) +- [ ] Branch pushed to remote +- [ ] PR created with rich auto-generated body +- [ ] STATE.md updated with shipping status +- [ ] User knows PR number and next steps + diff --git a/get-shit-done/workflows/transition.md b/get-shit-done/workflows/transition.md index 5e8927dfd..d3073cb6a 100644 --- a/get-shit-done/workflows/transition.md +++ b/get-shit-done/workflows/transition.md @@ -1,3 +1,19 @@ + + +**This is an INTERNAL workflow — NOT a user-facing command.** + +There is no `/gsd:transition` command. This workflow is invoked automatically by +`execute-phase` during auto-advance, or inline by the orchestrator after phase +verification. Users should never be told to run `/gsd:transition`. + +**Valid user commands for phase progression:** +- `/gsd:discuss-phase {N}` — discuss a phase before planning +- `/gsd:plan-phase {N}` — plan a phase +- `/gsd:execute-phase {N}` — execute a phase +- `/gsd:progress` — see roadmap progress + + + **Read these files NOW:** diff --git a/get-shit-done/workflows/update.md b/get-shit-done/workflows/update.md index 7d276eae4..fa910c927 100644 --- a/get-shit-done/workflows/update.md +++ b/get-shit-done/workflows/update.md @@ -20,8 +20,11 @@ First, derive `PREFERRED_RUNTIME` from the invoking prompt's `execution_context` Use `PREFERRED_RUNTIME` as the first runtime checked so `/gsd:update` targets the runtime that invoked it. ```bash -# Runtime candidates: ":" -RUNTIME_DIRS="claude:.claude opencode:.config/opencode opencode:.opencode gemini:.gemini codex:.codex" +# Runtime candidates: ":" stored as an array. +# Using an array instead of a space-separated string ensures correct +# iteration in both bash and zsh (zsh does not word-split unquoted +# variables by default). Fixes #1173. +RUNTIME_DIRS=( "claude:.claude" "opencode:.config/opencode" "opencode:.opencode" "gemini:.gemini" "codex:.codex" ) # PREFERRED_RUNTIME should be set from execution_context before running this block. # If not set, infer from runtime env vars; fallback to claude. @@ -40,23 +43,23 @@ if [ -z "$PREFERRED_RUNTIME" ]; then fi # Reorder entries so preferred runtime is checked first. -ORDERED_RUNTIME_DIRS="" -for entry in $RUNTIME_DIRS; do +ORDERED_RUNTIME_DIRS=() +for entry in "${RUNTIME_DIRS[@]}"; do runtime="${entry%%:*}" if [ "$runtime" = "$PREFERRED_RUNTIME" ]; then - ORDERED_RUNTIME_DIRS="$ORDERED_RUNTIME_DIRS $entry" + ORDERED_RUNTIME_DIRS+=( "$entry" ) fi done -for entry in $RUNTIME_DIRS; do +for entry in "${RUNTIME_DIRS[@]}"; do runtime="${entry%%:*}" if [ "$runtime" != "$PREFERRED_RUNTIME" ]; then - ORDERED_RUNTIME_DIRS="$ORDERED_RUNTIME_DIRS $entry" + ORDERED_RUNTIME_DIRS+=( "$entry" ) fi done # Check local first (takes priority only if valid and distinct from global) LOCAL_VERSION_FILE="" LOCAL_MARKER_FILE="" LOCAL_DIR="" LOCAL_RUNTIME="" -for entry in $ORDERED_RUNTIME_DIRS; do +for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do runtime="${entry%%:*}" dir="${entry#*:}" if [ -f "./$dir/get-shit-done/VERSION" ] || [ -f "./$dir/get-shit-done/workflows/update.md" ]; then @@ -69,7 +72,7 @@ for entry in $ORDERED_RUNTIME_DIRS; do done GLOBAL_VERSION_FILE="" GLOBAL_MARKER_FILE="" GLOBAL_DIR="" GLOBAL_RUNTIME="" -for entry in $ORDERED_RUNTIME_DIRS; do +for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do runtime="${entry%%:*}" dir="${entry#*:}" if [ -f "$HOME/$dir/get-shit-done/VERSION" ] || [ -f "$HOME/$dir/get-shit-done/workflows/update.md" ]; then diff --git a/hooks/gsd-check-update.js b/hooks/gsd-check-update.js index b9a6075ed..510302fb3 100755 --- a/hooks/gsd-check-update.js +++ b/hooks/gsd-check-update.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} // Check for GSD updates in background, write result to cache // Called by SessionStart hook - runs once per session @@ -43,6 +44,7 @@ if (!fs.existsSync(cacheDir)) { // Run check in background (spawn background process, windowsHide prevents console flash) const child = spawn(process.execPath, ['-e', ` const fs = require('fs'); + const path = require('path'); const { execSync } = require('child_process'); const cacheFile = ${JSON.stringify(cacheFile)}; @@ -51,14 +53,43 @@ const child = spawn(process.execPath, ['-e', ` // Check project directory first (local install), then global let installed = '0.0.0'; + let configDir = ''; try { if (fs.existsSync(projectVersionFile)) { installed = fs.readFileSync(projectVersionFile, 'utf8').trim(); + configDir = path.dirname(path.dirname(projectVersionFile)); } else if (fs.existsSync(globalVersionFile)) { installed = fs.readFileSync(globalVersionFile, 'utf8').trim(); + configDir = path.dirname(path.dirname(globalVersionFile)); } } catch (e) {} + // Check for stale hooks — compare hook version headers against installed VERSION + let staleHooks = []; + if (configDir) { + const hooksDir = path.join(configDir, 'hooks'); + try { + if (fs.existsSync(hooksDir)) { + const hookFiles = fs.readdirSync(hooksDir).filter(f => f.endsWith('.js')); + for (const hookFile of hookFiles) { + try { + const content = fs.readFileSync(path.join(hooksDir, hookFile), 'utf8'); + const versionMatch = content.match(/\\/\\/ gsd-hook-version:\\s*(.+)/); + if (versionMatch) { + const hookVersion = versionMatch[1].trim(); + if (hookVersion !== installed && !hookVersion.includes('{{')) { + staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed }); + } + } else { + // No version header at all — definitely stale (pre-version-tracking) + staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed }); + } + } catch (e) {} + } + } + } catch (e) {} + } + let latest = null; try { latest = execSync('npm view get-shit-done-cc version', { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim(); @@ -68,7 +99,8 @@ const child = spawn(process.execPath, ['-e', ` update_available: latest && installed !== latest, installed, latest: latest || 'unknown', - checked: Math.floor(Date.now() / 1000) + checked: Math.floor(Date.now() / 1000), + stale_hooks: staleHooks.length > 0 ? staleHooks : undefined }; fs.writeFileSync(cacheFile, JSON.stringify(result)); diff --git a/hooks/gsd-context-monitor.js b/hooks/gsd-context-monitor.js index d7a5eff06..ae1bbf9a3 100644 --- a/hooks/gsd-context-monitor.js +++ b/hooks/gsd-context-monitor.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} // Context Monitor - PostToolUse/AfterTool hook (Gemini uses AfterTool) // Reads context metrics from the statusline bridge file and injects // warnings when context usage is high. This makes the AGENT aware of @@ -27,10 +28,11 @@ const STALE_SECONDS = 60; // ignore metrics older than 60s const DEBOUNCE_CALLS = 5; // min tool uses between warnings let input = ''; -// Timeout guard: if stdin doesn't close within 3s (e.g. pipe issues on -// Windows/Git Bash), exit silently instead of hanging until Claude Code -// kills the process and reports "hook error". See #775. -const stdinTimeout = setTimeout(() => process.exit(0), 3000); +// Timeout guard: if stdin doesn't close within 10s (e.g. pipe issues on +// Windows/Git Bash, or slow Claude Code piping during large outputs), +// exit silently instead of hanging until Claude Code kills the process +// and reports "hook error". See #775, #1162. +const stdinTimeout = setTimeout(() => process.exit(0), 10000); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index d88ca4a2c..ae7025b99 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} // Claude Code Statusline - GSD Edition // Shows: model | current task | directory | context usage @@ -99,6 +100,9 @@ process.stdin.on('end', () => { if (cache.update_available) { gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; } + if (cache.stale_hooks && cache.stale_hooks.length > 0) { + gsdUpdate += '\x1b[31m⚠ stale hooks — run /gsd:update\x1b[0m │ '; + } } catch (e) {} } diff --git a/hooks/gsd-workflow-guard.js b/hooks/gsd-workflow-guard.js new file mode 100644 index 000000000..d8075aaf6 --- /dev/null +++ b/hooks/gsd-workflow-guard.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// GSD Workflow Guard — PreToolUse hook +// Detects when Claude attempts file edits outside a GSD workflow context +// (no active /gsd: command or Task subagent) and injects an advisory warning. +// +// This is a SOFT guard — it advises, not blocks. The edit still proceeds. +// The warning nudges Claude to use /gsd:quick or /gsd:fast instead of +// making direct edits that bypass state tracking. +// +// Enable via config: hooks.workflow_guard: true (default: false) +// Only triggers on Write/Edit tool calls to non-.planning/ files. + +const fs = require('fs'); +const path = require('path'); + +let input = ''; +const stdinTimeout = setTimeout(() => process.exit(0), 3000); +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => input += chunk); +process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + try { + const data = JSON.parse(input); + const toolName = data.tool_name; + + // Only guard Write and Edit tool calls + if (toolName !== 'Write' && toolName !== 'Edit') { + process.exit(0); + } + + // Check if we're inside a GSD workflow (Task subagent or /gsd: command) + // Subagents have a session_id that differs from the parent + // and typically have a description field set by the orchestrator + if (data.tool_input?.is_subagent || data.session_type === 'task') { + process.exit(0); + } + + // Check the file being edited + const filePath = data.tool_input?.file_path || data.tool_input?.path || ''; + + // Allow edits to .planning/ files (GSD state management) + if (filePath.includes('.planning/') || filePath.includes('.planning\\')) { + process.exit(0); + } + + // Allow edits to common config/docs files that don't need GSD tracking + const allowedPatterns = [ + /\.gitignore$/, + /\.env/, + /CLAUDE\.md$/, + /AGENTS\.md$/, + /GEMINI\.md$/, + /settings\.json$/, + ]; + if (allowedPatterns.some(p => p.test(filePath))) { + process.exit(0); + } + + // Check if workflow guard is enabled + const cwd = data.cwd || process.cwd(); + const configPath = path.join(cwd, '.planning', 'config.json'); + if (fs.existsSync(configPath)) { + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (!config.hooks?.workflow_guard) { + process.exit(0); // Guard disabled (default) + } + } catch (e) { + process.exit(0); + } + } else { + process.exit(0); // No GSD project — don't guard + } + + // If we get here: GSD project, guard enabled, file edit outside .planning/, + // not in a subagent context. Inject advisory warning. + const output = { + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: `⚠️ WORKFLOW ADVISORY: You're editing ${path.basename(filePath)} directly without a GSD command. ` + + 'This edit will not be tracked in STATE.md or produce a SUMMARY.md. ' + + 'Consider using /gsd:fast for trivial fixes or /gsd:quick for larger changes ' + + 'to maintain project state tracking. ' + + 'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.' + } + }; + + process.stdout.write(JSON.stringify(output)); + } catch (e) { + // Silent fail — never block tool execution + process.exit(0); + } +}); diff --git a/package-lock.json b/package-lock.json index 3ffa43783..2e1af42c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "get-shit-done-cc", - "version": "1.25.1", + "version": "1.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "get-shit-done-cc", - "version": "1.25.1", + "version": "1.26.0", "license": "MIT", "bin": { "get-shit-done-cc": "bin/install.js" diff --git a/package.json b/package.json index a03f6c366..5a11cfb12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "get-shit-done-cc", - "version": "1.25.1", + "version": "1.26.0", "description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini and Codex by TÂCHES.", "bin": { "get-shit-done-cc": "bin/install.js" diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index ffb60b0ff..b1b8fa416 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -1,10 +1,14 @@ #!/usr/bin/env node /** * Copy GSD hooks to dist for installation. + * Validates JavaScript syntax before copying to prevent shipping broken hooks. + * See #1107, #1109, #1125, #1161 — a duplicate const declaration shipped + * in dist and caused PostToolUse hook errors for all users. */ const fs = require('fs'); const path = require('path'); +const vm = require('vm'); const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); const DIST_DIR = path.join(HOOKS_DIR, 'dist'); @@ -13,16 +17,38 @@ const DIST_DIR = path.join(HOOKS_DIR, 'dist'); const HOOKS_TO_COPY = [ 'gsd-check-update.js', 'gsd-context-monitor.js', - 'gsd-statusline.js' + 'gsd-statusline.js', + 'gsd-workflow-guard.js' ]; +/** + * Validate JavaScript syntax without executing the file. + * Catches SyntaxError (duplicate const, missing brackets, etc.) + * before the hook gets shipped to users. + */ +function validateSyntax(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + try { + // Use vm.compileFunction to check syntax without executing + new vm.Script(content, { filename: path.basename(filePath) }); + return null; // No error + } catch (e) { + if (e instanceof SyntaxError) { + return e.message; + } + throw e; + } +} + function build() { // Ensure dist directory exists if (!fs.existsSync(DIST_DIR)) { fs.mkdirSync(DIST_DIR, { recursive: true }); } - // Copy hooks to dist + let hasErrors = false; + + // Copy hooks to dist with syntax validation for (const hook of HOOKS_TO_COPY) { const src = path.join(HOOKS_DIR, hook); const dest = path.join(DIST_DIR, hook); @@ -32,9 +58,21 @@ function build() { continue; } - console.log(`Copying ${hook}...`); + // Validate syntax before copying + const syntaxError = validateSyntax(src); + if (syntaxError) { + console.error(`\x1b[31m✗ ${hook}: SyntaxError — ${syntaxError}\x1b[0m`); + hasErrors = true; + continue; + } + + console.log(`\x1b[32m✓\x1b[0m Copying ${hook}...`); fs.copyFileSync(src, dest); - console.log(` → ${dest}`); + } + + if (hasErrors) { + console.error('\n\x1b[31mBuild failed: fix syntax errors above before publishing.\x1b[0m'); + process.exit(1); } console.log('\nBuild complete.'); diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index b43d2827a..f84624e47 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -567,6 +567,98 @@ describe('todo complete command', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// todo match-phase command +// ───────────────────────────────────────────────────────────────────────────── + +describe('todo match-phase command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + afterEach(() => cleanup(tmpDir)); + + test('returns empty matches when no todos exist', () => { + const result = runGsdTools('todo match-phase 01', tmpDir); + assert.ok(result.success, 'should succeed'); + const output = JSON.parse(result.output); + assert.strictEqual(output.todo_count, 0); + assert.deepStrictEqual(output.matches, []); + }); + + test('matches todo by keyword overlap with phase name', () => { + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'), + 'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nNeed to handle token expiry for OAuth flows.'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n'); + + const result = runGsdTools('todo match-phase 01', tmpDir); + assert.ok(result.success, 'should succeed'); + const output = JSON.parse(result.output); + assert.strictEqual(output.todo_count, 1, 'should find 1 todo'); + assert.ok(output.matches.length > 0, 'should have matches'); + assert.strictEqual(output.matches[0].title, 'Add OAuth token refresh'); + assert.ok(output.matches[0].score > 0, 'score should be positive'); + assert.ok(output.matches[0].reasons.length > 0, 'should have reasons'); + }); + + test('does not match unrelated todo', () => { + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'), + 'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token expiry.'); + fs.writeFileSync(path.join(pendingDir, 'unrelated-todo.md'), + 'title: Fix CSS grid layout in dashboard\narea: ui\ncreated: 2026-03-01\n\nGrid columns break on mobile.'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n'); + + const result = runGsdTools('todo match-phase 01', tmpDir); + assert.ok(result.success, 'should succeed'); + const output = JSON.parse(result.output); + const matchTitles = output.matches.map(m => m.title); + assert.ok(matchTitles.includes('Add OAuth token refresh'), 'auth todo should match'); + assert.ok(!matchTitles.includes('Fix CSS grid layout in dashboard'), 'unrelated todo should not match'); + }); + + test('matches todo by area overlap', () => { + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'), + 'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token handling.'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 01: Auth System\n\n**Goal:** Build auth module\n'); + + const result = runGsdTools('todo match-phase 01', tmpDir); + const output = JSON.parse(result.output); + const authMatch = output.matches.find(m => m.title === 'Add OAuth token refresh'); + assert.ok(authMatch, 'should find auth todo'); + const hasAreaReason = authMatch.reasons.some(r => r.startsWith('area:')); + assert.ok(hasAreaReason, 'should match on area'); + }); + + test('sorts matches by score descending', () => { + const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync(path.join(pendingDir, 'weak-match.md'), + 'title: Check token format\narea: general\ncreated: 2026-03-01\n\nToken format validation.'); + fs.writeFileSync(path.join(pendingDir, 'strong-match.md'), + 'title: Session management authentication OAuth token handling\narea: auth\ncreated: 2026-03-01\n\nSession auth OAuth tokens.'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login, session handling, and token management\n'); + + const result = runGsdTools('todo match-phase 01', tmpDir); + const output = JSON.parse(result.output); + assert.ok(output.matches.length >= 2, 'should have multiple matches'); + for (let i = 1; i < output.matches.length; i++) { + assert.ok(output.matches[i - 1].score >= output.matches[i].score, + `match ${i-1} score (${output.matches[i-1].score}) should be >= match ${i} score (${output.matches[i].score})`); + } + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // scaffold command // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 158ea974e..b6510d0e7 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -625,7 +625,7 @@ describe('copyCommandsAsCopilotSkills', () => { // Count gsd-* directories — should be 31 const dirs = fs.readdirSync(tempDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual(dirs.length, 39, `expected 39 skill folders, got ${dirs.length}`); + assert.strictEqual(dirs.length, 43, `expected 43 skill folders, got ${dirs.length}`); } finally { fs.rmSync(tempDir, { recursive: true }); } @@ -1119,7 +1119,7 @@ const { execFileSync } = require('child_process'); const crypto = require('crypto'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); -const EXPECTED_SKILLS = 39; +const EXPECTED_SKILLS = 43; const EXPECTED_AGENTS = 16; function runCopilotInstall(cwd) { diff --git a/tests/core.test.cjs b/tests/core.test.cjs index b20328f32..2f9d796f0 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -10,6 +10,7 @@ const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); const os = require('os'); +const { createTempProject, cleanup } = require('./helpers.cjs'); const { loadConfig, @@ -35,14 +36,13 @@ describe('loadConfig', () => { let originalCwd; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + tmpDir = createTempProject(); originalCwd = process.cwd(); }); afterEach(() => { process.chdir(originalCwd); - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); function writeConfig(obj) { @@ -129,12 +129,11 @@ describe('resolveModelInternal', () => { let tmpDir; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + tmpDir = createTempProject(); }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); function writeConfig(obj) { @@ -276,62 +275,11 @@ describe('generateSlugInternal', () => { }); }); -// ─── normalizePhaseName ──────────────────────────────────────────────────────── - -describe('normalizePhaseName', () => { - test('pads single digit', () => { - assert.strictEqual(normalizePhaseName('1'), '01'); - }); - - test('preserves double digit', () => { - assert.strictEqual(normalizePhaseName('12'), '12'); - }); - - test('handles letter suffix', () => { - assert.strictEqual(normalizePhaseName('1A'), '01A'); - }); - - test('handles decimal phases', () => { - assert.strictEqual(normalizePhaseName('2.1'), '02.1'); - }); - - test('handles multi-level decimals', () => { - assert.strictEqual(normalizePhaseName('1.2.3'), '01.2.3'); - }); - - test('returns non-matching input unchanged', () => { - assert.strictEqual(normalizePhaseName('abc'), 'abc'); - }); -}); - -// ─── comparePhaseNum ─────────────────────────────────────────────────────────── - -describe('comparePhaseNum', () => { - test('sorts integer phases numerically', () => { - assert.ok(comparePhaseNum('1', '2') < 0); - assert.ok(comparePhaseNum('10', '2') > 0); - }); - - test('sorts letter suffixes', () => { - assert.ok(comparePhaseNum('12', '12A') < 0); - assert.ok(comparePhaseNum('12A', '12B') < 0); - }); - - test('sorts decimal phases', () => { - assert.ok(comparePhaseNum('2', '2.1') < 0); - assert.ok(comparePhaseNum('2.1', '2.2') < 0); - }); - - test('handles multi-level decimals', () => { - assert.ok(comparePhaseNum('1.1', '1.1.2') < 0); - assert.ok(comparePhaseNum('1.1.2', '1.2') < 0); - }); - - test('returns 0 for equal phases', () => { - assert.strictEqual(comparePhaseNum('1', '1'), 0); - assert.strictEqual(comparePhaseNum('2.1', '2.1'), 0); - }); -}); +// ─── normalizePhaseName / comparePhaseNum ────────────────────────────────────── +// NOTE: Comprehensive tests for normalizePhaseName and comparePhaseNum are in +// phase.test.cjs (which covers all edge cases: hybrid, letter-suffix, +// multi-level decimal, case-insensitive, directory-slug, and full sort order). +// Removed duplicates here to keep a single authoritative test location. // ─── safeReadFile ────────────────────────────────────────────────────────────── @@ -343,7 +291,7 @@ describe('safeReadFile', () => { }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); test('reads existing file', () => { @@ -363,12 +311,11 @@ describe('pathExistsInternal', () => { let tmpDir; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + tmpDir = createTempProject(); }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); test('returns true for existing path', () => { @@ -390,12 +337,11 @@ describe('getMilestoneInfo', () => { let tmpDir; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + tmpDir = createTempProject(); }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); test('extracts version and name from roadmap', () => { @@ -502,7 +448,7 @@ describe('searchPhaseInDir', () => { }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); test('finds phase directory by normalized prefix', () => { @@ -564,12 +510,11 @@ describe('findPhaseInternal', () => { let tmpDir; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + tmpDir = createTempProject(); }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); test('finds phase in current phases directory', () => { @@ -605,12 +550,11 @@ describe('getRoadmapPhaseInternal', () => { let tmpDir; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + tmpDir = createTempProject(); }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); // Bug: getRoadmapPhaseInternal was missing from module.exports @@ -687,12 +631,11 @@ describe('getMilestonePhaseFilter', () => { let tmpDir; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-')); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + tmpDir = createTempProject(); }); afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanup(tmpDir); }); test('filters directories to only current milestone phases', () => { diff --git a/tests/cursor-conversion.test.cjs b/tests/cursor-conversion.test.cjs new file mode 100644 index 000000000..2c7c79d52 --- /dev/null +++ b/tests/cursor-conversion.test.cjs @@ -0,0 +1,81 @@ +/** + * Cursor conversion regression tests. + * + * Ensures Cursor frontmatter names are emitted as plain identifiers + * (without surrounding quotes), so Cursor 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 { + convertClaudeCommandToCursorSkill, + convertClaudeAgentToCursorAgent, +} = require('../bin/install.js'); + +describe('convertClaudeCommandToCursorSkill', () => { + test('writes unquoted Cursor skill name in frontmatter', () => { + const input = `--- +name: quick +description: Execute a quick task +--- + + +Test body + +`; + + const result = convertClaudeCommandToCursorSkill(input, 'gsd-quick'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + + assert.ok(nameMatch, 'frontmatter contains name field'); + assert.equal(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 = convertClaudeCommandToCursorSkill(input, 'gsd-plan-phase'); + + assert.ok(result.includes('/gsd-execute-phase 17'), 'slash command remains slash-prefixed'); + assert.ok(result.includes('/gsd-help'), 'existing slash command is preserved'); + assert.ok(result.includes('gsd-progress'), 'non-slash gsd: references still normalize'); + assert.ok(!result.includes('/gsd:execute-phase'), 'legacy colon command form is removed'); + }); +}); + +describe('convertClaudeAgentToCursorAgent', () => { + test('writes unquoted Cursor agent name in frontmatter', () => { + const input = `--- +name: gsd-planner +description: Planner agent +tools: Read, Write +color: green +--- + + +Planner body + +`; + + const result = convertClaudeAgentToCursorAgent(input); + const nameMatch = result.match(/^name:\s*(.+)$/m); + + assert.ok(nameMatch, 'frontmatter contains name field'); + assert.equal(nameMatch[1], 'gsd-planner', 'agent name is plain scalar'); + assert.ok(!result.includes('name: "gsd-planner"'), 'quoted agent name is not emitted'); + }); +}); diff --git a/tests/gemini-config.test.cjs b/tests/gemini-config.test.cjs deleted file mode 100644 index 794208427..000000000 --- a/tests/gemini-config.test.cjs +++ /dev/null @@ -1,47 +0,0 @@ -/** - * GSD Tools Tests - Gemini agent conversion - * - * Verifies Gemini-specific agent frontmatter conversion removes - * unsupported fields while preserving converted tools and body text. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert'); - -const { convertClaudeToGeminiAgent } = require('../bin/install.js'); - -describe('convertClaudeToGeminiAgent', () => { - test('drops unsupported skills frontmatter while keeping converted tools', () => { - const input = `--- -name: gsd-codebase-mapper -description: Explores codebase and writes structured analysis documents. -tools: Read, Bash, Grep, Glob, Write -color: cyan -skills: - - gsd-mapper-workflow ---- - - -Use \${PHASE} in shell examples. -`; - - const result = convertClaudeToGeminiAgent(input); - const frontmatter = result.split('---')[1] || ''; - - assert.ok(frontmatter.includes('name: gsd-codebase-mapper'), 'keeps name'); - assert.ok(frontmatter.includes('description: Explores codebase and writes structured analysis documents.'), 'keeps description'); - assert.ok(frontmatter.includes('tools:'), 'adds Gemini tools array'); - assert.ok(frontmatter.includes(' - read_file'), 'maps Read -> read_file'); - assert.ok(frontmatter.includes(' - run_shell_command'), 'maps Bash -> run_shell_command'); - assert.ok(frontmatter.includes(' - search_file_content'), 'maps Grep -> search_file_content'); - assert.ok(frontmatter.includes(' - glob'), 'maps Glob -> glob'); - assert.ok(frontmatter.includes(' - write_file'), 'maps Write -> write_file'); - assert.ok(!frontmatter.includes('color:'), 'drops unsupported color field'); - assert.ok(!frontmatter.includes('skills:'), 'drops unsupported skills field'); - assert.ok(!frontmatter.includes('gsd-mapper-workflow'), 'drops skills list items'); - assert.ok(result.includes('$PHASE'), 'escapes ${PHASE} shell variable for Gemini'); - assert.ok(!result.includes('${PHASE}'), 'removes Gemini template-string pattern'); - }); -}); diff --git a/tests/model-profiles.test.cjs b/tests/model-profiles.test.cjs new file mode 100644 index 000000000..55fd1cf00 --- /dev/null +++ b/tests/model-profiles.test.cjs @@ -0,0 +1,134 @@ +/** + * Model Profiles Tests + * + * Tests for MODEL_PROFILES data structure, VALID_PROFILES list, + * formatAgentToModelMapAsTable, and getAgentToModelMapForProfile. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +const { + MODEL_PROFILES, + VALID_PROFILES, + formatAgentToModelMapAsTable, + getAgentToModelMapForProfile, +} = require('../get-shit-done/bin/lib/model-profiles.cjs'); + +// ─── MODEL_PROFILES data integrity ──────────────────────────────────────────── + +describe('MODEL_PROFILES', () => { + test('contains all expected GSD agents', () => { + const expectedAgents = [ + 'gsd-planner', 'gsd-roadmapper', 'gsd-executor', + 'gsd-phase-researcher', 'gsd-project-researcher', 'gsd-research-synthesizer', + 'gsd-debugger', 'gsd-codebase-mapper', 'gsd-verifier', + 'gsd-plan-checker', 'gsd-integration-checker', 'gsd-nyquist-auditor', + 'gsd-ui-researcher', 'gsd-ui-checker', 'gsd-ui-auditor', + ]; + for (const agent of expectedAgents) { + assert.ok(MODEL_PROFILES[agent], `Missing agent: ${agent}`); + } + }); + + test('every agent has quality, balanced, and budget profiles', () => { + for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) { + assert.ok(profiles.quality, `${agent} missing quality profile`); + assert.ok(profiles.balanced, `${agent} missing balanced profile`); + assert.ok(profiles.budget, `${agent} missing budget profile`); + } + }); + + test('all profile values are valid model aliases', () => { + const validModels = ['opus', 'sonnet', 'haiku']; + for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) { + for (const [profile, model] of Object.entries(profiles)) { + assert.ok( + validModels.includes(model), + `${agent}.${profile} has invalid model "${model}" — expected one of ${validModels.join(', ')}` + ); + } + } + }); + + test('quality profile never uses haiku', () => { + for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) { + assert.notStrictEqual( + profiles.quality, 'haiku', + `${agent} quality profile should not use haiku` + ); + } + }); +}); + +// ─── VALID_PROFILES ─────────────────────────────────────────────────────────── + +describe('VALID_PROFILES', () => { + test('contains quality, balanced, and budget', () => { + assert.deepStrictEqual(VALID_PROFILES.sort(), ['balanced', 'budget', 'quality']); + }); + + test('is derived from MODEL_PROFILES keys', () => { + const fromData = Object.keys(MODEL_PROFILES['gsd-planner']); + assert.deepStrictEqual(VALID_PROFILES.sort(), fromData.sort()); + }); +}); + +// ─── getAgentToModelMapForProfile ───────────────────────────────────────────── + +describe('getAgentToModelMapForProfile', () => { + test('returns correct models for balanced profile', () => { + const map = getAgentToModelMapForProfile('balanced'); + assert.strictEqual(map['gsd-planner'], 'opus'); + assert.strictEqual(map['gsd-codebase-mapper'], 'haiku'); + assert.strictEqual(map['gsd-verifier'], 'sonnet'); + }); + + test('returns correct models for budget profile', () => { + const map = getAgentToModelMapForProfile('budget'); + assert.strictEqual(map['gsd-planner'], 'sonnet'); + assert.strictEqual(map['gsd-phase-researcher'], 'haiku'); + }); + + test('returns correct models for quality profile', () => { + const map = getAgentToModelMapForProfile('quality'); + assert.strictEqual(map['gsd-planner'], 'opus'); + assert.strictEqual(map['gsd-executor'], 'opus'); + }); + + test('returns all agents in the map', () => { + const map = getAgentToModelMapForProfile('balanced'); + const agentCount = Object.keys(MODEL_PROFILES).length; + assert.strictEqual(Object.keys(map).length, agentCount); + }); +}); + +// ─── formatAgentToModelMapAsTable ───────────────────────────────────────────── + +describe('formatAgentToModelMapAsTable', () => { + test('produces a table with header and separator', () => { + const map = { 'gsd-planner': 'opus', 'gsd-executor': 'sonnet' }; + const table = formatAgentToModelMapAsTable(map); + assert.ok(table.includes('Agent'), 'should have Agent header'); + assert.ok(table.includes('Model'), 'should have Model header'); + assert.ok(table.includes('─'), 'should have separator line'); + assert.ok(table.includes('gsd-planner'), 'should list agent'); + assert.ok(table.includes('opus'), 'should list model'); + }); + + test('pads columns correctly', () => { + const map = { 'a': 'opus', 'very-long-agent-name': 'haiku' }; + const table = formatAgentToModelMapAsTable(map); + const lines = table.split('\n').filter(l => l.trim()); + // Separator line uses ┼, data/header lines use │ + const dataLines = lines.filter(l => l.includes('│')); + const pipePositions = dataLines.map(l => l.indexOf('│')); + const unique = [...new Set(pipePositions)]; + assert.strictEqual(unique.length, 1, 'all data lines should align on │'); + }); + + test('handles empty map', () => { + const table = formatAgentToModelMapAsTable({}); + assert.ok(table.includes('Agent'), 'should still have header'); + }); +}); diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs new file mode 100644 index 000000000..3001195f5 --- /dev/null +++ b/tests/profile-output.test.cjs @@ -0,0 +1,197 @@ +/** + * Profile Output Tests + * + * Tests for profile rendering commands and PROFILING_QUESTIONS data. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs'); + +const { + PROFILING_QUESTIONS, + CLAUDE_INSTRUCTIONS, +} = require('../get-shit-done/bin/lib/profile-output.cjs'); + +// ─── PROFILING_QUESTIONS data ───────────────────────────────────────────────── + +describe('PROFILING_QUESTIONS', () => { + test('is a non-empty array', () => { + assert.ok(Array.isArray(PROFILING_QUESTIONS)); + assert.ok(PROFILING_QUESTIONS.length > 0); + }); + + test('each question has required fields', () => { + for (const q of PROFILING_QUESTIONS) { + assert.ok(q.dimension, `question missing dimension`); + assert.ok(q.header, `${q.dimension} missing header`); + assert.ok(q.question, `${q.dimension} missing question`); + assert.ok(Array.isArray(q.options), `${q.dimension} options should be array`); + assert.ok(q.options.length >= 2, `${q.dimension} should have at least 2 options`); + } + }); + + test('each option has label, value, and rating', () => { + for (const q of PROFILING_QUESTIONS) { + for (const opt of q.options) { + assert.ok(opt.label, `${q.dimension} option missing label`); + assert.ok(opt.value, `${q.dimension} option missing value`); + assert.ok(opt.rating, `${q.dimension} option missing rating`); + } + } + }); + + test('all dimension keys are unique', () => { + const dims = PROFILING_QUESTIONS.map(q => q.dimension); + const unique = [...new Set(dims)]; + assert.strictEqual(dims.length, unique.length); + }); +}); + +// ─── CLAUDE_INSTRUCTIONS ────────────────────────────────────────────────────── + +describe('CLAUDE_INSTRUCTIONS', () => { + test('is a non-empty object', () => { + assert.ok(typeof CLAUDE_INSTRUCTIONS === 'object'); + assert.ok(Object.keys(CLAUDE_INSTRUCTIONS).length > 0); + }); + + test('each dimension has at least one instruction', () => { + for (const [dim, instructions] of Object.entries(CLAUDE_INSTRUCTIONS)) { + assert.ok(typeof instructions === 'object', `${dim} should be an object`); + assert.ok(Object.keys(instructions).length > 0, `${dim} should have instructions`); + } + }); + + test('every PROFILING_QUESTIONS dimension has CLAUDE_INSTRUCTIONS', () => { + for (const q of PROFILING_QUESTIONS) { + assert.ok( + CLAUDE_INSTRUCTIONS[q.dimension], + `${q.dimension} has questions but no CLAUDE_INSTRUCTIONS` + ); + } + }); +}); + +// ─── write-profile command ──────────────────────────────────────────────────── + +describe('write-profile command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('writes USER-PROFILE.md from analysis JSON', () => { + const analysis = { + profile_version: '1.0', + dimensions: { + communication_style: { rating: 'terse-direct', confidence: 'HIGH' }, + decision_speed: { rating: 'fast-intuitive', confidence: 'MEDIUM' }, + explanation_depth: { rating: 'concise', confidence: 'HIGH' }, + debugging_approach: { rating: 'fix-first', confidence: 'LOW' }, + ux_philosophy: { rating: 'function-first', confidence: 'MEDIUM' }, + vendor_philosophy: { rating: 'pragmatic', confidence: 'HIGH' }, + frustration_triggers: { rating: 'over-explanation', confidence: 'LOW' }, + learning_style: { rating: 'hands-on', confidence: 'MEDIUM' }, + }, + }; + + const analysisPath = path.join(tmpDir, 'analysis.json'); + fs.writeFileSync(analysisPath, JSON.stringify(analysis)); + + const result = runGsdTools(['write-profile', '--input', analysisPath, '--raw'], tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.ok(out.profile_path, 'should return profile_path'); + assert.ok(out.dimensions_scored > 0, 'should have scored dimensions'); + }); + + test('errors when --input is missing', () => { + const result = runGsdTools('write-profile --raw', tmpDir); + assert.ok(!result.success, 'should fail without --input'); + assert.ok(result.error.includes('--input'), 'should mention --input'); + }); +}); + +// ─── generate-claude-md command ─────────────────────────────────────────────── + +describe('generate-claude-md command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempGitProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# My Project\n\nA test project.\n\n## Tech Stack\n\n- Node.js\n- TypeScript\n' + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('generates CLAUDE.md with --auto flag', () => { + const outputPath = path.join(tmpDir, 'CLAUDE.md'); + const result = runGsdTools(['generate-claude-md', '--output', outputPath, '--auto', '--raw'], tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + + if (fs.existsSync(outputPath)) { + const content = fs.readFileSync(outputPath, 'utf-8'); + assert.ok(content.length > 0, 'should have content'); + } + }); + + test('does not overwrite existing CLAUDE.md without --force', () => { + const outputPath = path.join(tmpDir, 'CLAUDE.md'); + fs.writeFileSync(outputPath, '# Custom CLAUDE.md\n\nUser content.\n'); + + const result = runGsdTools(['generate-claude-md', '--output', outputPath, '--auto', '--raw'], tmpDir); + // Should merge, not overwrite + const content = fs.readFileSync(outputPath, 'utf-8'); + assert.ok(content.length > 0, 'should still have content'); + }); +}); + +// ─── generate-dev-preferences ───────────────────────────────────────────────── + +describe('generate-dev-preferences command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('errors when --analysis is missing', () => { + const result = runGsdTools('generate-dev-preferences --raw', tmpDir); + assert.ok(!result.success, 'should fail without --analysis'); + assert.ok(result.error.includes('--analysis'), 'should mention --analysis'); + }); + + test('generates preferences from analysis file', () => { + const analysis = { + profile_version: '1.0', + dimensions: { + communication_style: { rating: 'terse-direct', confidence: 'HIGH' }, + decision_speed: { rating: 'fast-intuitive', confidence: 'MEDIUM' }, + }, + }; + const analysisPath = path.join(tmpDir, 'analysis.json'); + fs.writeFileSync(analysisPath, JSON.stringify(analysis)); + + const result = runGsdTools(['generate-dev-preferences', '--analysis', analysisPath, '--raw'], tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.ok(out.command_path || out.command_name, 'should return command output'); + }); +}); diff --git a/tests/profile-pipeline.test.cjs b/tests/profile-pipeline.test.cjs new file mode 100644 index 000000000..e6e783412 --- /dev/null +++ b/tests/profile-pipeline.test.cjs @@ -0,0 +1,160 @@ +/** + * Profile Pipeline Tests + * + * Tests for session scanning, message extraction, and profile sampling. + * Uses synthetic session data in temp directories via --path override. + */ + +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 { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ─── scan-sessions ──────────────────────────────────────────────────────────── + +describe('scan-sessions command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-test-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns empty array for empty sessions directory', () => { + const sessionsDir = path.join(tmpDir, 'projects'); + fs.mkdirSync(sessionsDir, { recursive: true }); + const result = runGsdTools(`scan-sessions --path ${sessionsDir} --raw`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.ok(Array.isArray(out), 'should return an array'); + assert.strictEqual(out.length, 0, 'should be empty'); + }); + + test('scans synthetic project directory', () => { + const sessionsDir = path.join(tmpDir, 'projects'); + const projectDir = path.join(sessionsDir, 'test-project-abc123'); + fs.mkdirSync(projectDir, { recursive: true }); + + // Create a synthetic session file + const sessionData = [ + JSON.stringify({ type: 'user', userType: 'external', message: { content: 'hello' }, timestamp: Date.now() }), + JSON.stringify({ type: 'assistant', message: { content: 'hi' }, timestamp: Date.now() }), + ].join('\n'); + fs.writeFileSync(path.join(projectDir, 'session-001.jsonl'), sessionData); + + const result = runGsdTools(`scan-sessions --path ${sessionsDir} --raw`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.ok(Array.isArray(out), 'should return array'); + assert.strictEqual(out.length, 1, 'should find 1 project'); + assert.strictEqual(out[0].sessionCount, 1, 'should have 1 session'); + }); + + test('reports multiple sessions and sizes', () => { + const sessionsDir = path.join(tmpDir, 'projects'); + const projectDir = path.join(sessionsDir, 'multi-session-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + for (let i = 1; i <= 3; i++) { + const data = JSON.stringify({ type: 'user', userType: 'external', message: { content: `msg ${i}` }, timestamp: Date.now() }); + fs.writeFileSync(path.join(projectDir, `session-${i}.jsonl`), data + '\n'); + } + + const result = runGsdTools(`scan-sessions --path ${sessionsDir} --raw`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out[0].sessionCount, 3); + assert.ok(out[0].totalSize > 0, 'should have non-zero size'); + }); +}); + +// ─── extract-messages ───────────────────────────────────────────────────────── + +describe('extract-messages command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-test-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('extracts user messages from synthetic session', () => { + const sessionsDir = path.join(tmpDir, 'projects'); + const projectDir = path.join(sessionsDir, 'my-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const messages = [ + { type: 'user', userType: 'external', message: { content: 'fix the login bug' }, timestamp: Date.now() }, + { type: 'assistant', message: { content: 'I will fix it.' }, timestamp: Date.now() }, + { type: 'user', userType: 'external', message: { content: 'add dark mode' }, timestamp: Date.now() }, + { type: 'user', userType: 'internal', isMeta: true, message: { content: ' JSON.stringify(m)).join('\n') + ); + + const result = runGsdTools(`extract-messages my-project --path ${sessionsDir} --raw`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.messages_extracted, 2, 'should extract 2 genuine user messages'); + assert.strictEqual(out.project, 'my-project'); + assert.ok(out.output_file, 'should have output file path'); + }); + + test('filters out meta and internal messages', () => { + const sessionsDir = path.join(tmpDir, 'projects'); + const projectDir = path.join(sessionsDir, 'filter-test'); + fs.mkdirSync(projectDir, { recursive: true }); + + const messages = [ + { type: 'user', userType: 'external', message: { content: 'real message' }, timestamp: Date.now() }, + { type: 'user', userType: 'internal', message: { content: 'internal msg' }, timestamp: Date.now() }, + { type: 'user', userType: 'external', isMeta: true, message: { content: 'meta msg' }, timestamp: Date.now() }, + { type: 'user', userType: 'external', message: { content: ' JSON.stringify(m)).join('\n') + ); + + const result = runGsdTools(`extract-messages filter-test --path ${sessionsDir} --raw`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.messages_extracted, 2, 'should only extract 2 genuine external messages'); + }); +}); + +// ─── profile-questionnaire ──────────────────────────────────────────────────── + +describe('profile-questionnaire command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns questionnaire structure', () => { + const result = runGsdTools('profile-questionnaire --raw', tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.ok(out.questions, 'should have questions array'); + assert.ok(out.questions.length > 0, 'should have at least one question'); + assert.ok(out.questions[0].dimension, 'each question should have a dimension'); + assert.ok(out.questions[0].options, 'each question should have options'); + }); +}); diff --git a/tests/opencode-agent-conversion.test.cjs b/tests/runtime-converters.test.cjs similarity index 62% rename from tests/opencode-agent-conversion.test.cjs rename to tests/runtime-converters.test.cjs index 6ba62b54b..5da337310 100644 --- a/tests/opencode-agent-conversion.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -1,19 +1,23 @@ /** - * OpenCode Agent Frontmatter Conversion Tests + * Runtime Converter Tests — OpenCode + Gemini * - * Validates that convertClaudeToOpencodeFrontmatter correctly converts - * agent frontmatter for OpenCode compatibility when isAgent: true. + * Tests for small runtime-specific conversion functions from install.js. + * Larger runtime test suites (Copilot, Codex, Antigravity) have their own files. * - * Bug: Without isAgent flag, the function strips name: (agents need it), - * keeps color:/skills:/tools: record (should strip), and doesn't add - * model: inherit / mode: subagent (required by OpenCode agents). + * OpenCode: convertClaudeToOpencodeFrontmatter (agent + command modes) + * model: inherit is NOT added (OpenCode doesn't support it — see #1156) + * but mode: subagent IS added (required by OpenCode agents). + * Gemini: convertClaudeToGeminiAgent (frontmatter + tool mapping + body escaping) */ const { test, describe } = require('node:test'); const assert = require('node:assert'); process.env.GSD_TEST_MODE = '1'; -const { convertClaudeToOpencodeFrontmatter } = require('../bin/install.js'); +const { + convertClaudeToOpencodeFrontmatter, + convertClaudeToGeminiAgent, +} = require('../bin/install.js'); // Sample Claude agent frontmatter (matches actual GSD agent format) const SAMPLE_AGENT = `--- @@ -54,10 +58,10 @@ describe('OpenCode agent conversion (isAgent: true)', () => { assert.ok(frontmatter.includes('name: gsd-executor'), 'name: should be preserved for agents'); }); - test('adds model: inherit', () => { + test('does not add model: inherit (OpenCode does not support it)', () => { const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); const frontmatter = result.split('---')[1]; - assert.ok(frontmatter.includes('model: inherit'), 'model: inherit should be added'); + assert.ok(!frontmatter.includes('model: inherit'), 'model: inherit should NOT be added — OpenCode throws ProviderModelNotFoundError'); }); test('adds mode: subagent', () => { @@ -141,3 +145,41 @@ describe('OpenCode command conversion (isAgent: false, default)', () => { assert.ok(frontmatter.includes('description:'), 'description should be kept'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Gemini CLI agent conversion (merged from gemini-config.test.cjs) +// ───────────────────────────────────────────────────────────────────────────── + +describe('convertClaudeToGeminiAgent', () => { + test('drops unsupported skills frontmatter while keeping converted tools', () => { + const input = `--- +name: gsd-codebase-mapper +description: Explores codebase and writes structured analysis documents. +tools: Read, Bash, Grep, Glob, Write +color: cyan +skills: + - gsd-mapper-workflow +--- + + +Use \${PHASE} in shell examples. +`; + + const result = convertClaudeToGeminiAgent(input); + const frontmatter = result.split('---')[1] || ''; + + assert.ok(frontmatter.includes('name: gsd-codebase-mapper'), 'keeps name'); + assert.ok(frontmatter.includes('description: Explores codebase and writes structured analysis documents.'), 'keeps description'); + assert.ok(frontmatter.includes('tools:'), 'adds Gemini tools array'); + assert.ok(frontmatter.includes(' - read_file'), 'maps Read -> read_file'); + assert.ok(frontmatter.includes(' - run_shell_command'), 'maps Bash -> run_shell_command'); + assert.ok(frontmatter.includes(' - search_file_content'), 'maps Grep -> search_file_content'); + assert.ok(frontmatter.includes(' - glob'), 'maps Glob -> glob'); + assert.ok(frontmatter.includes(' - write_file'), 'maps Write -> write_file'); + assert.ok(!frontmatter.includes('color:'), 'drops unsupported color field'); + assert.ok(!frontmatter.includes('skills:'), 'drops unsupported skills field'); + assert.ok(!frontmatter.includes('gsd-mapper-workflow'), 'drops skills list items'); + assert.ok(result.includes('$PHASE'), 'escapes ${PHASE} shell variable for Gemini'); + assert.ok(!result.includes('${PHASE}'), 'removes Gemini template-string pattern'); + }); +}); diff --git a/tests/template.test.cjs b/tests/template.test.cjs new file mode 100644 index 000000000..8d2ae3d51 --- /dev/null +++ b/tests/template.test.cjs @@ -0,0 +1,186 @@ +/** + * Template Tests + * + * Tests for cmdTemplateSelect (heuristic template selection) and + * cmdTemplateFill (summary, plan, verification template generation). + */ + +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'); + +// ─── template select ────────────────────────────────────────────────────────── + +describe('template select command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Create a phase directory with a plan + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('selects minimal template for simple plan', () => { + const planPath = path.join(tmpDir, '.planning', 'phases', '01-setup', '01-01-PLAN.md'); + fs.writeFileSync(planPath, [ + '# Plan', + '', + '### Task 1', + 'Do the thing.', + '', + 'File: `src/index.ts`', + ].join('\n')); + + const result = runGsdTools(`template select .planning/phases/01-setup/01-01-PLAN.md`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.type, 'minimal'); + assert.ok(out.template.includes('summary-minimal')); + }); + + test('selects standard template for moderate plan', () => { + const planPath = path.join(tmpDir, '.planning', 'phases', '01-setup', '01-01-PLAN.md'); + fs.writeFileSync(planPath, [ + '# Plan', + '', + '### Task 1', + 'Create `src/auth/login.ts`', + '', + '### Task 2', + 'Create `src/auth/register.ts`', + '', + '### Task 3', + 'Update `src/routes/index.ts`', + '', + 'Files: `src/auth/login.ts`, `src/auth/register.ts`, `src/routes/index.ts`, `src/middleware/auth.ts`', + ].join('\n')); + + const result = runGsdTools(`template select .planning/phases/01-setup/01-01-PLAN.md`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.type, 'standard'); + }); + + test('selects complex template for plan with decisions and many files', () => { + const planPath = path.join(tmpDir, '.planning', 'phases', '01-setup', '01-01-PLAN.md'); + const lines = ['# Plan', '']; + for (let i = 1; i <= 6; i++) { + lines.push(`### Task ${i}`, `Do task ${i}.`, ''); + } + lines.push('Made a decision about architecture.', 'Another decision here.'); + for (let i = 1; i <= 8; i++) { + lines.push(`File: \`src/module${i}/index.ts\``); + } + fs.writeFileSync(planPath, lines.join('\n')); + + const result = runGsdTools(`template select .planning/phases/01-setup/01-01-PLAN.md`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.type, 'complex'); + }); + + test('returns standard as fallback for nonexistent file', () => { + const result = runGsdTools(`template select .planning/phases/01-setup/nonexistent.md`, tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.type, 'standard'); + assert.ok(out.error, 'should include error message'); + }); +}); + +// ─── template fill ──────────────────────────────────────────────────────────── + +describe('template fill command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '## Roadmap\n\n### Phase 1: Setup\n**Goal:** Initial setup\n' + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('fills summary template', () => { + const result = runGsdTools('template fill summary --phase 1', tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.created, true); + assert.ok(out.path.includes('01-01-SUMMARY.md')); + + const content = fs.readFileSync(path.join(tmpDir, out.path), 'utf-8'); + assert.ok(content.includes('---'), 'should have frontmatter'); + assert.ok(content.includes('Phase 1'), 'should reference phase'); + assert.ok(content.includes('Accomplishments'), 'should have accomplishments section'); + }); + + test('fills plan template', () => { + const result = runGsdTools('template fill plan --phase 1', tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.created, true); + assert.ok(out.path.includes('01-01-PLAN.md')); + + const content = fs.readFileSync(path.join(tmpDir, out.path), 'utf-8'); + assert.ok(content.includes('---'), 'should have frontmatter'); + assert.ok(content.includes('Objective'), 'should have objective section'); + assert.ok(content.includes(''), 'should have task XML'); + }); + + test('fills verification template', () => { + const result = runGsdTools('template fill verification --phase 1', tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.created, true); + assert.ok(out.path.includes('01-VERIFICATION.md')); + + const content = fs.readFileSync(path.join(tmpDir, out.path), 'utf-8'); + assert.ok(content.includes('Observable Truths'), 'should have truths section'); + assert.ok(content.includes('Required Artifacts'), 'should have artifacts section'); + }); + + test('rejects existing file', () => { + // Create the file first + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Existing'); + + const result = runGsdTools('template fill summary --phase 1', tmpDir); + assert.ok(result.success); // outputs JSON, doesn't crash + const out = JSON.parse(result.output); + assert.ok(out.error, 'should report error for existing file'); + assert.ok(out.error.includes('already exists')); + }); + + test('errors on unknown template type', () => { + const result = runGsdTools('template fill bogus --phase 1', tmpDir); + assert.ok(!result.success, 'should fail for unknown type'); + assert.ok(result.error.includes('Unknown template type')); + }); + + test('errors when phase not found', () => { + const result = runGsdTools('template fill summary --phase 99', tmpDir); + assert.ok(result.success); + const out = JSON.parse(result.output); + assert.ok(out.error, 'should report phase not found'); + }); + + test('respects --plan option for plan number', () => { + const result = runGsdTools('template fill plan --phase 1 --plan 03', tmpDir); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.ok(out.path.includes('01-03-PLAN.md'), `Expected plan 03 in path, got ${out.path}`); + }); +});