merge: sync Kilo runtime branch with latest main

This commit is contained in:
Alex Alecu
2026-04-03 12:47:24 +03:00
43 changed files with 3868 additions and 407 deletions

View File

@@ -4,7 +4,7 @@
[English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · **日本語** [English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · **日本語**
**Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Antigravity向けの軽量かつ強力なメタプロンプティング、コンテキストエンジニアリング、仕様駆動開発システム。** **Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf、Antigravity、Augment向けの軽量かつ強力なメタプロンプティング、コンテキストエンジニアリング、仕様駆動開発システム。**
**コンテキストロット(Claudeがコンテキストウィンドウを消費するにつれ品質が劣化する現象)を解決します。** **コンテキストロット(Claudeがコンテキストウィンドウを消費するにつれ品質が劣化する現象)を解決します。**

View File

@@ -4,7 +4,7 @@
[English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · **한국어** [English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · **한국어**
**Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Antigravity를 위한 가볍고 강력한 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.** **Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment를 위한 가볍고 강력한 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.**
**컨텍스트 rot를 해결합니다 — Claude의 컨텍스트 창이 채워질수록 품질이 저하되는 문제.** **컨텍스트 rot를 해결합니다 — Claude의 컨텍스트 창이 채워질수록 품질이 저하되는 문제.**

View File

@@ -4,7 +4,7 @@
**English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) **English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md)
**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and Antigravity.** **A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, and Augment.**
**Solves context rot — the quality degradation that happens as Claude fills its context window.** **Solves context rot — the quality degradation that happens as Claude fills its context window.**
@@ -84,16 +84,13 @@ npx get-shit-done-cc@latest
``` ```
The installer prompts you to choose: The installer prompts you to choose:
1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, or all (interactive multi-select — pick multiple runtimes in a single install session) 1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, or all (interactive multi-select — pick multiple runtimes in a single install session)
2. **Location** — Global (all projects) or local (current project only) 2. **Location** — Global (all projects) or local (current project only)
Verify with: Verify with:
- Claude Code / Gemini: `/gsd:help` - Claude Code / Gemini / Copilot / Antigravity: `/gsd:help`
- OpenCode: `/gsd-help` - OpenCode / Kilo / Augment: `/gsd-help`
- Kilo: `/gsd-help`
- Codex: `$gsd-help` - Codex: `$gsd-help`
- Copilot: `/gsd:help`
- Antigravity: `/gsd:help`
> [!NOTE] > [!NOTE]
> Claude Code 2.1.88+ and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. The installer handles this automatically. > Claude Code 2.1.88+ and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. The installer handles this automatically.
@@ -144,12 +141,16 @@ npx get-shit-done-cc --windsurf --local # Install to ./.windsurf/
npx get-shit-done-cc --antigravity --global # Install to ~/.gemini/antigravity/ npx get-shit-done-cc --antigravity --global # Install to ~/.gemini/antigravity/
npx get-shit-done-cc --antigravity --local # Install to ./.agent/ npx get-shit-done-cc --antigravity --local # Install to ./.agent/
# Augment
npx get-shit-done-cc --augment --global # Install to ~/.augment/
npx get-shit-done-cc --augment --local # Install to ./.augment/
# All runtimes # All runtimes
npx get-shit-done-cc --all --global # Install to all directories npx get-shit-done-cc --all --global # Install to all directories
``` ```
Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt. Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt.
Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, or `--all` to skip the runtime prompt. Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, or `--all` to skip the runtime prompt.
Use `--sdk` to also install the GSD SDK CLI (`gsd-sdk`) for headless autonomous execution. Use `--sdk` to also install the GSD SDK CLI (`gsd-sdk`) for headless autonomous execution.
</details> </details>
@@ -806,6 +807,7 @@ npx get-shit-done-cc --copilot --global --uninstall
npx get-shit-done-cc --cursor --global --uninstall npx get-shit-done-cc --cursor --global --uninstall
npx get-shit-done-cc --windsurf --global --uninstall npx get-shit-done-cc --windsurf --global --uninstall
npx get-shit-done-cc --antigravity --global --uninstall npx get-shit-done-cc --antigravity --global --uninstall
npx get-shit-done-cc --augment --global --uninstall
# Local installs (current project) # Local installs (current project)
npx get-shit-done-cc --claude --local --uninstall npx get-shit-done-cc --claude --local --uninstall
@@ -817,6 +819,7 @@ npx get-shit-done-cc --copilot --local --uninstall
npx get-shit-done-cc --cursor --local --uninstall npx get-shit-done-cc --cursor --local --uninstall
npx get-shit-done-cc --windsurf --local --uninstall npx get-shit-done-cc --windsurf --local --uninstall
npx get-shit-done-cc --antigravity --local --uninstall npx get-shit-done-cc --antigravity --local --uninstall
npx get-shit-done-cc --augment --local --uninstall
``` ```
This removes all GSD commands, agents, hooks, and settings while preserving your other configurations. This removes all GSD commands, agents, hooks, and settings while preserving your other configurations.

View File

@@ -4,7 +4,7 @@
[English](README.md) · **Português** · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) [English](README.md) · **Português** · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md)
**Um sistema leve e poderoso de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificação para Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor e Antigravity.** **Um sistema leve e poderoso de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificação para Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity e Augment.**
**Resolve context rot — a degradação de qualidade que acontece conforme o Claude enche a janela de contexto.** **Resolve context rot — a degradação de qualidade que acontece conforme o Claude enche a janela de contexto.**

View File

@@ -68,6 +68,7 @@ const hasCopilot = args.includes('--copilot');
const hasAntigravity = args.includes('--antigravity'); const hasAntigravity = args.includes('--antigravity');
const hasCursor = args.includes('--cursor'); const hasCursor = args.includes('--cursor');
const hasWindsurf = args.includes('--windsurf'); const hasWindsurf = args.includes('--windsurf');
const hasAugment = args.includes('--augment');
const hasSdk = args.includes('--sdk'); const hasSdk = args.includes('--sdk');
const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasBoth = args.includes('--both'); // Legacy flag, keeps working
const hasAll = args.includes('--all'); const hasAll = args.includes('--all');
@@ -76,7 +77,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
// Runtime selection - can be set by flags or interactive prompt // Runtime selection - can be set by flags or interactive prompt
let selectedRuntimes = []; let selectedRuntimes = [];
if (hasAll) { if (hasAll) {
selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf']; selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
} else if (hasBoth) { } else if (hasBoth) {
selectedRuntimes = ['claude', 'opencode']; selectedRuntimes = ['claude', 'opencode'];
} else { } else {
@@ -89,6 +90,7 @@ if (hasAll) {
if (hasAntigravity) selectedRuntimes.push('antigravity'); if (hasAntigravity) selectedRuntimes.push('antigravity');
if (hasCursor) selectedRuntimes.push('cursor'); if (hasCursor) selectedRuntimes.push('cursor');
if (hasWindsurf) selectedRuntimes.push('windsurf'); if (hasWindsurf) selectedRuntimes.push('windsurf');
if (hasAugment) selectedRuntimes.push('augment');
} }
// WSL + Windows Node.js detection // WSL + Windows Node.js detection
@@ -135,6 +137,7 @@ function getDirName(runtime) {
if (runtime === 'antigravity') return '.agent'; if (runtime === 'antigravity') return '.agent';
if (runtime === 'cursor') return '.cursor'; if (runtime === 'cursor') return '.cursor';
if (runtime === 'windsurf') return '.windsurf'; if (runtime === 'windsurf') return '.windsurf';
if (runtime === 'augment') return '.augment';
return '.claude'; return '.claude';
} }
@@ -165,6 +168,7 @@ function getConfigDirFromHome(runtime, isGlobal) {
} }
if (runtime === 'cursor') return "'.cursor'"; if (runtime === 'cursor') return "'.cursor'";
if (runtime === 'windsurf') return "'.windsurf'"; if (runtime === 'windsurf') return "'.windsurf'";
if (runtime === 'augment') return "'.augment'";
return "'.claude'"; return "'.claude'";
} }
@@ -306,6 +310,16 @@ function getGlobalDir(runtime, explicitDir = null) {
return path.join(os.homedir(), '.windsurf'); return path.join(os.homedir(), '.windsurf');
} }
if (runtime === 'augment') {
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.AUGMENT_CONFIG_DIR) {
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
}
return path.join(os.homedir(), '.augment');
}
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
if (explicitDir) { if (explicitDir) {
@@ -327,7 +341,7 @@ const banner = '\n' +
'\n' + '\n' +
' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' + ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' +
' A meta-prompting, context engineering and spec-driven\n' + ' A meta-prompting, context engineering and spec-driven\n' +
' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, and Windsurf by TÂCHES.\n'; ' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, and Augment by TÂCHES.\n';
// Parse --config-dir argument // Parse --config-dir argument
function parseConfigDirArg() { function parseConfigDirArg() {
@@ -365,7 +379,7 @@ if (hasUninstall) {
// Show help if requested // Show help if requested
if (hasHelp) { if (hasHelp) {
console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}--sdk${reset} Also install GSD SDK CLI (gsd-sdk)\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR environment variables.\n`); console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}--sdk${reset} Also install GSD SDK CLI (gsd-sdk)\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR environment variables.\n`);
process.exit(0); process.exit(0);
} }
@@ -1224,6 +1238,189 @@ function convertClaudeAgentToWindsurfAgent(content) {
return `${cleanFrontmatter}\n${body}`; return `${cleanFrontmatter}\n${body}`;
} }
// --- Augment converters ---
// Augment (auggie CLI) uses a tool set similar to Cursor/Windsurf (VS Code-based).
// Config lives in .augment/ (local) and ~/.augment/ (global).
const claudeToAugmentTools = {
Bash: 'launch-process',
Edit: 'str-replace-editor',
AskUserQuestion: null,
SlashCommand: null,
TodoWrite: 'add_tasks',
};
function convertAugmentToolName(claudeTool) {
if (claudeTool in claudeToAugmentTools) {
return claudeToAugmentTools[claudeTool];
}
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
const toolMapping = {
Read: 'view',
Write: 'save-file',
Glob: 'view',
Grep: 'grep',
Task: null,
WebSearch: 'web-search',
WebFetch: 'web-fetch',
};
return toolMapping[claudeTool] || claudeTool;
}
function convertSlashCommandsToAugmentSkillMentions(content) {
return content.replace(/gsd:/gi, 'gsd-');
}
function convertClaudeToAugmentMarkdown(content) {
let converted = convertSlashCommandsToAugmentSkillMentions(content);
converted = converted.replace(/\bBash\(/g, 'launch-process(');
converted = converted.replace(/\bEdit\(/g, 'str-replace-editor(');
converted = converted.replace(/\bRead\(/g, 'view(');
converted = converted.replace(/\bWrite\(/g, 'save-file(');
converted = converted.replace(/\bTodoWrite\(/g, 'add_tasks(');
converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
// Replace subagent_type from Claude to Augment format
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
// Replace project-level Claude conventions with Augment equivalents
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.augment/rules/`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.augment/rules/');
converted = converted.replace(/`CLAUDE\.md`/g, '`.augment/rules/`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.augment/rules/');
converted = converted.replace(/\.claude\/skills\//g, '.augment/skills/');
// Remove Claude Code-specific bug workarounds before brand replacement
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
// Replace "Claude Code" brand references with "Augment"
converted = converted.replace(/\bClaude Code\b/g, 'Augment');
return converted;
}
function getAugmentSkillAdapterHeader(skillName) {
return `<augment_skill_adapter>
## A. Skill Invocation
- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
- Treat all user text after the skill mention as \`{{GSD_ARGS}}\`.
- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty.
## B. User Prompting
When the workflow needs user input, prompt the user conversationally:
- Present options as a numbered list in your response text
- Ask the user to reply with their choice
- For multi-select, ask for comma-separated numbers
## C. Tool Usage
Use these Augment tools when executing GSD workflows:
- \`launch-process\` for running commands (terminal operations)
- \`str-replace-editor\` for editing existing files
- \`view\` for reading files and listing directories
- \`save-file\` for creating new files
- \`grep\` for searching code (or use MCP servers for advanced search)
- \`web-search\`, \`web-fetch\` for web queries
- \`add_tasks\`, \`view_tasklist\`, \`update_tasks\` for task management
## D. Subagent Spawning
When the workflow needs to spawn a subagent:
- Use the built-in subagent spawning capability
- Define agent prompts in \`.augment/agents/\` directory
</augment_skill_adapter>`;
}
function convertClaudeCommandToAugmentSkill(content, skillName) {
const converted = convertClaudeToAugmentMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD workflow ${skillName}.`;
if (frontmatter) {
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
if (maybeDescription) {
description = maybeDescription;
}
}
description = toSingleLine(description);
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
const adapter = getAugmentSkillAdapterHeader(skillName);
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
/**
* Convert Claude Code agent markdown to Augment agent format.
* Strips frontmatter fields Augment doesn't support (color, skills),
* converts tool references, and cleans up for Augment agents.
*/
function convertClaudeAgentToAugmentAgent(content) {
let converted = convertClaudeToAugmentMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
/**
* Copy Claude commands as Augment skills — one folder per skill with SKILL.md.
* Mirrors copyCommandsAsCursorSkills but uses Augment converters.
*/
function copyCommandsAsAugmentSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) {
if (!fs.existsSync(srcDir)) {
return;
}
fs.mkdirSync(skillsDir, { recursive: true });
// Remove previous GSD Augment skills to avoid stale command skills
const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of existing) {
if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
}
}
function recurse(currentSrcDir, currentPrefix) {
const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(currentSrcDir, entry.name);
if (entry.isDirectory()) {
recurse(srcPath, `${currentPrefix}-${entry.name}`);
continue;
}
if (!entry.name.endsWith('.md')) {
continue;
}
const baseName = entry.name.replace('.md', '');
const skillName = `${currentPrefix}-${baseName}`;
const skillDir = path.join(skillsDir, skillName);
fs.mkdirSync(skillDir, { recursive: true });
let content = fs.readFileSync(srcPath, 'utf8');
const globalClaudeRegex = /~\/\.claude\//g;
const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
const localClaudeRegex = /\.\/\.claude\//g;
const augmentDirRegex = /~\/\.augment\//g;
content = content.replace(globalClaudeRegex, pathPrefix);
content = content.replace(globalClaudeHomeRegex, pathPrefix);
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
content = content.replace(augmentDirRegex, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
content = convertClaudeCommandToAugmentSkill(content, skillName);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
}
}
recurse(srcDir, prefix);
}
function convertSlashCommandsToCodexSkillMentions(content) { function convertSlashCommandsToCodexSkillMentions(content) {
let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => { let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
return `$gsd-${String(commandName).toLowerCase()}`; return `$gsd-${String(commandName).toLowerCase()}`;
@@ -3509,6 +3706,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
const isAntigravity = runtime === 'antigravity'; const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor'; const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf'; const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const dirName = getDirName(runtime); const dirName = getDirName(runtime);
// Clean install: remove existing destination to prevent orphaned files // Clean install: remove existing destination to prevent orphaned files
@@ -3777,6 +3975,7 @@ function uninstall(isGlobal, runtime = 'claude') {
const isAntigravity = runtime === 'antigravity'; const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor'; const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf'; const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const dirName = getDirName(runtime); const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type // Get the target directory based on runtime and install type
@@ -3797,6 +3996,7 @@ function uninstall(isGlobal, runtime = 'claude') {
if (runtime === 'antigravity') runtimeLabel = 'Antigravity'; if (runtime === 'antigravity') runtimeLabel = 'Antigravity';
if (runtime === 'cursor') runtimeLabel = 'Cursor'; if (runtime === 'cursor') runtimeLabel = 'Cursor';
if (runtime === 'windsurf') runtimeLabel = 'Windsurf'; if (runtime === 'windsurf') runtimeLabel = 'Windsurf';
if (runtime === 'augment') runtimeLabel = 'Augment';
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
@@ -3964,9 +4164,26 @@ function uninstall(isGlobal, runtime = 'claude') {
// Gemini: still uses commands/gsd/ // Gemini: still uses commands/gsd/
const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(gsdCommandsDir)) { if (fs.existsSync(gsdCommandsDir)) {
// Preserve user-generated files before wipe (#1423)
// Note: if more user files are added, consider a naming convention (e.g., USER-*.md)
// and preserve all matching files instead of listing each one individually.
const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md');
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
fs.rmSync(gsdCommandsDir, { recursive: true }); fs.rmSync(gsdCommandsDir, { recursive: true });
removedCount++; removedCount++;
console.log(` ${green}✓${reset} Removed commands/gsd/`); console.log(` ${green}✓${reset} Removed commands/gsd/`);
// Restore user-generated files
if (preservedDevPrefs) {
try {
fs.mkdirSync(gsdCommandsDir, { recursive: true });
fs.writeFileSync(devPrefsPath, preservedDevPrefs);
console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
}
}
} }
} else { } else {
// Claude Code: remove skills/gsd-*/ directories // Claude Code: remove skills/gsd-*/ directories
@@ -3989,18 +4206,47 @@ function uninstall(isGlobal, runtime = 'claude') {
// Also clean up legacy commands/gsd/ from older installs // Also clean up legacy commands/gsd/ from older installs
const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(legacyCommandsDir)) { if (fs.existsSync(legacyCommandsDir)) {
// Preserve user-generated files before legacy wipe (#1423)
const devPrefsPath = path.join(legacyCommandsDir, 'dev-preferences.md');
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
fs.rmSync(legacyCommandsDir, { recursive: true }); fs.rmSync(legacyCommandsDir, { recursive: true });
removedCount++; removedCount++;
console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
if (preservedDevPrefs) {
try {
fs.mkdirSync(legacyCommandsDir, { recursive: true });
fs.writeFileSync(devPrefsPath, preservedDevPrefs);
console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
}
}
} }
} }
// 2. Remove get-shit-done directory // 2. Remove get-shit-done directory
const gsdDir = path.join(targetDir, 'get-shit-done'); const gsdDir = path.join(targetDir, 'get-shit-done');
if (fs.existsSync(gsdDir)) { if (fs.existsSync(gsdDir)) {
// Preserve user-generated files before wipe (#1423)
const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md');
const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null;
fs.rmSync(gsdDir, { recursive: true }); fs.rmSync(gsdDir, { recursive: true });
removedCount++; removedCount++;
console.log(` ${green}✓${reset} Removed get-shit-done/`); console.log(` ${green}✓${reset} Removed get-shit-done/`);
// Restore user-generated files
if (preservedProfile) {
try {
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(userProfilePath, preservedProfile);
console.log(` ${green}✓${reset} Preserved get-shit-done/USER-PROFILE.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
}
}
} }
// 3. Remove GSD agents (gsd-*.md files only) // 3. Remove GSD agents (gsd-*.md files only)
@@ -4023,7 +4269,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// 4. Remove GSD hooks // 4. Remove GSD hooks
const hooksDir = path.join(targetDir, 'hooks'); const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir)) { if (fs.existsSync(hooksDir)) {
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js']; const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
let hookCount = 0; let hookCount = 0;
for (const hook of gsdHooks) { for (const hook of gsdHooks) {
const hookPath = path.join(hooksDir, hook); const hookPath = path.join(hooksDir, hook);
@@ -4338,7 +4584,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
} }
} }
// OpenCode also supports a top-level string permission like "allow". // OpenCode also allows a top-level string permission like "allow".
// In that case, path-specific permission entries are unnecessary. // In that case, path-specific permission entries are unnecessary.
if (typeof config.permission === 'string') { if (typeof config.permission === 'string') {
return; return;
@@ -4419,7 +4665,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
} }
// Ensure permission structure exists // Ensure permission structure exists
if (!config.permission) { if (!config.permission || typeof config.permission !== 'object') {
config.permission = {}; config.permission = {};
} }
@@ -4704,6 +4950,7 @@ function install(isGlobal, runtime = 'claude') {
const isAntigravity = runtime === 'antigravity'; const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor'; const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf'; const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const dirName = getDirName(runtime); const dirName = getDirName(runtime);
const src = path.join(__dirname, '..'); const src = path.join(__dirname, '..');
@@ -4736,6 +4983,7 @@ function install(isGlobal, runtime = 'claude') {
if (isAntigravity) runtimeLabel = 'Antigravity'; if (isAntigravity) runtimeLabel = 'Antigravity';
if (isCursor) runtimeLabel = 'Cursor'; if (isCursor) runtimeLabel = 'Cursor';
if (isWindsurf) runtimeLabel = 'Windsurf'; if (isWindsurf) runtimeLabel = 'Windsurf';
if (isAugment) runtimeLabel = 'Augment';
console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`); console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
@@ -4823,11 +5071,19 @@ function install(isGlobal, runtime = 'claude') {
} else { } else {
failures.push('skills/gsd-*'); failures.push('skills/gsd-*');
} }
} else if (isAugment) {
const skillsDir = path.join(targetDir, 'skills');
const gsdSrc = path.join(src, 'commands', 'gsd');
copyCommandsAsAugmentSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime);
const installedSkillNames = listCodexSkillNames(skillsDir);
if (installedSkillNames.length > 0) {
console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`);
} else {
failures.push('skills/gsd-*');
}
} else if (isGemini) { } else if (isGemini) {
// Gemini: nested structure in commands/ directory (still supported)
const commandsDir = path.join(targetDir, 'commands'); const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true }); fs.mkdirSync(commandsDir, { recursive: true });
const gsdSrc = path.join(src, 'commands', 'gsd'); const gsdSrc = path.join(src, 'commands', 'gsd');
const gsdDest = path.join(commandsDir, 'gsd'); const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
@@ -4916,6 +5172,8 @@ function install(isGlobal, runtime = 'claude') {
content = convertClaudeAgentToCursorAgent(content); content = convertClaudeAgentToCursorAgent(content);
} else if (isWindsurf) { } else if (isWindsurf) {
content = convertClaudeAgentToWindsurfAgent(content); content = convertClaudeAgentToWindsurfAgent(content);
} else if (isAugment) {
content = convertClaudeAgentToAugmentAgent(content);
} }
const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name;
fs.writeFileSync(path.join(agentsDest, destName), content); fs.writeFileSync(path.join(agentsDest, destName), content);
@@ -5241,6 +5499,75 @@ function install(isGlobal, runtime = 'claude') {
}); });
console.log(` ${green}✓${reset} Configured prompt injection guard hook`); console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
} }
// Community hooks — registered on install but opt-in at runtime.
// Each hook checks .planning/config.json for hooks.community: true
// and exits silently (no-op) if not enabled. This lets users enable
// them per-project by adding: "hooks": { "community": true }
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
const validateCommitCommand = isGlobal
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-validate-commit.sh'
: 'bash ' + dirName + '/hooks/gsd-validate-commit.sh';
const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit'))
);
if (!hasValidateCommitHook) {
settings.hooks[preToolEvent].push({
matcher: 'Bash',
hooks: [
{
type: 'command',
command: validateCommitCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
}
// Configure session state orientation hook (opt-in)
const sessionStateCommand = isGlobal
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-session-state.sh'
: 'bash ' + dirName + '/hooks/gsd-session-state.sh';
const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state'))
);
if (!hasSessionStateHook) {
settings.hooks.SessionStart.push({
hooks: [
{
type: 'command',
command: sessionStateCommand
}
]
});
console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
}
// Configure phase boundary detection hook (opt-in)
const phaseBoundaryCommand = isGlobal
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-phase-boundary.sh'
: 'bash ' + dirName + '/hooks/gsd-phase-boundary.sh';
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))
);
if (!hasPhaseBoundaryHook) {
settings.hooks[postToolEvent].push({
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: phaseBoundaryCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
}
} }
return { settingsPath, settings, statuslineCommand, runtime, configDir: targetDir }; return { settingsPath, settings, statuslineCommand, runtime, configDir: targetDir };
@@ -5309,6 +5636,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
if (runtime === 'copilot') program = 'Copilot'; if (runtime === 'copilot') program = 'Copilot';
if (runtime === 'antigravity') program = 'Antigravity'; if (runtime === 'antigravity') program = 'Antigravity';
if (runtime === 'cursor') program = 'Cursor'; if (runtime === 'cursor') program = 'Cursor';
if (runtime === 'windsurf') program = 'Windsurf';
if (runtime === 'augment') program = 'Augment';
let command = '/gsd:new-project'; let command = '/gsd:new-project';
if (runtime === 'opencode') command = '/gsd-new-project'; if (runtime === 'opencode') command = '/gsd-new-project';
@@ -5317,6 +5646,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
if (runtime === 'copilot') command = '/gsd-new-project'; if (runtime === 'copilot') command = '/gsd-new-project';
if (runtime === 'antigravity') command = '/gsd-new-project'; if (runtime === 'antigravity') command = '/gsd-new-project';
if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)'; if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)';
if (runtime === 'windsurf') command = '/gsd-new-project';
if (runtime === 'augment') command = '/gsd-new-project';
console.log(` console.log(`
${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
@@ -5468,9 +5799,10 @@ function promptRuntime(callback) {
'6': 'copilot', '6': 'copilot',
'7': 'antigravity', '7': 'antigravity',
'8': 'cursor', '8': 'cursor',
'9': 'windsurf' '9': 'windsurf',
'10': 'augment'
}; };
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf']; const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
${cyan}2${reset}) Kilo ${dim}(~/.config/kilo)${reset} - open source, the #1 AI coding platform on OpenRouter ${cyan}2${reset}) Kilo ${dim}(~/.config/kilo)${reset} - open source, the #1 AI coding platform on OpenRouter
@@ -5481,7 +5813,8 @@ function promptRuntime(callback) {
${cyan}7${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} ${cyan}7${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset}
${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset} ${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset}
${cyan}9${reset}) Windsurf ${dim}(~/.windsurf)${reset} ${cyan}9${reset}) Windsurf ${dim}(~/.windsurf)${reset}
${cyan}10${reset}) All ${cyan}10${reset}) Augment ${dim}(~/.augment)${reset}
${cyan}11${reset}) All
${dim}Select multiple: 1,2,6 or 1 2 6${reset} ${dim}Select multiple: 1,2,6 or 1 2 6${reset}
`); `);
@@ -5492,7 +5825,7 @@ function promptRuntime(callback) {
const input = answer.trim() || '1'; const input = answer.trim() || '1';
// "All" shortcut // "All" shortcut
if (input === '10') { if (input === '11') {
callback(allRuntimes); callback(allRuntimes);
return; return;
} }
@@ -5655,11 +5988,16 @@ if (process.env.GSD_TEST_MODE) {
convertClaudeCommandToWindsurfSkill, convertClaudeCommandToWindsurfSkill,
convertClaudeAgentToWindsurfAgent, convertClaudeAgentToWindsurfAgent,
copyCommandsAsWindsurfSkills, copyCommandsAsWindsurfSkills,
convertClaudeToAugmentMarkdown,
convertClaudeCommandToAugmentSkill,
convertClaudeAgentToAugmentAgent,
copyCommandsAsAugmentSkills,
writeManifest, writeManifest,
reportLocalPatches, reportLocalPatches,
validateHookFields, validateHookFields,
installSdk, installSdk,
promptSdk, promptSdk,
configureOpencodePermissions,
}; };
} else { } else {

View File

@@ -1,7 +1,7 @@
--- ---
name: gsd:autonomous name: gsd:autonomous
description: Run all remaining phases autonomously — discuss→plan→execute per phase description: Run all remaining phases autonomously — discuss→plan→execute per phase
argument-hint: "[--from N]" argument-hint: "[--from N] [--only N] [--interactive]"
allowed-tools: allowed-tools:
- Read - Read
- Write - Write
@@ -30,7 +30,10 @@ Uses ROADMAP.md phase discovery and Skill() flat invocations for each phase comm
</execution_context> </execution_context>
<context> <context>
Optional flag: `--from N` — start from phase N instead of the first incomplete phase. Optional flags:
- `--from N` — start from phase N instead of the first incomplete phase.
- `--only N` — execute only phase N (single-phase mode).
- `--interactive` — run discuss inline with questions (not auto-answered), then dispatch plan→execute as background agents. Keeps the main context lean while preserving user input on decisions.
Project context, phase list, and state are resolved inside the workflow using init commands (`gsd-tools.cjs init milestone-op`, `gsd-tools.cjs roadmap analyze`). No upfront context loading needed. Project context, phase list, and state are resolved inside the workflow using init commands (`gsd-tools.cjs init milestone-op`, `gsd-tools.cjs roadmap analyze`). No upfront context loading needed.
</context> </context>

View File

@@ -1,7 +1,7 @@
--- ---
name: gsd:debug name: gsd:debug
description: Systematic debugging with persistent state across context resets description: Systematic debugging with persistent state across context resets
argument-hint: [issue description] argument-hint: [--diagnose] [issue description]
allowed-tools: allowed-tools:
- Read - Read
- Bash - Bash
@@ -15,6 +15,9 @@ Debug issues using scientific method with subagent isolation.
**Orchestrator role:** Gather symptoms, spawn gsd-debugger agent, handle checkpoints, spawn continuations. **Orchestrator role:** Gather symptoms, spawn gsd-debugger agent, handle checkpoints, spawn continuations.
**Why subagent:** Investigation burns context fast (reading files, forming hypotheses, testing). Fresh 200k context per investigation. Main context stays lean for user interaction. **Why subagent:** Investigation burns context fast (reading files, forming hypotheses, testing). Fresh 200k context per investigation. Main context stays lean for user interaction.
**Flags:**
- `--diagnose` — Diagnose only. Find root cause without applying a fix. Returns a structured Root Cause Report. Use when you want to validate the diagnosis before committing to a fix.
</objective> </objective>
<available_agent_types> <available_agent_types>
@@ -25,6 +28,10 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
<context> <context>
User's issue: $ARGUMENTS User's issue: $ARGUMENTS
Parse flags from $ARGUMENTS:
- If `--diagnose` is present, set `diagnose_only=true` and remove the flag from the issue description.
- Otherwise, `diagnose_only=false`.
Check for active sessions: Check for active sessions:
```bash ```bash
ls .planning/debug/*.md 2>/dev/null | grep -v resolved | head -5 ls .planning/debug/*.md 2>/dev/null | grep -v resolved | head -5
@@ -87,7 +94,7 @@ timeline: {timeline}
<mode> <mode>
symptoms_prefilled: true symptoms_prefilled: true
goal: find_and_fix goal: {if diagnose_only: "find_root_cause_only", else: "find_and_fix"}
</mode> </mode>
<debug_file> <debug_file>
@@ -106,12 +113,18 @@ Task(
## 4. Handle Agent Return ## 4. Handle Agent Return
**If `## ROOT CAUSE FOUND`:** **If `## ROOT CAUSE FOUND` (diagnose-only mode):**
- Display root cause and evidence summary - Display root cause, confidence level, files involved, and suggested fix strategies
- Offer options: - Offer options:
- "Fix now" - spawn fix subagent - "Fix now" — spawn a continuation agent with `goal: find_and_fix` to apply the fix (see step 5)
- "Plan fix" - suggest /gsd:plan-phase --gaps - "Plan fix" — suggest `/gsd:plan-phase --gaps`
- "Manual fix" - done - "Manual fix" — done
**If `## DEBUG COMPLETE` (find_and_fix mode):**
- Display root cause and fix summary
- Offer options:
- "Plan fix" — suggest `/gsd:plan-phase --gaps` if further work needed
- "Done" — mark resolved
**If `## CHECKPOINT REACHED`:** **If `## CHECKPOINT REACHED`:**
- Present checkpoint details to user - Present checkpoint details to user
@@ -128,9 +141,9 @@ Task(
- "Manual investigation" - done - "Manual investigation" - done
- "Add more context" - gather more symptoms, spawn again - "Add more context" - gather more symptoms, spawn again
## 5. Spawn Continuation Agent (After Checkpoint) ## 5. Spawn Continuation Agent (After Checkpoint or "Fix now")
When user responds to checkpoint, spawn fresh agent: When user responds to checkpoint OR selects "Fix now" from diagnose-only results, spawn fresh agent:
```markdown ```markdown
<objective> <objective>

View File

@@ -1,7 +1,7 @@
--- ---
name: gsd:review name: gsd:review
description: Request cross-AI peer review of phase plans from external AI CLIs description: Request cross-AI peer review of phase plans from external AI CLIs
argument-hint: "--phase N [--gemini] [--claude] [--codex] [--all]" argument-hint: "--phase N [--gemini] [--claude] [--codex] [--opencode] [--all]"
allowed-tools: allowed-tools:
- Read - Read
- Write - Write
@@ -11,7 +11,7 @@ allowed-tools:
--- ---
<objective> <objective>
Invoke external AI CLIs (Gemini, Claude, Codex) to independently review phase plans. Invoke external AI CLIs (Gemini, Claude, Codex, OpenCode) to independently review phase plans.
Produces a structured REVIEWS.md with per-reviewer feedback that can be fed back into Produces a structured REVIEWS.md with per-reviewer feedback that can be fed back into
planning via /gsd:plan-phase --reviews. planning via /gsd:plan-phase --reviews.
@@ -29,6 +29,7 @@ Phase number: extracted from $ARGUMENTS (required)
- `--gemini` — Include Gemini CLI review - `--gemini` — Include Gemini CLI review
- `--claude` — Include Claude CLI review (uses separate session) - `--claude` — Include Claude CLI review (uses separate session)
- `--codex` — Include Codex CLI review - `--codex` — Include Codex CLI review
- `--opencode` — Include OpenCode review (uses model from user's OpenCode config)
- `--all` — Include all available CLIs - `--all` — Include all available CLIs
</context> </context>

View File

@@ -2,7 +2,7 @@
# GET SHIT DONE # GET SHIT DONE
**一个轻量级且强大的元提示、上下文工程和规格驱动开发系统,支持 Claude Code、OpenCode、Gemini CLI、Kilo 和 Codex。** **一个轻量级且强大的元提示、上下文工程和规格驱动开发系统,支持 Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf、Antigravity 和 Augment。**
**解决上下文衰减 —— 即 Claude 填充上下文窗口时发生的质量退化问题。** **解决上下文衰减 —— 即 Claude 填充上下文窗口时发生的质量退化问题。**

View File

@@ -430,6 +430,11 @@ async function runCommand(command, args, cwd, raw) {
break; break;
} }
case 'check-commit': {
commands.cmdCheckCommit(cwd, raw);
break;
}
case 'commit-to-subrepo': { case 'commit-to-subrepo': {
const message = args[1]; const message = args[1];
const filesIndex = args.indexOf('--files'); const filesIndex = args.indexOf('--files');

View File

@@ -960,6 +960,39 @@ function cmdStats(cwd, format, raw) {
} }
} }
/**
* Check whether a commit should be allowed based on commit_docs config.
* When commit_docs is false, rejects commits that stage .planning/ files.
* Intended for use as a pre-commit hook guard.
*/
function cmdCheckCommit(cwd, raw) {
const config = loadConfig(cwd);
// If commit_docs is true (or not set), allow all commits
if (config.commit_docs !== false) {
output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed');
return;
}
// commit_docs is false — check if any .planning/ files are staged
try {
const staged = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8' }).trim();
const planningFiles = staged.split('\n').filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\'));
if (planningFiles.length > 0) {
error(
`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
planningFiles.map(f => ` ${f}`).join('\n') +
`\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`
);
}
} catch {
// git diff --cached failed (no staged files or not a git repo) — allow
}
output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed');
}
module.exports = { module.exports = {
cmdGenerateSlug, cmdGenerateSlug,
cmdCurrentTimestamp, cmdCurrentTimestamp,
@@ -976,4 +1009,5 @@ module.exports = {
cmdTodoMatchPhase, cmdTodoMatchPhase,
cmdScaffold, cmdScaffold,
cmdStats, cmdStats,
cmdCheckCommit,
}; };

View File

@@ -23,8 +23,9 @@ const VALID_CONFIG_KEYS = new Set([
'workflow.skip_discuss', 'workflow.skip_discuss',
'workflow._auto_chain_active', 'workflow._auto_chain_active',
'workflow.use_worktrees', 'workflow.use_worktrees',
'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', 'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored', 'planning.commit_docs', 'planning.search_gitignored',
'workflow.subagent_timeout',
'hooks.context_warnings', 'hooks.context_warnings',
'project_code', 'phase_naming', 'project_code', 'phase_naming',
]); ]);

View File

@@ -195,7 +195,7 @@ function safeReadFile(filePath) {
} }
function loadConfig(cwd) { function loadConfig(cwd) {
const configPath = path.join(cwd, '.planning', 'config.json'); const configPath = path.join(planningDir(cwd), 'config.json');
const defaults = { const defaults = {
model_profile: 'balanced', model_profile: 'balanced',
commit_docs: true, commit_docs: true,
@@ -218,6 +218,7 @@ function loadConfig(cwd) {
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs) phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation') project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation')
subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms)
}; };
try { try {
@@ -310,6 +311,7 @@ function loadConfig(cwd) {
context_window: get('context_window') ?? defaults.context_window, context_window: get('context_window') ?? defaults.context_window,
phase_naming: get('phase_naming') ?? defaults.phase_naming, phase_naming: get('phase_naming') ?? defaults.phase_naming,
project_code: get('project_code') ?? defaults.project_code, project_code: get('project_code') ?? defaults.project_code,
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
model_overrides: parsed.model_overrides || null, model_overrides: parsed.model_overrides || null,
agent_skills: parsed.agent_skills || {}, agent_skills: parsed.agent_skills || {},
}; };
@@ -360,21 +362,45 @@ function normalizeMd(content) {
const lines = text.split('\n'); const lines = text.split('\n');
const result = []; const result = [];
// Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
const fenceRegex = /^```/;
const insideFence = new Array(lines.length);
let fenceOpen = false;
for (let i = 0; i < lines.length; i++) {
if (fenceRegex.test(lines[i].trimEnd())) {
if (fenceOpen) {
// This is a closing fence — mark as NOT inside (it's the boundary)
insideFence[i] = false;
fenceOpen = false;
} else {
// This is an opening fence
insideFence[i] = false;
fenceOpen = true;
}
} else {
insideFence[i] = fenceOpen;
}
}
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
const prev = i > 0 ? lines[i - 1] : ''; const prev = i > 0 ? lines[i - 1] : '';
const prevTrimmed = prev.trimEnd(); const prevTrimmed = prev.trimEnd();
const trimmed = line.trimEnd(); const trimmed = line.trimEnd();
const isFenceLine = fenceRegex.test(trimmed);
// MD022: Blank line before headings (skip first line and frontmatter delimiters) // MD022: Blank line before headings (skip first line and frontmatter delimiters)
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') { if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
result.push(''); result.push('');
} }
// MD031: Blank line before fenced code blocks // MD031: Blank line before fenced code blocks (opening fences only)
if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) { if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
// Only add blank before opening fences (not closing ones)
if (i === 0 || !insideFence[i - 1]) {
result.push(''); result.push('');
} }
}
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item) // MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
@@ -394,7 +420,7 @@ function normalizeMd(content) {
} }
// MD031: Blank line after closing fenced code blocks // MD031: Blank line after closing fenced code blocks
if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) { if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
const next = lines[i + 1]; const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '') { if (next !== undefined && next.trimEnd() !== '') {
result.push(''); result.push('');
@@ -424,24 +450,6 @@ function normalizeMd(content) {
return text; return text;
} }
/** Check if line index i is inside an already-open fenced code block */
function isInsideFencedBlock(lines, i) {
let fenceCount = 0;
for (let j = 0; j < i; j++) {
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
}
return fenceCount % 2 === 1;
}
/** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */
function isClosingFence(lines, i) {
let fenceCount = 0;
for (let j = 0; j <= i; j++) {
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
}
return fenceCount % 2 === 0;
}
function execGit(cwd, args) { function execGit(cwd, args) {
const result = spawnSync('git', args, { const result = spawnSync('git', args, {
cwd, cwd,
@@ -542,20 +550,43 @@ function withPlanningLock(cwd, fn) {
} }
/** /**
* Get the .planning directory path, workstream-aware. * Get the .planning directory path, project- and workstream-aware.
* When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var), *
* returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`. * Resolution order:
* 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to
* `.planning/{project}/` — supports multi-project workspaces where several
* independent projects share a single `.planning/` root directory (e.g.,
* an Obsidian vault or monorepo knowledge base used as a command center).
* 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`.
* 3. Otherwise returns `.planning/`.
*
* GSD_PROJECT and GSD_WORKSTREAM can be combined:
* `.planning/{project}/workstreams/{ws}/`
* *
* @param {string} cwd - project root * @param {string} cwd - project root
* @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var * @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var
* @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var
*/ */
function planningDir(cwd, ws) { function planningDir(cwd, ws, project) {
if (project === undefined) project = process.env.GSD_PROJECT || null;
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
if (!ws) return path.join(cwd, '.planning');
return path.join(cwd, '.planning', 'workstreams', ws); // Reject path separators and traversal components in project/workstream names
const BAD_SEGMENT = /[/\\]|\.\./;
if (project && BAD_SEGMENT.test(project)) {
throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`);
}
if (ws && BAD_SEGMENT.test(ws)) {
throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`);
}
let base = path.join(cwd, '.planning');
if (project) base = path.join(base, project);
if (ws) base = path.join(base, 'workstreams', ws);
return base;
} }
/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */ /** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */
function planningRoot(cwd) { function planningRoot(cwd) {
return path.join(cwd, '.planning'); return path.join(cwd, '.planning');
} }

View File

@@ -252,6 +252,19 @@ function parseMustHavesBlock(content, blockName) {
} }
if (current) items.push(current); if (current) items.push(current);
// Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
// to Option C (LLM-derived truths) instead of checking documented contracts.
if (items.length === 0 && blockLines.length > 0) {
const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
if (nonEmptyLines > 0) {
process.stderr.write(
`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`
);
}
}
return items; return items;
} }

View File

@@ -780,6 +780,7 @@ function cmdInitMapCodebase(cwd, raw) {
commit_docs: config.commit_docs, commit_docs: config.commit_docs,
search_gitignored: config.search_gitignored, search_gitignored: config.search_gitignored,
parallelization: config.parallelization, parallelization: config.parallelization,
subagent_timeout: config.subagent_timeout,
// Paths // Paths
codebase_dir: '.planning/codebase', codebase_dir: '.planning/codebase',

View File

@@ -4,7 +4,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error, readSubdirectories } = require('./core.cjs'); const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs');
const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs'); const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs');
@@ -328,22 +328,25 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
error('ROADMAP.md not found'); error('ROADMAP.md not found');
} }
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
const slug = generateSlugInternal(description); const slug = generateSlugInternal(description);
let newPhaseId; // Wrap entire read-modify-write in lock to prevent concurrent corruption
let dirName; const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation') // Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
const projectCode = config.project_code || ''; const projectCode = config.project_code || '';
const prefix = projectCode ? `${projectCode}-` : ''; const prefix = projectCode ? `${projectCode}-` : '';
let _newPhaseId;
let _dirName;
if (customId || config.phase_naming === 'custom') { if (customId || config.phase_naming === 'custom') {
// Custom phase naming: use provided ID or generate from description // Custom phase naming: use provided ID or generate from description
newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-'); _newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
if (!newPhaseId) error('--id required when phase_naming is "custom"'); if (!_newPhaseId) error('--id required when phase_naming is "custom"');
dirName = `${prefix}${newPhaseId}-${slug}`; _dirName = `${prefix}${_newPhaseId}-${slug}`;
} else { } else {
// Sequential mode: find highest integer phase number (in current milestone only) // Sequential mode: find highest integer phase number (in current milestone only)
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
@@ -354,20 +357,20 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
if (num > maxPhase) maxPhase = num; if (num > maxPhase) maxPhase = num;
} }
newPhaseId = maxPhase + 1; _newPhaseId = maxPhase + 1;
const paddedNum = String(newPhaseId).padStart(2, '0'); const paddedNum = String(_newPhaseId).padStart(2, '0');
dirName = `${prefix}${paddedNum}-${slug}`; _dirName = `${prefix}${paddedNum}-${slug}`;
} }
const dirPath = path.join(planningDir(cwd), 'phases', dirName); const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
// Create directory with .gitkeep so git tracks empty folders // Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
// Build phase entry // Build phase entry
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`; const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseId} to break down)\n`; const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_newPhaseId} to break down)\n`;
// Find insertion point: before last "---" or at end // Find insertion point: before last "---" or at end
let updatedContent; let updatedContent;
@@ -379,6 +382,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
} }
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
return { newPhaseId: _newPhaseId, dirName: _dirName };
});
const result = { const result = {
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId), phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
@@ -402,9 +407,12 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
error('ROADMAP.md not found'); error('ROADMAP.md not found');
} }
const slug = generateSlugInternal(description);
// Wrap entire read-modify-write in lock to prevent concurrent corruption
const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8'); const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd); const content = extractCurrentMilestone(rawContent, cwd);
const slug = generateSlugInternal(description);
// Normalize input then strip leading zeros for flexible matching // Normalize input then strip leading zeros for flexible matching
const normalizedAfter = normalizePhaseName(afterPhase); const normalizedAfter = normalizePhaseName(afterPhase);
@@ -431,21 +439,20 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
} catch { /* intentionally empty */ } } catch { /* intentionally empty */ }
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
const decimalPhase = `${normalizedBase}.${nextDecimal}`; const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
// Optional project code prefix // Optional project code prefix
const config = loadConfig(cwd); const insertConfig = loadConfig(cwd);
const projectCode = config.project_code || ''; const projectCode = insertConfig.project_code || '';
const prefix = projectCode ? `${projectCode}-` : ''; const pfx = projectCode ? `${projectCode}-` : '';
const dirName = `${prefix}${decimalPhase}-${slug}`; const _dirName = `${pfx}${_decimalPhase}-${slug}`;
const dirPath = path.join(planningDir(cwd), 'phases', dirName); const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
// Create directory with .gitkeep so git tracks empty folders // Create directory with .gitkeep so git tracks empty folders
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
// Build phase entry // Build phase entry
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`; const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_decimalPhase} to break down)\n`;
// Insert after the target phase section // Insert after the target phase section
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
@@ -467,6 +474,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx); const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
return { decimalPhase: _decimalPhase, dirName: _dirName };
});
const result = { const result = {
phase_number: decimalPhase, phase_number: decimalPhase,
@@ -554,7 +563,9 @@ function renameIntegerPhases(phasesDir, removedInt) {
/** /**
* Remove a phase section from ROADMAP.md and renumber all subsequent integer phases. * Remove a phase section from ROADMAP.md and renumber all subsequent integer phases.
*/ */
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt) { function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
// Wrap entire read-modify-write in lock to prevent concurrent corruption
withPlanningLock(cwd, () => {
let content = fs.readFileSync(roadmapPath, 'utf-8'); let content = fs.readFileSync(roadmapPath, 'utf-8');
const escaped = escapeRegex(targetPhase); const escaped = escapeRegex(targetPhase);
@@ -577,6 +588,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
} }
fs.writeFileSync(roadmapPath, content, 'utf-8'); fs.writeFileSync(roadmapPath, content, 'utf-8');
});
} }
function cmdPhaseRemove(cwd, targetPhase, options, raw) { function cmdPhaseRemove(cwd, targetPhase, options, raw) {
@@ -617,7 +629,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
} catch { /* intentionally empty */ } } catch { /* intentionally empty */ }
// Update ROADMAP.md // Update ROADMAP.md
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10)); updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
// Update STATE.md phase count // Update STATE.md phase count
const statePath = path.join(planningDir(cwd), 'STATE.md'); const statePath = path.join(planningDir(cwd), 'STATE.md');
@@ -686,8 +698,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
} }
} catch {} } catch {}
// Update ROADMAP.md: mark phase complete // Update ROADMAP.md and REQUIREMENTS.md atomically under lock
if (fs.existsSync(roadmapPath)) { if (fs.existsSync(roadmapPath)) {
withPlanningLock(cwd, () => {
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE) // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
@@ -778,6 +791,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
requirementsUpdated = true; requirementsUpdated = true;
} }
} }
});
} }
// Find next phase — check both filesystem AND roadmap // Find next phase — check both filesystem AND roadmap

View File

@@ -4,7 +4,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { escapeRegex, normalizePhaseName, planningPaths, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs'); const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
function cmdRoadmapGetPhase(cwd, phaseNum, raw) { function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
const roadmapPath = planningPaths(cwd).roadmap; const roadmapPath = planningPaths(cwd).roadmap;
@@ -254,6 +254,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
return; return;
} }
// Wrap entire read-modify-write in lock to prevent concurrent corruption
withPlanningLock(cwd, () => {
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const phaseEscaped = escapeRegex(phaseNum); const phaseEscaped = escapeRegex(phaseNum);
@@ -311,6 +313,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
} }
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
});
output({ output({
updated: true, updated: true,

View File

@@ -141,9 +141,10 @@ function cmdStatePatch(cwd, patches, raw) {
const statePath = planningPaths(cwd).state; const statePath = planningPaths(cwd).state;
try { try {
let content = fs.readFileSync(statePath, 'utf-8');
const results = { updated: [], failed: [] }; const results = { updated: [], failed: [] };
// Use atomic read-modify-write to prevent lost updates from concurrent agents
readModifyWriteStateMd(statePath, (content) => {
for (const [field, value] of Object.entries(patches)) { for (const [field, value] of Object.entries(patches)) {
const fieldEscaped = escapeRegex(field); const fieldEscaped = escapeRegex(field);
// Try **Field:** bold format first, then plain Field: format // Try **Field:** bold format first, then plain Field: format
@@ -160,10 +161,8 @@ function cmdStatePatch(cwd, patches, raw) {
results.failed.push(field); results.failed.push(field);
} }
} }
return content;
if (results.updated.length > 0) { }, cwd);
writeStateMd(statePath, content, cwd);
}
output(results, raw, results.updated.length > 0 ? 'true' : 'false'); output(results, raw, results.updated.length > 0 ? 'true' : 'false');
} catch { } catch {
@@ -236,6 +235,12 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
result = stateReplaceField(content, fallback, value); result = stateReplaceField(content, fallback, value);
if (result) return result; if (result) return result;
} }
// Neither pattern matched — field may have been reformatted or removed.
// Log diagnostic so template drift is detected early rather than silently swallowed.
process.stderr.write(
`[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
`This may indicate STATE.md was externally modified or uses an unexpected format.\n`
);
return content; return content;
} }
@@ -780,6 +785,50 @@ function syncStateFrontmatter(content, cwd) {
return `---\n${yamlStr}\n---\n\n${body}`; return `---\n${yamlStr}\n---\n\n${body}`;
} }
/**
* Acquire a lockfile for STATE.md operations.
* Returns the lock path for later release.
*/
function acquireStateLock(statePath) {
const lockPath = statePath + '.lock';
const maxRetries = 10;
const retryDelay = 200; // ms
for (let i = 0; i < maxRetries; i++) {
try {
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
return lockPath;
} catch (err) {
if (err.code === 'EEXIST') {
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > 10000) {
fs.unlinkSync(lockPath);
continue;
}
} catch { /* lock was released between check — retry */ }
if (i === maxRetries - 1) {
try { fs.unlinkSync(lockPath); } catch {}
return lockPath;
}
const jitter = Math.floor(Math.random() * 50);
const start = Date.now();
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
continue;
}
return lockPath; // non-EEXIST error — proceed without lock
}
}
return statePath + '.lock';
}
function releaseStateLock(lockPath) {
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
}
/** /**
* Write STATE.md with synchronized YAML frontmatter. * Write STATE.md with synchronized YAML frontmatter.
* All STATE.md writes should use this instead of raw writeFileSync. * All STATE.md writes should use this instead of raw writeFileSync.
@@ -788,48 +837,29 @@ function syncStateFrontmatter(content, cwd) {
*/ */
function writeStateMd(statePath, content, cwd) { function writeStateMd(statePath, content, cwd) {
const synced = syncStateFrontmatter(content, cwd); const synced = syncStateFrontmatter(content, cwd);
const lockPath = statePath + '.lock'; const lockPath = acquireStateLock(statePath);
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 { try {
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8'); fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
} finally { } finally {
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ } releaseStateLock(lockPath);
}
}
/**
* Atomic read-modify-write for STATE.md.
* Holds the lock across the entire read -> transform -> write cycle,
* preventing the lost-update problem where two agents read the same
* content and the second write clobbers the first.
*/
function readModifyWriteStateMd(statePath, transformFn, cwd) {
const lockPath = acquireStateLock(statePath);
try {
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
const modified = transformFn(content);
const synced = syncStateFrontmatter(modified, cwd);
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
} finally {
releaseStateLock(lockPath);
} }
} }

View File

@@ -5,7 +5,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { safeReadFile, loadConfig, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error, checkAgentsInstalled } = require('./core.cjs'); const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error, checkAgentsInstalled } = require('./core.cjs');
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs'); const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
const { writeStateMd } = require('./state.cjs'); const { writeStateMd } = require('./state.cjs');
@@ -757,6 +757,71 @@ function cmdValidateHealth(cwd, options, raw) {
} }
} }
// ─── Check 9: STATE.md / ROADMAP.md cross-validation ─────────────────────
if (fs.existsSync(statePath) && fs.existsSync(roadmapPath)) {
try {
const stateContent = fs.readFileSync(statePath, 'utf-8');
const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8');
// Extract current phase from STATE.md
const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/i) ||
stateContent.match(/Current Phase:\s*(\S+)/i);
if (currentPhaseMatch) {
const statePhase = currentPhaseMatch[1].replace(/^0+/, '');
// Check if ROADMAP shows this phase as already complete
const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}[:\\s]`, 'i');
if (phaseCheckboxRe.test(roadmapContentFull)) {
// STATE says "current" but ROADMAP says "complete" — divergence
const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i);
const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : '';
if (statusVal !== 'complete' && statusVal !== 'done') {
addIssue('warning', 'W011',
`STATE.md says current phase is ${statePhase} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`,
'Run /gsd:progress to re-derive current position, or manually update STATE.md');
}
}
}
} catch { /* intentionally empty — cross-validation is advisory */ }
}
// ─── Check 10: Config field validation ────────────────────────────────────
if (fs.existsSync(configPath)) {
try {
const configRaw = fs.readFileSync(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw);
// Validate branching_strategy
const validStrategies = ['none', 'phase', 'milestone'];
if (configParsed.branching_strategy && !validStrategies.includes(configParsed.branching_strategy)) {
addIssue('warning', 'W012',
`config.json: invalid branching_strategy "${configParsed.branching_strategy}"`,
`Valid values: ${validStrategies.join(', ')}`);
}
// Validate context_window is a positive integer
if (configParsed.context_window !== undefined) {
const cw = configParsed.context_window;
if (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw)) {
addIssue('warning', 'W013',
`config.json: context_window should be a positive integer, got "${cw}"`,
'Set to 200000 (default) or 1000000 (for 1M models)');
}
}
// Validate branch templates have required placeholders
if (configParsed.phase_branch_template && !configParsed.phase_branch_template.includes('{phase}')) {
addIssue('warning', 'W014',
'config.json: phase_branch_template missing {phase} placeholder',
'Template must include {phase} for phase number substitution');
}
if (configParsed.milestone_branch_template && !configParsed.milestone_branch_template.includes('{milestone}')) {
addIssue('warning', 'W015',
'config.json: milestone_branch_template missing {milestone} placeholder',
'Template must include {milestone} for version substitution');
}
} catch { /* parse error already caught in Check 5 */ }
}
// ─── Perform repairs if requested ───────────────────────────────────────── // ─── Perform repairs if requested ─────────────────────────────────────────
const repairActions = []; const repairActions = [];
if (options.repair && repairs.length > 0) { if (options.repair && repairs.length > 0) {

View File

@@ -10,6 +10,7 @@ Configuration options for `.planning/` directory behavior.
}, },
"git": { "git": {
"branching_strategy": "none", "branching_strategy": "none",
"base_branch": null,
"phase_branch_template": "gsd/phase-{phase}-{slug}", "phase_branch_template": "gsd/phase-{phase}-{slug}",
"milestone_branch_template": "gsd/{milestone}-{slug}", "milestone_branch_template": "gsd/{milestone}-{slug}",
"quick_branch_template": null "quick_branch_template": null
@@ -21,10 +22,12 @@ Configuration options for `.planning/` directory behavior.
| `commit_docs` | `true` | Whether to commit planning artifacts to git | | `commit_docs` | `true` | Whether to commit planning artifacts to git |
| `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches | | `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches |
| `git.branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` | | `git.branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` |
| `git.base_branch` | `null` (auto-detect) | Target branch for PRs and merges (e.g. `"master"`, `"develop"`). When `null`, auto-detects from `git symbolic-ref refs/remotes/origin/HEAD`, falling back to `"main"`. |
| `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy | | `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy |
| `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy | | `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy |
| `git.quick_branch_template` | `null` | Optional branch template for quick-task runs | | `git.quick_branch_template` | `null` | Optional branch template for quick-task runs |
| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. | | `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. |
| `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). |
</config_schema> </config_schema>
<commit_docs_behavior> <commit_docs_behavior>

View File

@@ -16,7 +16,7 @@ Read all files referenced by the invoking prompt's execution_context before star
## 1. Initialize ## 1. Initialize
Parse `$ARGUMENTS` for `--from N` and `--only N` flags: Parse `$ARGUMENTS` for `--from N`, `--only N`, and `--interactive` flags:
```bash ```bash
FROM_PHASE="" FROM_PHASE=""
@@ -29,10 +29,17 @@ if echo "$ARGUMENTS" | grep -qE '\-\-only\s+[0-9]'; then
ONLY_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-only\s+[0-9]+\.?[0-9]*' | awk '{print $2}') ONLY_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-only\s+[0-9]+\.?[0-9]*' | awk '{print $2}')
FROM_PHASE="$ONLY_PHASE" FROM_PHASE="$ONLY_PHASE"
fi fi
INTERACTIVE=""
if echo "$ARGUMENTS" | grep -q '\-\-interactive'; then
INTERACTIVE="true"
fi
``` ```
When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies. When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies.
When `--interactive` is set, discuss runs inline with questions (not auto-answered), while plan and execute are dispatched as background agents. This keeps the main context lean — only discuss conversations accumulate — while preserving user input on all design decisions.
Bootstrap via milestone-level init: Bootstrap via milestone-level init:
```bash ```bash
@@ -58,6 +65,7 @@ Display startup banner:
If `ONLY_PHASE` is set, display: `Single phase mode: Phase ${ONLY_PHASE}` If `ONLY_PHASE` is set, display: `Single phase mode: Phase ${ONLY_PHASE}`
Else if `FROM_PHASE` is set, display: `Starting from phase ${FROM_PHASE}` Else if `FROM_PHASE` is set, display: `Starting from phase ${FROM_PHASE}`
If `INTERACTIVE` is set, display: `Mode: Interactive (discuss inline, plan+execute in background)`
</step> </step>
@@ -230,18 +238,26 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${PADDED_PHASE
Proceed to 3b. Proceed to 3b.
**If SKIP_DISCUSS is `false` (or unset):** Execute the smart_discuss step for this phase. **If SKIP_DISCUSS is `false` (or unset):**
**IMPORTANT — Discuss must be single-pass in autonomous mode.** **IMPORTANT — Discuss must be single-pass in autonomous mode.**
The discuss step in `--auto` mode MUST NOT loop. If CONTEXT.md already exists after discuss completes, do NOT re-invoke discuss for the same phase. The `has_context` check below is authoritative — once true, discuss is done for this phase regardless of perceived "gaps" in the context file. The discuss step in `--auto` mode MUST NOT loop. If CONTEXT.md already exists after discuss completes, do NOT re-invoke discuss for the same phase. The `has_context` check below is authoritative — once true, discuss is done for this phase regardless of perceived "gaps" in the context file.
After smart_discuss completes, verify context was written: **If `INTERACTIVE` is set:** Run the standard discuss-phase skill inline (asks interactive questions, waits for user answers). This preserves user input on all design decisions while keeping plan+execute out of the main context:
```
Skill(skill="gsd:discuss-phase", args="${PHASE_NUM}")
```
**If `INTERACTIVE` is NOT set:** Execute the smart_discuss step for this phase (batch table proposals, auto-optimized).
After discuss completes (either mode), verify context was written:
```bash ```bash
PHASE_STATE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op ${PHASE_NUM}) PHASE_STATE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op ${PHASE_NUM})
``` ```
Check `has_context`. If false → go to handle_blocker: "Smart discuss for phase ${PHASE_NUM} did not produce CONTEXT.md." Check `has_context`. If false → go to handle_blocker: "Discuss for phase ${PHASE_NUM} did not produce CONTEXT.md."
**3a.5. UI Design Contract (Frontend Phases)** **3a.5. UI Design Contract (Frontend Phases)**
@@ -284,6 +300,20 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
**3b. Plan** **3b. Plan**
**If `INTERACTIVE` is set:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4).
```
Agent(
description="Plan phase ${PHASE_NUM}: ${PHASE_NAME}",
run_in_background=true,
prompt="Run plan-phase for phase ${PHASE_NUM}: Skill(skill=\"gsd:plan-phase\", args=\"${PHASE_NUM}\")"
)
```
Store the agent task_id. After discuss for the next phase completes (or if no next phase), wait for the plan agent to finish before proceeding to execute.
**If `INTERACTIVE` is NOT set (default):** Run plan inline as before.
``` ```
Skill(skill="gsd:plan-phase", args="${PHASE_NUM}") Skill(skill="gsd:plan-phase", args="${PHASE_NUM}")
``` ```
@@ -292,13 +322,29 @@ Verify plan produced output — re-run `init phase-op` and check `has_plans`. If
**3c. Execute** **3c. Execute**
**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already), verify plans exist, then dispatch execute as a background agent:
```
Agent(
description="Execute phase ${PHASE_NUM}: ${PHASE_NAME}",
run_in_background=true,
prompt="Run execute-phase for phase ${PHASE_NUM}: Skill(skill=\"gsd:execute-phase\", args=\"${PHASE_NUM} --no-transition\")"
)
```
Store the agent task_id. The workflow can now start discussing the next phase while this phase executes in the background. Before starting post-execution routing for this phase, wait for the execute agent to complete.
**If `INTERACTIVE` is NOT set (default):** Run execute inline as before.
``` ```
Skill(skill="gsd:execute-phase", args="${PHASE_NUM} --no-transition") Skill(skill="gsd:execute-phase", args="${PHASE_NUM} --no-transition")
``` ```
**3d. Post-Execution Routing** **3d. Post-Execution Routing**
After execute-phase returns, read the verification result: **If `INTERACTIVE` is set:** Wait for the execute agent to complete before reading verification results.
After execute-phase returns (or the execute agent completes), read the verification result:
```bash ```bash
VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ') VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ')
@@ -733,6 +779,13 @@ Check for blockers in the Blockers/Concerns section. If blockers are found, go t
If incomplete phases remain: proceed to next phase, loop back to execute_phase. If incomplete phases remain: proceed to next phase, loop back to execute_phase.
**Interactive mode overlap:** When `INTERACTIVE` is set, the iterate step enables pipeline parallelism:
1. After discuss completes for Phase N, dispatch plan+execute as background agents
2. Immediately start discuss for Phase N+1 (the next incomplete phase) while Phase N builds
3. Before starting plan for Phase N+1, wait for Phase N's execute agent to complete and handle its post-execution routing (verification, gap closure, etc.)
This means the user is always answering discuss questions (lightweight, interactive) while the heavy work (planning, code generation) runs in the background. The main context only accumulates discuss conversations — plan and execute contexts are isolated in their agents.
If all phases complete, proceed to lifecycle step. If all phases complete, proceed to lifecycle step.
</step> </step>
@@ -935,4 +988,10 @@ When any phase operation fails or a blocker is detected, present 3 options via A
- [ ] `--only N` exits cleanly after single phase completes - [ ] `--only N` exits cleanly after single phase completes
- [ ] `--only N` on already-complete phase exits with message - [ ] `--only N` on already-complete phase exits with message
- [ ] `--only N` handle_blocker resume message uses --only flag - [ ] `--only N` handle_blocker resume message uses --only flag
- [ ] `--interactive` runs discuss inline via gsd:discuss-phase (asks questions, waits for user)
- [ ] `--interactive` dispatches plan and execute as background agents (context isolation)
- [ ] `--interactive` enables pipeline parallelism: discuss Phase N+1 while Phase N builds
- [ ] `--interactive` main context only accumulates discuss conversations (lean)
- [ ] `--interactive` waits for background agents before post-execution routing
- [ ] `--interactive` compatible with `--only` and `--from` flags
</success_criteria> </success_criteria>

View File

@@ -537,6 +537,15 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
Extract `branching_strategy`, `phase_branch_template`, `milestone_branch_template`, and `commit_docs` from init JSON. Extract `branching_strategy`, `phase_branch_template`, `milestone_branch_template`, and `commit_docs` from init JSON.
Detect base branch:
```bash
BASE_BRANCH=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get git.base_branch 2>/dev/null || echo "")
if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||')
BASE_BRANCH="${BASE_BRANCH:-main}"
fi
```
**If "none":** Skip to git_tag. **If "none":** Skip to git_tag.
**For "phase" strategy:** **For "phase" strategy:**
@@ -575,7 +584,7 @@ AskUserQuestion with options: Squash merge (Recommended), Merge with history, De
```bash ```bash
CURRENT_BRANCH=$(git branch --show-current) CURRENT_BRANCH=$(git branch --show-current)
git checkout main git checkout ${BASE_BRANCH}
if [ "$BRANCHING_STRATEGY" = "phase" ]; then if [ "$BRANCHING_STRATEGY" = "phase" ]; then
for branch in $PHASE_BRANCHES; do for branch in $PHASE_BRANCHES; do
@@ -604,7 +613,7 @@ git checkout "$CURRENT_BRANCH"
```bash ```bash
CURRENT_BRANCH=$(git branch --show-current) CURRENT_BRANCH=$(git branch --show-current)
git checkout main git checkout ${BASE_BRANCH}
if [ "$BRANCHING_STRATEGY" = "phase" ]; then if [ "$BRANCHING_STRATEGY" = "phase" ]; then
for branch in $PHASE_BRANCHES; do for branch in $PHASE_BRANCHES; do

View File

@@ -76,6 +76,17 @@ USE_WORKTREES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get
When `USE_WORKTREES` is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees. When `USE_WORKTREES` is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees.
Read context window size for adaptive prompt enrichment:
```bash
CONTEXT_WINDOW=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get context_window 2>/dev/null || echo "200000")
```
When `CONTEXT_WINDOW >= 500000` (1M-class models), subagent prompts include richer context:
- Executor agents receive prior wave SUMMARY.md files and the phase CONTEXT.md/RESEARCH.md
- Verifier agents receive all PLAN.md, SUMMARY.md, CONTEXT.md files plus REQUIREMENTS.md
- This enables cross-phase awareness and history-aware verification
**If `phase_found` is false:** Error — phase directory not found. **If `phase_found` is false:** Error — phase directory not found.
**If `plan_count` is 0:** Error — no plans found in phase. **If `plan_count` is 0:** Error — no plans found in phase.
**If `state_exists` is false but `.planning/` exists:** Offer reconstruct or continue. **If `state_exists` is false but `.planning/` exists:** Offer reconstruct or continue.
@@ -236,6 +247,7 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
``` ```
Task( Task(
subagent_type="gsd-executor", subagent_type="gsd-executor",
description="Execute plan {plan_number} of phase {phase_number}",
model="{executor_model}", model="{executor_model}",
isolation="worktree", isolation="worktree",
prompt=" prompt="
@@ -265,6 +277,11 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
- .planning/PROJECT.md (Project context — core value, requirements, evolution rules) - .planning/PROJECT.md (Project context — core value, requirements, evolution rules)
- .planning/STATE.md (State) - .planning/STATE.md (State)
- .planning/config.json (Config, if exists) - .planning/config.json (Config, if exists)
${CONTEXT_WINDOW >= 500000 ? `
- ${phase_dir}/*-CONTEXT.md (User decisions from discuss-phase — honors locked choices)
- ${phase_dir}/*-RESEARCH.md (Technical research — pitfalls and patterns to follow)
- ${prior_wave_summaries} (SUMMARY.md files from earlier waves in this phase — what was already built)
` : ''}
- ./CLAUDE.md (Project instructions, if exists — follow project-specific guidelines and coding conventions) - ./CLAUDE.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) - .claude/skills/ or .agents/skills/ (Project skills, if either exists — list skills, read SKILL.md for each, follow relevant rules during implementation)
</files_to_read> </files_to_read>
@@ -732,6 +749,7 @@ VERIFIER_SKILLS=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-ski
``` ```
Task( Task(
description="Verify phase {phase_number} goal achievement",
prompt="Verify phase {phase_number} goal achievement. prompt="Verify phase {phase_number} goal achievement.
Phase directory: {phase_dir} Phase directory: {phase_dir}
Phase goal: {goal from ROADMAP.md} Phase goal: {goal from ROADMAP.md}
@@ -739,6 +757,18 @@ Phase requirement IDs: {phase_req_ids}
Check must_haves against actual codebase. Check must_haves against actual codebase.
Cross-reference requirement IDs from PLAN frontmatter against REQUIREMENTS.md — every ID MUST be accounted for. Cross-reference requirement IDs from PLAN frontmatter against REQUIREMENTS.md — every ID MUST be accounted for.
Create VERIFICATION.md. Create VERIFICATION.md.
<files_to_read>
Read these files before verification:
- {phase_dir}/*-PLAN.md (All plans — understand intent, check must_haves)
- {phase_dir}/*-SUMMARY.md (All summaries — cross-reference claimed vs actual)
- .planning/REQUIREMENTS.md (Requirement traceability)
${CONTEXT_WINDOW >= 500000 ? `- {phase_dir}/*-CONTEXT.md (User decisions — verify they were honored)
- {phase_dir}/*-RESEARCH.md (Known pitfalls — check for traps)
- Prior VERIFICATION.md files from earlier phases (regression check)
` : ''}
</files_to_read>
${VERIFIER_SKILLS}", ${VERIFIER_SKILLS}",
subagent_type="gsd-verifier", subagent_type="gsd-verifier",
model="{verifier_model}" model="{verifier_model}"

View File

@@ -36,7 +36,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_MAPPER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-codebase-mapper 2>/dev/null) AGENT_SKILLS_MAPPER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-codebase-mapper 2>/dev/null)
``` ```
Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`. Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`, `subagent_timeout`.
</step> </step>
<step name="check_existing"> <step name="check_existing">
@@ -199,9 +199,11 @@ Wait for all 4 agents to complete using TaskOutput tool.
TaskOutput tool: TaskOutput tool:
task_id: "{task_id from Agent result}" task_id: "{task_id from Agent result}"
block: true block: true
timeout: 300000 timeout: {subagent_timeout from init context, default 300000}
``` ```
> The timeout is configurable via `workflow.subagent_timeout` in `.planning/config.json` (milliseconds). Default: 300000 (5 minutes). Increase for large codebases or slower models.
Call TaskOutput for all 4 agents in parallel (single message with 4 TaskOutput calls). Call TaskOutput for all 4 agents in parallel (single message with 4 TaskOutput calls).
Once all TaskOutput calls return, read each agent's output file to collect confirmations. Once all TaskOutput calls return, read each agent's output file to collect confirmations.

View File

@@ -27,8 +27,11 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_RESEARCHER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-researcher 2>/dev/null) AGENT_SKILLS_RESEARCHER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-researcher 2>/dev/null)
AGENT_SKILLS_PLANNER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-planner 2>/dev/null) AGENT_SKILLS_PLANNER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-planner 2>/dev/null)
AGENT_SKILLS_CHECKER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-checker 2>/dev/null) AGENT_SKILLS_CHECKER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-checker 2>/dev/null)
CONTEXT_WINDOW=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get context_window 2>/dev/null || echo "200000")
``` ```
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes prior phase CONTEXT.md files so cross-phase decisions are consistent (e.g., "use library X for all data fetching" from Phase 2 is visible to Phase 5's planner).
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`, `phase_req_ids`. Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`, `phase_req_ids`.
**File paths (for <files_to_read> blocks):** `state_path`, `roadmap_path`, `requirements_path`, `context_path`, `research_path`, `verification_path`, `uat_path`, `reviews_path`. These are null if files don't exist. **File paths (for <files_to_read> blocks):** `state_path`, `roadmap_path`, `requirements_path`, `context_path`, `research_path`, `verification_path`, `uat_path`, `reviews_path`. These are null if files don't exist.
@@ -576,6 +579,11 @@ Planner prompt:
- {uat_path} (UAT Gaps - if --gaps) - {uat_path} (UAT Gaps - if --gaps)
- {reviews_path} (Cross-AI Review Feedback - if --reviews) - {reviews_path} (Cross-AI Review Feedback - if --reviews)
- {UI_SPEC_PATH} (UI Design Contract — visual/interaction specs, if exists) - {UI_SPEC_PATH} (UI Design Contract — visual/interaction specs, if exists)
${CONTEXT_WINDOW >= 500000 ? `
**Cross-phase context (1M model enrichment):**
- Prior phase CONTEXT.md files (locked decisions from earlier phases — maintain consistency)
- Prior phase SUMMARY.md files (what was actually built — reuse patterns, avoid duplication)
` : ''}
</files_to_read> </files_to_read>
${AGENT_SKILLS_PLANNER} ${AGENT_SKILLS_PLANNER}

View File

@@ -19,6 +19,7 @@ command -v gemini >/dev/null 2>&1 && echo "gemini:available" || echo "gemini:mis
command -v claude >/dev/null 2>&1 && echo "claude:available" || echo "claude:missing" command -v claude >/dev/null 2>&1 && echo "claude:available" || echo "claude:missing"
command -v codex >/dev/null 2>&1 && echo "codex:available" || echo "codex:missing" command -v codex >/dev/null 2>&1 && echo "codex:available" || echo "codex:missing"
command -v coderabbit >/dev/null 2>&1 && echo "coderabbit:available" || echo "coderabbit:missing" command -v coderabbit >/dev/null 2>&1 && echo "coderabbit:available" || echo "coderabbit:missing"
command -v opencode >/dev/null 2>&1 && echo "opencode:available" || echo "opencode:missing"
``` ```
Parse flags from `$ARGUMENTS`: Parse flags from `$ARGUMENTS`:
@@ -26,6 +27,7 @@ Parse flags from `$ARGUMENTS`:
- `--claude` → include Claude - `--claude` → include Claude
- `--codex` → include Codex - `--codex` → include Codex
- `--coderabbit` → include CodeRabbit - `--coderabbit` → include CodeRabbit
- `--opencode` → include OpenCode
- `--all` → include all available - `--all` → include all available
- No flags → include all available - No flags → include all available
@@ -35,6 +37,7 @@ No external AI CLIs found. Install at least one:
- gemini: https://github.com/google-gemini/gemini-cli - gemini: https://github.com/google-gemini/gemini-cli
- codex: https://github.com/openai/codex - codex: https://github.com/openai/codex
- claude: https://github.com/anthropics/claude-code - claude: https://github.com/anthropics/claude-code
- opencode: https://opencode.ai (leverages GitHub Copilot subscription models)
Then run /gsd:review again. Then run /gsd:review again.
``` ```
@@ -141,6 +144,14 @@ Note: CodeRabbit reviews the current git diff/working tree — it does not accep
coderabbit review --prompt-only 2>/dev/null > /tmp/gsd-review-coderabbit-{phase}.md coderabbit review --prompt-only 2>/dev/null > /tmp/gsd-review-coderabbit-{phase}.md
``` ```
**OpenCode (via GitHub Copilot):**
```bash
cat /tmp/gsd-review-prompt-{phase}.md | opencode run - 2>/dev/null > /tmp/gsd-review-opencode-{phase}.md
if [ ! -s /tmp/gsd-review-opencode-{phase}.md ]; then
echo "OpenCode review failed or returned empty output." > /tmp/gsd-review-opencode-{phase}.md
fi
```
If a CLI fails, log the error and continue with remaining CLIs. If a CLI fails, log the error and continue with remaining CLIs.
Display progress: Display progress:
@@ -160,7 +171,7 @@ Combine all review responses into `{phase_dir}/{padded_phase}-REVIEWS.md`:
```markdown ```markdown
--- ---
phase: {N} phase: {N}
reviewers: [gemini, claude, codex, coderabbit] reviewers: [gemini, claude, codex, coderabbit, opencode]
reviewed_at: {ISO timestamp} reviewed_at: {ISO timestamp}
plans_reviewed: [{list of PLAN.md files}] plans_reviewed: [{list of PLAN.md files}]
--- ---
@@ -191,6 +202,12 @@ plans_reviewed: [{list of PLAN.md files}]
--- ---
## OpenCode Review
{opencode review content}
---
## Consensus Summary ## Consensus Summary
{synthesize common concerns across all reviewers} {synthesize common concerns across all reviewers}

View File

@@ -24,6 +24,15 @@ CONFIG=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state load)
``` ```
Extract: `branching_strategy`, `branch_name`. Extract: `branching_strategy`, `branch_name`.
Detect base branch for PRs and merges:
```bash
BASE_BRANCH=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get git.base_branch 2>/dev/null || echo "")
if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||')
BASE_BRANCH="${BASE_BRANCH:-main}"
fi
```
</step> </step>
<step name="preflight_checks"> <step name="preflight_checks">
@@ -46,7 +55,7 @@ Verify the work is ready to ship:
```bash ```bash
CURRENT_BRANCH=$(git branch --show-current) CURRENT_BRANCH=$(git branch --show-current)
``` ```
If on `main`/`master`: warn — should be on a feature branch. If on `${BASE_BRANCH}`: warn — should be on a feature branch.
If branching_strategy is `none`: offer to create a branch now. If branching_strategy is `none`: offer to create a branch now.
4. **Remote configured?** 4. **Remote configured?**
@@ -74,7 +83,7 @@ If push fails (e.g., no upstream): set upstream:
git push --set-upstream origin ${CURRENT_BRANCH} 2>&1 git push --set-upstream origin ${CURRENT_BRANCH} 2>&1
``` ```
Report: "Pushed `{branch}` to origin ({commit_count} commits ahead of main)" Report: "Pushed `{branch}` to origin ({commit_count} commits ahead of ${BASE_BRANCH})"
</step> </step>
<step name="generate_pr_body"> <step name="generate_pr_body">
@@ -141,7 +150,7 @@ Create the PR using the generated body:
gh pr create \ gh pr create \
--title "Phase ${PHASE_NUMBER}: ${PHASE_NAME}" \ --title "Phase ${PHASE_NUMBER}: ${PHASE_NAME}" \
--body "${PR_BODY}" \ --body "${PR_BODY}" \
--base main --base ${BASE_BRANCH}
``` ```
If `--draft` flag was passed: add `--draft`. If `--draft` flag was passed: add `--draft`.
@@ -194,7 +203,7 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${padded_phase
## ✓ Phase {X}: {Name} — Shipped ## ✓ Phase {X}: {Name} — Shipped
PR: #{number} ({url}) PR: #{number} ({url})
Branch: {branch} → main Branch: {branch} → ${BASE_BRANCH}
Commits: {count} Commits: {count}
Verification: ✓ Passed Verification: ✓ Passed
Requirements: {N} REQ-IDs addressed Requirements: {N} REQ-IDs addressed

27
hooks/gsd-phase-boundary.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# gsd-phase-boundary.sh — PostToolUse hook: detect .planning/ file writes
# Outputs a reminder when planning files are modified outside normal workflow.
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
#
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
# Enable with: "hooks": { "community": true } in .planning/config.json
# Check opt-in config — exit silently if not enabled
if [ -f .planning/config.json ]; then
ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null)
if [ "$ENABLED" != "1" ]; then exit 0; fi
else
exit 0
fi
INPUT=$(cat)
# Extract file_path from JSON using Node (handles escaping correctly)
FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{}})" 2>/dev/null)
if [[ "$FILE" == *.planning/* ]] || [[ "$FILE" == .planning/* ]]; then
echo ".planning/ file modified: $FILE"
echo "Check: Should STATE.md be updated to reflect this change?"
fi
exit 0

33
hooks/gsd-session-state.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# gsd-session-state.sh — SessionStart hook: inject project state reminder
# Outputs STATE.md head on every session start for orientation.
#
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
# Enable with: "hooks": { "community": true } in .planning/config.json
# Check opt-in config — exit silently if not enabled
if [ -f .planning/config.json ]; then
ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null)
if [ "$ENABLED" != "1" ]; then exit 0; fi
else
exit 0
fi
echo '## Project State Reminder'
echo ''
if [ -f .planning/STATE.md ]; then
echo 'STATE.md exists - check for blockers and current phase.'
head -20 .planning/STATE.md
else
echo 'No .planning/ found - suggest /gsd:new-project if starting new work.'
fi
echo ''
if [ -f .planning/config.json ]; then
MODE=$(grep -o '"mode"[[:space:]]*:[[:space:]]*"[^"]*"' .planning/config.json 2>/dev/null || echo '"mode": "unknown"')
echo "Config: $MODE"
fi
exit 0

47
hooks/gsd-validate-commit.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
# Blocks git commit commands with non-conforming messages (exit 2).
# Allows conforming messages and all non-commit commands (exit 0).
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
#
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
# Enable with: "hooks": { "community": true } in .planning/config.json
# Check opt-in config — exit silently if not enabled
if [ -f .planning/config.json ]; then
ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null)
if [ "$ENABLED" != "1" ]; then exit 0; fi
else
exit 0
fi
INPUT=$(cat)
# Extract command from JSON using Node (handles escaping correctly, no jq needed)
CMD=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{}})" 2>/dev/null)
# Only check git commit commands
if [[ "$CMD" =~ ^git[[:space:]]+commit ]]; then
# Extract message from -m flag
MSG=""
if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
MSG="${BASH_REMATCH[1]}"
elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then
MSG="${BASH_REMATCH[1]}"
fi
if [ -n "$MSG" ]; then
SUBJECT=$(echo "$MSG" | head -1)
# Validate Conventional Commits format
if ! [[ "$SUBJECT" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?:[[:space:]].+ ]]; then
echo '{"decision": "block", "reason": "Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}'
exit 2
fi
if [ ${#SUBJECT} -gt 72 ]; then
echo '{"decision": "block", "reason": "Commit subject must be 72 characters or less."}'
exit 2
fi
fi
fi
exit 0

View File

@@ -0,0 +1,171 @@
/**
* Augment conversion regression tests.
*
* Ensures Augment frontmatter names are emitted as plain identifiers
* (without surrounding quotes), so Augment does not treat quotes as
* literal parts of skill/subagent names.
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert');
const {
convertClaudeCommandToAugmentSkill,
convertClaudeAgentToAugmentAgent,
convertClaudeToAugmentMarkdown,
} = require('../bin/install.js');
describe('convertClaudeCommandToAugmentSkill', () => {
test('writes unquoted Augment skill name in frontmatter', () => {
const input = `---
name: quick
description: Execute a quick task
---
<objective>
Test body
</objective>
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-quick');
const nameMatch = result.match(/^name:\s*(.+)$/m);
assert.ok(nameMatch, 'frontmatter contains name field');
assert.strictEqual(nameMatch[1], 'gsd-quick', 'skill name is plain scalar');
assert.ok(!result.includes('name: "gsd-quick"'), 'quoted skill name is not emitted');
});
test('preserves slash for slash commands in markdown body', () => {
const input = `---
name: gsd:plan-phase
description: Plan a phase
---
Next:
/gsd:execute-phase 17
/gsd-help
gsd:progress
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-plan-phase');
// Slash commands: /gsd:execute-phase -> /gsd-execute-phase
assert.ok(result.includes('/gsd-execute-phase 17'), 'slash command gsd: -> gsd-');
assert.ok(result.includes('/gsd-help'), '/gsd-help preserved');
assert.ok(result.includes('gsd-progress'), 'bare gsd: -> gsd-');
});
test('includes augment_skill_adapter block', () => {
const input = `---
name: test
description: A test skill
---
Body content.
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test');
assert.ok(result.includes('<augment_skill_adapter>'), 'adapter header present');
assert.ok(result.includes('</augment_skill_adapter>'), 'adapter footer present');
assert.ok(result.includes('launch-process'), 'launch-process tool mentioned');
assert.ok(result.includes('str-replace-editor'), 'str-replace-editor tool mentioned');
});
test('converts tool names to Augment format', () => {
const input = `---
name: test
description: Test
---
Use Bash() to run commands.
Use Edit() to modify files.
Use Read() to view files.
`;
const result = convertClaudeCommandToAugmentSkill(input, 'gsd-test');
assert.ok(result.includes('launch-process('), 'Bash converted to launch-process');
assert.ok(result.includes('str-replace-editor('), 'Edit converted to str-replace-editor');
assert.ok(result.includes('view('), 'Read converted to view');
});
});
describe('convertClaudeAgentToAugmentAgent', () => {
test('converts agent frontmatter with unquoted name', () => {
const input = `---
name: gsd-bugfix
description: "Fix bugs automatically"
tools: Read, Write, Edit, Bash
color: green
---
<role>
You are a bug fixer.
</role>
`;
const result = convertClaudeAgentToAugmentAgent(input);
const nameMatch = result.match(/^name:\s*(.+)$/m);
assert.ok(nameMatch, 'frontmatter contains name field');
assert.strictEqual(nameMatch[1], 'gsd-bugfix', 'agent name is plain scalar');
assert.ok(!result.includes('name: "gsd-bugfix"'), 'quoted agent name is not emitted');
});
test('removes color and skills from frontmatter', () => {
const input = `---
name: gsd-test
description: Test agent
color: blue
skills:
- some-skill
tools: Read, Write
---
<role>
Test role
</role>
`;
const result = convertClaudeAgentToAugmentAgent(input);
assert.ok(result.includes('name: gsd-test'), 'name preserved');
assert.ok(result.includes('description:'), 'description preserved');
assert.ok(!result.includes('color:'), 'color removed');
assert.ok(!result.includes('skills:'), 'skills removed');
});
test('replaces CLAUDE.md with .augment/rules/', () => {
const input = `---
name: gsd-test
description: Test
---
See CLAUDE.md for details.
`;
const result = convertClaudeAgentToAugmentAgent(input);
assert.ok(result.includes('.augment/rules/'), 'CLAUDE.md replaced with .augment/rules/');
assert.ok(!result.includes('CLAUDE.md'), 'CLAUDE.md reference removed');
});
});
describe('convertClaudeToAugmentMarkdown', () => {
test('replaces Claude Code with Augment', () => {
const input = 'Use Claude Code tools for this task.';
const result = convertClaudeToAugmentMarkdown(input);
assert.strictEqual(result, 'Use Augment tools for this task.');
});
test('replaces .claude/skills/ with .augment/skills/', () => {
const input = 'Check .claude/skills/ for more info.';
const result = convertClaudeToAugmentMarkdown(input);
assert.strictEqual(result, 'Check .augment/skills/ for more info.');
});
test('normalizes gsd: to gsd-', () => {
const input = 'Run /gsd:new-project for new projects.';
const result = convertClaudeToAugmentMarkdown(input);
assert.strictEqual(result, 'Run /gsd-new-project for new projects.');
});
});

View File

@@ -0,0 +1,84 @@
/**
* GSD Tools Tests - autonomous --interactive flag
*
* Validates that the autonomous workflow and command definition
* correctly document and support the --interactive flag.
*
* Closes: #1413
*/
const { test, describe } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
describe('autonomous --interactive flag (#1413)', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'autonomous.md');
const commandPath = path.join(__dirname, '..', 'commands', 'gsd', 'autonomous.md');
test('command definition includes --interactive in argument-hint', () => {
const content = fs.readFileSync(commandPath, 'utf8');
assert.ok(content.includes('--interactive'), 'command should document --interactive flag');
assert.ok(content.includes('argument-hint:') && content.includes('--interactive'),
'argument-hint should include --interactive');
});
test('command definition describes interactive mode behavior', () => {
const content = fs.readFileSync(commandPath, 'utf8');
assert.ok(content.includes('discuss') && content.includes('inline'),
'command should describe discuss running inline');
assert.ok(content.includes('background'),
'command should mention background agents for plan+execute');
});
test('workflow parses --interactive flag', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes("--interactive") && content.includes('INTERACTIVE'),
'workflow should parse --interactive into INTERACTIVE variable');
});
test('workflow uses discuss-phase skill in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('gsd:discuss-phase') && content.includes('INTERACTIVE'),
'workflow should invoke gsd:discuss-phase when INTERACTIVE is set'
);
});
test('workflow dispatches plan as background agent in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
// Should have Agent() with run_in_background for plan
assert.ok(
content.includes('run_in_background') && content.includes('plan-phase'),
'workflow should dispatch plan-phase as background agent in interactive mode'
);
});
test('workflow dispatches execute as background agent in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('run_in_background') && content.includes('execute-phase'),
'workflow should dispatch execute-phase as background agent in interactive mode'
);
});
test('workflow describes pipeline parallelism in interactive mode', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('pipeline parallelism') || content.includes('Phase N+1'),
'workflow should describe overlapping discuss/execute between phases'
);
});
test('success criteria include --interactive requirements', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
const criteriaMatch = content.match(/<success_criteria>([\s\S]*?)<\/success_criteria>/);
const criteria = criteriaMatch ? criteriaMatch[1] : '';
assert.ok(criteria.includes('--interactive'),
'success criteria should include --interactive requirements');
assert.ok(criteria.includes('discuss inline'),
'success criteria should mention discuss inline');
assert.ok(criteria.includes('background agents'),
'success criteria should mention background agents');
});
});

View File

@@ -1694,3 +1694,60 @@ describe('stats command', () => {
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification'); assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
}); });
}); });
// ─────────────────────────────────────────────────────────────────────────────
// check-commit command (#1395)
// ─────────────────────────────────────────────────────────────────────────────
describe('check-commit command', () => {
const { createTempGitProject } = require('./helpers.cjs');
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('allows commit when commit_docs is true', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: true })
);
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
// Stage a non-planning file
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.allowed, true);
});
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ commit_docs: false })
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('check-commit', tmpDir);
assert.ok(!result.success, 'should block commit');
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
});
});

View File

@@ -0,0 +1,819 @@
/**
* GSD Tools Tests - Concurrency Safety
*
* Tests for fix/concurrency-safety-1473a:
* - Planning lock integration (withPlanningLock in phase/roadmap operations)
* - readModifyWriteStateMd (atomic state updates)
* - normalizeMd behavioral equivalence (O(n) insideFence rewrite)
* - Warnings (frontmatter parse warning, stateReplaceFieldWithFallback)
* - Performance benchmarks (normalizeMd O(n) verification)
* - Snapshot tests for normalizeMd (regression detection)
* - Multi-process concurrent write tests
* - Stress tests at scale (50+ phases)
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, exec } = require('child_process');
const { promisify } = require('util');
const { performance } = require('perf_hooks');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const {
normalizeMd,
} = require('../get-shit-done/bin/lib/core.cjs');
const execAsync = promisify(exec);
// ─── Helpers ────────────────────────────────────────────────────────────────
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n${lines}\n`
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
`# Project\n\n${content}`
);
}
function writeValidConfigJson(tmpDir, overrides = {}) {
const base = { model_profile: 'balanced', commit_docs: true };
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ ...base, ...overrides }, null, 2)
);
}
/**
* Generate a 50-phase project structure for stress testing.
*/
function create50PhaseProject(tmpDir, completedCount = 25) {
let roadmapContent = '# Roadmap v1.0\n\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
}
roadmapContent += '\n';
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
roadmapContent += `### Phase ${i}: Feature ${i}\n\n`;
roadmapContent += `**Goal:** Build feature ${i}\n`;
roadmapContent += `**Requirements:** REQ-${pad}\n`;
roadmapContent += `**Plans:** 1 plans\n\n`;
roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`;
}
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
roadmapContent
);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
const dirName = `${pad}-feature-${i}`;
const phaseDir = path.join(phasesDir, dirName);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, `${pad}-01-PLAN.md`),
`# Phase ${i} Plan 1\n\nBuild feature ${i}.\n`
);
if (i <= completedCount) {
fs.writeFileSync(
path.join(phaseDir, `${pad}-01-SUMMARY.md`),
`# Phase ${i} Plan 1 Summary\n\nFeature ${i} completed.\n`
);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Planning lock integration
// ─────────────────────────────────────────────────────────────────────────────
describe('planning lock integration', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('phase add creates and releases .planning/.lock during ROADMAP write', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
);
const result = runGsdTools('phase add Testing', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase add');
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
});
test('phase complete creates and releases .planning/.lock', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase complete');
const output = JSON.parse(result.output);
assert.strictEqual(output.completed_phase, '1', 'phase should be completed');
});
test('roadmap update-plan-progress creates and releases .planning/.lock', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n| Phase | Plans | Status | Updated |\n|-------|-------|--------|---------|\n| 1 | 0/0 | Not started | - |\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after roadmap update');
});
test('lock file does NOT persist after successful phase operations', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n`
);
runGsdTools('phase add First Phase', tmpDir);
runGsdTools('phase add Second Phase', tmpDir);
const lockPath = path.join(tmpDir, '.planning', '.lock');
assert.ok(!fs.existsSync(lockPath), '.lock file should not persist after multiple operations');
});
test('phase add still works correctly with lock (behavioral regression)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n\n---\n`
);
const result = runGsdTools('phase add User Dashboard', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
assert.strictEqual(output.slug, 'user-dashboard');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
'directory should be created'
);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. readModifyWriteStateMd (tested via CLI commands that use it)
// ─────────────────────────────────────────────────────────────────────────────
describe('readModifyWriteStateMd (via state patch)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('transforms content atomically (read + modify + write under lock)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 03\n**Status:** Planning\n**Current Plan:** 03-01\n`
);
const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 03-02', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('**Status:** In progress'), 'Status should be updated');
assert.ok(content.includes('03-02'), 'Current Plan should be updated');
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should be released after patch');
});
test('lock file cleaned up after state patch operation', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Ready\n`
);
runGsdTools('state patch --Status "In progress"', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after operation');
});
test('state patch still works correctly via readModifyWriteStateMd path (behavioral regression)', () => {
const stateMd = [
'# Project State',
'',
'**Current Phase:** 03',
'**Status:** Planning',
'**Current Plan:** 03-01',
'**Last Activity:** 2024-01-15',
].join('\n') + '\n';
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
});
test('two sequential state patches both persist (patch A then patch B)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n**Last Activity:** 2024-01-01\n`
);
const resultA = runGsdTools('state patch --Status "In progress"', tmpDir);
assert.ok(resultA.success, `Patch A failed: ${resultA.error}`);
const resultB = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
assert.ok(resultB.success, `Patch B failed: ${resultB.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('**Status:** In progress'), 'Patch A (Status) should persist');
assert.ok(content.includes('01-02'), 'Patch B (Current Plan) should persist');
assert.ok(content.includes('**Last Activity:** 2024-01-01'), 'Untouched field should be preserved');
});
test('lock file does not persist after rapid sequential patches', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n`
);
runGsdTools('state patch --Status "In progress"', tmpDir);
runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
runGsdTools('state patch --Status Complete', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after rapid sequential patches');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Multi-process concurrent write tests
// ─────────────────────────────────────────────────────────────────────────────
describe('multi-process concurrent write tests', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('two concurrent state patches to DIFFERENT fields both persist', async () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** In progress',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working',
'',
].join('\n')
);
const toolsPath = TOOLS_PATH;
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
const [resultA, resultB] = await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(e => e),
execAsync(cmdB, { encoding: 'utf-8' }).catch(e => e),
]);
const aOk = !(resultA instanceof Error);
const bOk = !(resultB instanceof Error);
assert.ok(aOk || bOk, 'At least one concurrent patch should succeed');
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
content.includes('Complete') || content.includes('01-02'),
`At least one concurrent patch should persist in STATE.md. Content:\n${content}`
);
if (content.includes('Complete') && content.includes('01-02')) {
assert.ok(true, 'Both concurrent patches persisted (lock serialization)');
}
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field Current Phase should survive');
assert.ok(content.includes('2025-01-01'), 'Untouched field Last Activity should survive');
});
test('lock file does not persist after concurrent operations', async () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'**Current Plan:** 01-01',
'',
].join('\n')
);
const toolsPath = TOOLS_PATH;
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(
!fs.existsSync(lockPath),
'STATE.md.lock should not persist after concurrent operations complete'
);
});
test('three rapid sequential patches all persist', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'**Current Plan:** 01-01',
'**Last Activity:** 2025-01-01',
'',
].join('\n')
);
const r1 = runGsdTools('state patch --Status "In progress"', tmpDir);
assert.ok(r1.success, `Patch 1 failed: ${r1.error}`);
const r2 = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
assert.ok(r2.success, `Patch 2 failed: ${r2.error}`);
const r3 = runGsdTools('state patch --"Last Activity" 2025-06-15', tmpDir);
assert.ok(r3.success, `Patch 3 failed: ${r3.error}`);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(content.includes('In progress'), 'Patch 1 (Status) should persist');
assert.ok(content.includes('01-02'), 'Patch 2 (Current Plan) should persist');
assert.ok(content.includes('2025-06-15'), 'Patch 3 (Last Activity) should persist');
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field should be preserved');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. normalizeMd behavioral equivalence (O(n) insideFence rewrite)
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd behavioral equivalence', () => {
test('simple markdown with headings and paragraphs', () => {
const input = '# Title\nSome text.\n## Section\nMore text.\n';
const result = normalizeMd(input);
assert.ok(result.includes('# Title\n\nSome text.'), 'title heading should have blank line after');
assert.ok(result.includes('\n\n## Section\n\nMore text.'), 'section heading should have blank lines around it');
assert.ok(result.endsWith('\n'), 'should end with newline');
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
});
test('single fenced code block gets blank lines before/after', () => {
const input = 'Some text\n```js\nconst x = 1;\n```\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before');
assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after');
assert.ok(result.includes('const x = 1;'), 'code content should be preserved');
});
test('multiple fenced code blocks', () => {
const input = 'Intro\n```js\nfoo();\n```\nMiddle\n```py\nbar()\n```\nEnd\n';
const result = normalizeMd(input);
assert.ok(result.includes('Intro\n\n```js'), 'first code block should have blank line before');
assert.ok(result.includes('```\n\nMiddle'), 'first code block should have blank line after');
assert.ok(result.includes('Middle\n\n```py'), 'second code block should have blank line before');
assert.ok(result.includes('```\n\nEnd'), 'second code block should have blank line after');
});
test('unclosed fence at end of file (edge case)', () => {
const input = 'Some text\n```js\nconst x = 1;\n';
const result = normalizeMd(input);
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.includes('```js'), 'fence opener should be preserved');
assert.ok(result.includes('const x = 1;'), 'content after unclosed fence should be preserved');
assert.ok(result.endsWith('\n'), 'should end with newline');
});
test('empty string input', () => {
assert.strictEqual(normalizeMd(''), '', 'empty string should return empty string');
});
test('mixed headings + lists + fences (complex case)', () => {
const input = [
'# Title',
'## Section One',
'Paragraph text.',
'- item 1',
'- item 2',
'## Section Two',
'```bash',
'echo hello',
'```',
'After code.',
'## Section Three',
'1. First',
'2. Second',
'Done.',
].join('\n') + '\n';
const result = normalizeMd(input);
assert.ok(result.includes('\n\n## Section One\n\n'), 'Section One heading needs blank lines');
assert.ok(result.includes('\n\n## Section Two\n\n'), 'Section Two heading needs blank lines');
assert.ok(result.includes('\n\n## Section Three\n\n'), 'Section Three heading needs blank lines');
assert.ok(result.includes('Paragraph text.\n\n- item 1'), 'list should have blank line before');
assert.ok(result.includes('\n\n```bash'), 'code block should have blank line before');
assert.ok(result.includes('```\n\nAfter code.'), 'code block should have blank line after');
assert.ok(result.includes('echo hello'), 'code content should be preserved');
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. normalizeMd performance benchmark
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd performance benchmark', () => {
test('processes a 100-line markdown file in under 50ms', () => {
const lines = [];
for (let i = 0; i < 100; i++) {
if (i % 20 === 0) {
lines.push(`## Section ${i / 20 + 1}`);
} else if (i % 30 === 0) {
lines.push('```js');
lines.push(`const x${i} = ${i};`);
lines.push('```');
} else if (i % 5 === 0) {
lines.push(`- List item ${i}`);
} else {
lines.push(`Paragraph text line ${i} with some content to process.`);
}
}
const input = lines.join('\n') + '\n';
const start = performance.now();
const result = normalizeMd(input);
const elapsed = performance.now() - start;
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.length > 0, 'result should not be empty');
assert.ok(result.endsWith('\n'), 'result should end with newline');
assert.ok(elapsed < 50, `100-line file should process in under 50ms, took ${elapsed.toFixed(2)}ms`);
});
test('processes a 1000-line markdown file with 20 code blocks in under 200ms', () => {
const lines = [];
let codeBlockCount = 0;
for (let i = 0; i < 1000; i++) {
if (i % 50 === 0 && codeBlockCount < 20) {
lines.push(`## Section ${codeBlockCount + 1}`);
lines.push('');
lines.push('Some introductory text for this section.');
lines.push('');
lines.push('```python');
for (let j = 0; j < 5; j++) {
lines.push(` result_${codeBlockCount}_${j} = compute(${j})`);
}
lines.push('```');
lines.push('');
lines.push('Explanation of the code above.');
codeBlockCount++;
} else if (i % 10 === 0) {
lines.push(`### Subsection at line ${i}`);
} else if (i % 7 === 0) {
lines.push(`- Item ${i}: description of this list item`);
} else if (i % 13 === 0) {
lines.push(`1. Ordered item ${i}`);
} else {
lines.push(`Line ${i}: Regular paragraph content with various markdown elements.`);
}
}
const input = lines.join('\n') + '\n';
normalizeMd(input); // warm up JIT
const start = performance.now();
const result = normalizeMd(input);
const elapsed = performance.now() - start;
assert.ok(typeof result === 'string', 'should return a string');
assert.ok(result.length > 0, 'result should not be empty');
assert.ok(result.endsWith('\n'), 'result should end with newline');
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
assert.ok(elapsed < 200, `1000-line file with 20 code blocks should process in under 200ms, took ${elapsed.toFixed(2)}ms`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. normalizeMd snapshot tests
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeMd snapshot tests', () => {
test('snapshot - heading spacing', () => {
const input = '# Title\nParagraph\n## Section\nMore text';
const expected = '# Title\n\nParagraph\n\n## Section\n\nMore text\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Heading spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - code block spacing', () => {
const input = 'Text before\n```js\nconst x = 1;\n```\nText after\n';
const expected = 'Text before\n\n```js\nconst x = 1;\n```\n\nText after\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Code block spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - list spacing', () => {
const input = 'Paragraph\n- item 1\n- item 2\nAnother paragraph';
const expected = 'Paragraph\n\n- item 1\n- item 2\n\nAnother paragraph\n';
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`List spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
test('snapshot - complex mixed document', () => {
const input = [
'# Main Title',
'Intro paragraph.',
'## Section One',
'Some text here.',
'```js',
'const a = 1;',
'```',
'- first item',
'- second item',
'## Section Two',
'Final text.',
].join('\n');
const expected = [
'# Main Title',
'',
'Intro paragraph.',
'',
'## Section One',
'',
'Some text here.',
'',
'```js',
'const a = 1;',
'```',
'',
'- first item',
'- second item',
'',
'## Section Two',
'',
'Final text.',
'',
].join('\n');
const result = normalizeMd(input);
assert.strictEqual(result, expected,
`Complex mixed document snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Warnings (frontmatter parse, state field miss)
// ─────────────────────────────────────────────────────────────────────────────
describe('warnings', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('must_haves parse warning fires for block with content but 0 items', () => {
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(planDir, { recursive: true });
fs.writeFileSync(
path.join(planDir, '01-01-PLAN.md'),
`---
phase: "01"
plan: "01"
must_haves:
acceptance:
bare content without dash prefix
another line without dash prefix
---
# Plan 01-01
`
);
const result = runGsdTools(
['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), 'must_haves'],
tmpDir
);
const stderr = result.error || '';
assert.ok(
stderr.includes('WARNING') && stderr.includes('must_haves') ||
result.output.includes('acceptance'),
`Expected WARNING about must_haves parse or valid parse result. stderr: ${stderr}, stdout: ${result.output}`
);
});
test('stateReplaceFieldWithFallback logs warning on miss', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 8. Malformed input resilience
// ─────────────────────────────────────────────────────────────────────────────
describe('malformed input resilience', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE.md with invalid bold format -- state patch returns gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Project State\n\n**Current Phase: 01\n**Status:** Planning\n'
);
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
const didNotCrash = result.success || (result.output !== undefined);
assert.ok(didNotCrash, `state patch should not crash on malformed bold format: ${result.error}`);
if (result.success) {
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
content.includes('In progress'),
'Status field (with valid bold format) should be updated'
);
}
});
test('STATE.md with only frontmatter, no body -- state patch handles gracefully', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nphase: "01"\n---\n'
);
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
const didNotCrash = result.success || (result.output !== undefined);
assert.ok(didNotCrash, `state patch should not crash on frontmatter-only STATE.md: ${result.error}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 9. Stress tests with 50+ phases
// ─────────────────────────────────────────────────────────────────────────────
describe('stress tests with 50+ phases', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('roadmap analyze on 50-phase ROADMAP completes in under 2000ms', () => {
create50PhaseProject(tmpDir, 25);
const start = performance.now();
const result = runGsdTools('roadmap analyze', tmpDir);
const elapsed = performance.now() - start;
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
assert.ok(elapsed < 2000, `Should complete in under 2000ms, took ${elapsed.toFixed(0)}ms`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.phases), 'Output should contain a phases array');
assert.strictEqual(output.phases.length, 50, `Should have 50 phases, got ${output.phases.length}`);
const completedPhases = output.phases.filter(p => p.disk_status === 'complete');
assert.strictEqual(completedPhases.length, 25, `Should have 25 complete phases, got ${completedPhases.length}`);
});
test('phase complete on phase 26 of 50-phase project works correctly', () => {
create50PhaseProject(tmpDir, 25);
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** In progress\n');
const phase26Dir = path.join(tmpDir, '.planning', 'phases', '26-feature-26');
fs.writeFileSync(
path.join(phase26Dir, '26-01-SUMMARY.md'),
'# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n'
);
const result = runGsdTools('phase complete 26', tmpDir);
assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`);
const roadmapContent = fs.readFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'utf-8'
);
const phase26Checkbox = roadmapContent.match(/-\s*\[(x| )\]\s*.*Phase\s+26/i);
assert.ok(phase26Checkbox, 'Should find Phase 26 checkbox in ROADMAP');
assert.strictEqual(phase26Checkbox[1], 'x', 'Phase 26 should now be marked as complete [x]');
});
});

View File

@@ -242,6 +242,16 @@ describe('config-set command', () => {
assert.strictEqual(config.workflow.use_worktrees, false); assert.strictEqual(config.workflow.use_worktrees, false);
}); });
test('sets git.base_branch for non-main default branches', () => {
writeConfig(tmpDir, {});
const result = runGsdTools('config-set git.base_branch master', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual(config.git.base_branch, 'master');
});
test('errors when no key path provided', () => { test('errors when no key path provided', () => {
const result = runGsdTools('config-set', tmpDir); const result = runGsdTools('config-set', tmpDir);
assert.strictEqual(result.success, false); assert.strictEqual(result.success, false);
@@ -329,6 +339,27 @@ describe('config-get command', () => {
}); });
}); });
test('gets git.base_branch after it is set', () => {
runGsdTools('config-set git.base_branch master', tmpDir);
const result = runGsdTools('config-get git.base_branch', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 'master');
});
test('errors for git.base_branch when not explicitly set', () => {
// Default config from config-ensure-section does not include git.base_branch,
// so config-get should return "Key not found" — this triggers auto-detect
// fallback in the workflow (origin/HEAD detection).
const result = runGsdTools('config-get git.base_branch', tmpDir);
assert.strictEqual(result.success, false);
assert.ok(
result.error.includes('Key not found'),
`Expected "Key not found" in error: ${result.error}`
);
});
test('errors when no key path provided', () => { test('errors when no key path provided', () => {
const result = runGsdTools('config-get', tmpDir); const result = runGsdTools('config-get', tmpDir);
assert.strictEqual(result.success, false); assert.strictEqual(result.success, false);

View File

@@ -0,0 +1,145 @@
/**
* GSD Tools Tests - Adaptive Context Enrichment for 1M Models
*
* Tests for feat/1m-context-enrichment-1473b:
* - Workflow template syntax validation (CONTEXT_WINDOW conditionals)
* - execute-phase.md enrichment blocks (executor + verifier)
* - plan-phase.md cross-phase context gating
*/
const { test, describe } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
// ─────────────────────────────────────────────────────────────────────────────
// Workflow template syntax validation
// ─────────────────────────────────────────────────────────────────────────────
describe('execute-phase.md context enrichment', () => {
const EXECUTE_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-phase.md');
test('contains CONTEXT_WINDOW config-get command', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('CONTEXT_WINDOW'),
'execute-phase.md should reference CONTEXT_WINDOW variable'
);
assert.ok(
content.includes('config-get context_window'),
'execute-phase.md should read context_window via config-get'
);
assert.ok(
content.includes('|| echo "200000"'),
'execute-phase.md should default CONTEXT_WINDOW to 200000'
);
});
test('contains conditional prior_wave_summaries in executor prompt', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('CONTEXT_WINDOW >= 500000'),
'execute-phase.md should gate enrichment on CONTEXT_WINDOW >= 500000'
);
assert.ok(
content.includes('prior_wave_summaries'),
'execute-phase.md should include prior_wave_summaries in enrichment block'
);
assert.ok(
content.includes('CONTEXT.md'),
'execute-phase.md should reference CONTEXT.md in conditional enrichment'
);
assert.ok(
content.includes('RESEARCH.md'),
'execute-phase.md should reference RESEARCH.md in conditional enrichment'
);
});
test('verifier prompt includes files_to_read block', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('<files_to_read>'),
'execute-phase.md should contain <files_to_read> opening tag'
);
assert.ok(
content.includes('</files_to_read>'),
'execute-phase.md should contain </files_to_read> closing tag'
);
const verifierSection = content.substring(content.lastIndexOf('<files_to_read>'));
assert.ok(
verifierSection.includes('PLAN.md'),
'verifier files_to_read should reference PLAN.md'
);
assert.ok(
verifierSection.includes('SUMMARY.md'),
'verifier files_to_read should reference SUMMARY.md'
);
assert.ok(
verifierSection.includes('REQUIREMENTS.md'),
'verifier files_to_read should reference REQUIREMENTS.md'
);
});
test('executor enrichment block includes CONTEXT.md and RESEARCH.md for 1M models', () => {
const content = fs.readFileSync(EXECUTE_WORKFLOW_PATH, 'utf-8');
// Find the executor section's enrichment block
const executorIdx = content.indexOf('CONTEXT_WINDOW >= 500000');
assert.ok(executorIdx > -1, 'Should find CONTEXT_WINDOW >= 500000 conditional');
// Extract ~500 chars after the conditional to check what's included
const enrichmentBlock = content.substring(executorIdx, executorIdx + 500);
assert.ok(
enrichmentBlock.includes('CONTEXT.md'),
'executor enrichment should include CONTEXT.md'
);
assert.ok(
enrichmentBlock.includes('RESEARCH.md'),
'executor enrichment should include RESEARCH.md'
);
});
});
describe('plan-phase.md context enrichment', () => {
const PLAN_WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-phase.md');
test('contains CONTEXT_WINDOW conditional for prior CONTEXT.md', () => {
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('CONTEXT_WINDOW'),
'plan-phase.md should reference CONTEXT_WINDOW variable'
);
assert.ok(
content.includes('config-get context_window'),
'plan-phase.md should read context_window via config-get'
);
assert.ok(
content.includes('CONTEXT_WINDOW >= 500000'),
'plan-phase.md should gate cross-phase context on CONTEXT_WINDOW >= 500000'
);
assert.ok(
content.includes('CONTEXT.md'),
'plan-phase.md should reference CONTEXT.md in cross-phase enrichment'
);
});
test('enrichment block mentions cross-phase decision consistency', () => {
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
// The enrichment should explain why prior context matters
assert.ok(
content.includes('cross-phase') || content.includes('Cross-phase'),
'plan-phase.md should mention cross-phase context'
);
assert.ok(
content.includes('SUMMARY.md'),
'plan-phase.md should reference prior SUMMARY.md files'
);
});
test('default CONTEXT_WINDOW fallback is 200000', () => {
const content = fs.readFileSync(PLAN_WORKFLOW_PATH, 'utf-8');
assert.ok(
content.includes('|| echo "200000"'),
'plan-phase.md should default CONTEXT_WINDOW to 200000'
);
});
});

View File

@@ -659,7 +659,7 @@ describe('copyCommandsAsCopilotSkills', () => {
assert.ok(skillContent.includes('description: Run all remaining phases autonomously'), assert.ok(skillContent.includes('description: Run all remaining phases autonomously'),
'description preserved'); 'description preserved');
// argument-hint present and double-quoted // argument-hint present and double-quoted
assert.ok(skillContent.includes('argument-hint: "[--from N]"'), 'argument-hint present and quoted'); assert.ok(skillContent.includes('argument-hint: "[--from N] [--only N] [--interactive]"'), 'argument-hint present and quoted');
// allowed-tools comma-separated // allowed-tools comma-separated
assert.ok(skillContent.includes('allowed-tools: Read, Write, Bash, Glob, Grep, AskUserQuestion, Task'), assert.ok(skillContent.includes('allowed-tools: Read, Write, Bash, Glob, Grep, AskUserQuestion, Task'),
'allowed-tools is comma-separated'); 'allowed-tools is comma-separated');
@@ -1349,3 +1349,89 @@ describe('E2E: Copilot uninstall verification', () => {
}); });
}); });
}); });
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
function runClaudeInstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
function runClaudeUninstall(cwd) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--uninstall'], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
}
describe('Claude uninstall preserves user-generated files (#1423)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-preserve-'));
runClaudeInstall(tmpDir);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('preserves USER-PROFILE.md across uninstall', () => {
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
const content = '# Developer Profile\n\nAutonomy: High\nGenerated: 2026-03-29\n';
fs.writeFileSync(profilePath, content);
runClaudeUninstall(tmpDir);
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive uninstall');
assert.strictEqual(fs.readFileSync(profilePath, 'utf-8'), content, 'content should be identical');
});
test('preserves dev-preferences.md across uninstall', () => {
const prefsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
fs.mkdirSync(prefsDir, { recursive: true });
const prefsPath = path.join(prefsDir, 'dev-preferences.md');
const content = '---\nname: dev-preferences\n---\n# Preferences\nUse TypeScript strict.\n';
fs.writeFileSync(prefsPath, content);
runClaudeUninstall(tmpDir);
assert.ok(fs.existsSync(prefsPath), 'dev-preferences.md should survive uninstall');
assert.strictEqual(fs.readFileSync(prefsPath, 'utf-8'), content, 'content should be identical');
});
test('still removes GSD engine files during uninstall', () => {
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
fs.writeFileSync(profilePath, '# Profile\n');
// Verify engine files exist before uninstall
const binDir = path.join(tmpDir, '.claude', 'get-shit-done', 'bin');
assert.ok(fs.existsSync(binDir), 'bin/ should exist before uninstall');
runClaudeUninstall(tmpDir);
// Engine files gone, user file preserved
assert.ok(!fs.existsSync(binDir), 'bin/ should be removed after uninstall');
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive');
});
test('clean uninstall when no user files exist', () => {
runClaudeUninstall(tmpDir);
const gsdDir = path.join(tmpDir, '.claude', 'get-shit-done');
const cmdDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
// Directories should be fully removed when no user files to preserve
assert.ok(!fs.existsSync(gsdDir), 'get-shit-done/ should not exist after clean uninstall');
assert.ok(!fs.existsSync(cmdDir), 'commands/gsd/ should not exist after clean uninstall');
});
});

View File

@@ -30,6 +30,7 @@ const {
findPhaseInternal, findPhaseInternal,
findProjectRoot, findProjectRoot,
detectSubRepos, detectSubRepos,
planningDir,
} = require('../get-shit-done/bin/lib/core.cjs'); } = require('../get-shit-done/bin/lib/core.cjs');
// ─── loadConfig ──────────────────────────────────────────────────────────────── // ─── loadConfig ────────────────────────────────────────────────────────────────
@@ -1611,3 +1612,78 @@ describe('reapStaleTempFiles', () => {
}); });
}); });
}); });
// ─── planningDir ──────────────────────────────────────────────────────────────
describe('planningDir', () => {
const cwd = '/fake/repo';
let savedProject, savedWorkstream;
beforeEach(() => {
savedProject = process.env.GSD_PROJECT;
savedWorkstream = process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
});
afterEach(() => {
if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
else delete process.env.GSD_PROJECT;
if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
else delete process.env.GSD_WORKSTREAM;
});
test('returns .planning/ when neither project nor workstream is set', () => {
const result = planningDir(cwd, null, null);
assert.strictEqual(result, path.join(cwd, '.planning'));
});
test('returns .planning/{project}/ when project is set', () => {
const result = planningDir(cwd, null, 'my-app');
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app'));
});
test('returns .planning/workstreams/{ws}/ when workstream is set', () => {
const result = planningDir(cwd, 'feature-x', null);
assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x'));
});
test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => {
const result = planningDir(cwd, 'feature-x', 'my-app');
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'));
});
test('reads GSD_PROJECT from env when project param is undefined', () => {
process.env.GSD_PROJECT = 'env-project';
const result = planningDir(cwd);
assert.strictEqual(result, path.join(cwd, '.planning', 'env-project'));
});
test('rejects path traversal in project name', () => {
assert.throws(
() => planningDir(cwd, null, '../../etc'),
/invalid path characters/
);
});
test('rejects forward slash in project name', () => {
assert.throws(
() => planningDir(cwd, null, 'foo/bar'),
/invalid path characters/
);
});
test('rejects backslash in project name', () => {
assert.throws(
() => planningDir(cwd, null, 'foo\\bar'),
/invalid path characters/
);
});
test('rejects path traversal in workstream name', () => {
assert.throws(
() => planningDir(cwd, '../../../tmp', null),
/invalid path characters/
);
});
});

View File

@@ -0,0 +1,388 @@
/**
* GSD Tools Tests - Health Validation
*
* Tests for fix/health-validation-1473c:
* - W011: STATE/ROADMAP cross-validation (phase divergence detection)
* - W012: branching_strategy validation
* - W013: context_window validation
* - W014: phase_branch_template placeholder validation
* - W015: milestone_branch_template placeholder validation
* - stateReplaceFieldWithFallback field-miss warning
* - Boundary conditions and edge cases
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Helpers ────────────────────────────────────────────────────────────────
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n${lines}\n`
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
`# Project\n\n${content}`
);
}
function writeValidConfigJson(tmpDir, overrides = {}) {
const base = { model_profile: 'balanced', commit_docs: true };
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ ...base, ...overrides }, null, 2)
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. W011: STATE/ROADMAP cross-validation
// ─────────────────────────────────────────────────────────────────────────────
describe('W011: STATE/ROADMAP cross-validation', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE says current phase but ROADMAP shows it as complete -> warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Session State\n\n**Current Phase:** 03\n**Current Phase Name:** Database Layer\n**Status:** In progress\n`
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W011'),
`Expected W011 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('STATE and ROADMAP agree (phase not checked off) -> no W011 warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Phase 2: API Layer\n\n### Phase 2: API Layer\n**Goal:** Build API\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Session State\n\n**Current Phase:** 2\n**Status:** In progress\n`
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W011'),
`Should not have W011: ${JSON.stringify(output.warnings)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. W012-W015: Config field validation
// ─────────────────────────────────────────────────────────────────────────────
describe('config field validation', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('W012: invalid branching_strategy triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { branching_strategy: 'banana' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W012'),
`Expected W012 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W013: negative context_window triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: -500 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W013'),
`Expected W013 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W014: phase_branch_template missing {phase} triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { phase_branch_template: 'gsd/no-placeholder-{slug}' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W014'),
`Expected W014 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W015: milestone_branch_template missing {milestone} triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { milestone_branch_template: 'release/no-placeholder' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W015'),
`Expected W015 in warnings: ${JSON.stringify(output.warnings)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Boundary conditions
// ─────────────────────────────────────────────────────────────────────────────
describe('boundary conditions', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('context_window config accepts 500000 (boundary value)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: 500000 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 for context_window=500000: ${JSON.stringify(output.warnings)}`
);
});
test('context_window config accepts 200000 (default value)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: 200000 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 for context_window=200000: ${JSON.stringify(output.warnings)}`
);
});
test('W013 does NOT fire when context_window is absent from config', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 when context_window is absent: ${JSON.stringify(output.warnings)}`
);
});
test('health check handles STATE.md with no Current Phase field (no W011 crash)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nSome content but no phase reference.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command should not crash: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'should return a status string');
assert.ok(Array.isArray(output.errors), 'should return errors array');
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
});
test('health check handles empty ROADMAP.md (no crash)', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command should not crash on empty ROADMAP.md: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'should return a status string');
assert.ok(Array.isArray(output.errors), 'should return errors array');
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
});
test('config.json with trailing comma -- validate health reports parse error', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{"model_profile": "balanced",}'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Test Phase\n'
);
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `validate health should not crash on invalid JSON: ${result.error}`);
const output = JSON.parse(result.output);
const hasE005 = output.errors.some(e => e.code === 'E005');
assert.ok(hasE005, `Should report E005 for invalid config.json: ${JSON.stringify(output.errors)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. stateReplaceFieldWithFallback warning
// ─────────────────────────────────────────────────────────────────────────────
describe('stateReplaceFieldWithFallback field-miss warning', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('advance-plan completes even when fields are missing (non-fatal)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
});
test('validate health on 50-phase project completes in under 3000ms', () => {
// Stress test for the new health checks at scale
let roadmapContent = '# Roadmap v1.0\n\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `- [${i <= 25 ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
}
roadmapContent += '\n';
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
roadmapContent += `### Phase ${i}: Feature ${i}\n\n**Goal:** Build feature ${i}\n**Plans:** 1 plans\n\n`;
}
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
writeMinimalProjectMd(tmpDir);
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** Planning\n');
writeValidConfigJson(tmpDir);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Plan ${i}\n`);
if (i <= 25) {
fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Summary ${i}\n`);
}
}
const { performance } = require('perf_hooks');
const start = performance.now();
const result = runGsdTools('validate health', tmpDir);
const elapsed = performance.now() - start;
assert.ok(result.success, `validate health should succeed: ${result.error}`);
assert.ok(elapsed < 3000, `Should complete in under 3000ms, took ${elapsed.toFixed(0)}ms`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'Should return a status string');
});
});

507
tests/hooks-opt-in.test.cjs Normal file
View File

@@ -0,0 +1,507 @@
/**
* GSD Tools Tests - Community Hooks (opt-in)
*
* Tests for feat/hooks-opt-in-1473d:
* - Hook file existence and permissions
* - Installer hook registration in install.js
* - Hook execution with opt-in enabled and disabled
* - Negative security tests for hooks
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const isWindows = process.platform === 'win32';
// ─── Helpers ────────────────────────────────────────────────────────────────
function createTempProject(prefix = 'gsd-hook-test-') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
function cleanup(tmpDir) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
}
function writeConfigWithHooks(tmpDir, enabled) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
hooks: { community: enabled }
}, null, 2)
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || '# Session State\n\n**Current Phase:** 01\n**Status:** Active\n';
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Hook file existence and permissions
// ─────────────────────────────────────────────────────────────────────────────
describe('hook file validation', () => {
test('gsd-session-state.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-session-state.sh should exist');
});
test('gsd-validate-commit.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-validate-commit.sh should exist');
});
test('gsd-phase-boundary.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-phase-boundary.sh should exist');
});
test('gsd-session-state.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-session-state.sh should be executable');
});
test('gsd-validate-commit.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-validate-commit.sh should be executable');
});
test('gsd-phase-boundary.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-phase-boundary.sh should be executable');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Installer hook registration
// ─────────────────────────────────────────────────────────────────────────────
describe('installer hook registration', () => {
const installJsPath = path.join(__dirname, '..', 'bin', 'install.js');
let installSource;
beforeEach(() => {
installSource = fs.readFileSync(installJsPath, 'utf-8');
});
test('install.js contains gsd-validate-commit registration block', () => {
assert.ok(
installSource.includes('gsd-validate-commit'),
'install.js should contain gsd-validate-commit hook registration'
);
assert.ok(
installSource.includes('validateCommitCommand'),
'install.js should define validateCommitCommand variable'
);
assert.ok(
installSource.includes('hasValidateCommitHook'),
'install.js should check for existing validate-commit hook'
);
});
test('install.js contains gsd-session-state registration block', () => {
assert.ok(
installSource.includes('gsd-session-state'),
'install.js should contain gsd-session-state hook registration'
);
assert.ok(
installSource.includes('sessionStateCommand'),
'install.js should define sessionStateCommand variable'
);
assert.ok(
installSource.includes('hasSessionStateHook'),
'install.js should check for existing session-state hook'
);
});
test('install.js contains gsd-phase-boundary registration block', () => {
assert.ok(
installSource.includes('gsd-phase-boundary'),
'install.js should contain gsd-phase-boundary hook registration'
);
assert.ok(
installSource.includes('phaseBoundaryCommand'),
'install.js should define phaseBoundaryCommand variable'
);
assert.ok(
installSource.includes('hasPhaseBoundaryHook'),
'install.js should check for existing phase-boundary hook'
);
});
test('install.js registers validate-commit with PreToolUse event and Bash matcher', () => {
assert.ok(
installSource.includes("settings.hooks[preToolEvent].push"),
'validate-commit should be pushed to preToolEvent hooks array'
);
const validateCommitBlock = installSource.substring(
installSource.indexOf('// Configure commit validation hook'),
installSource.indexOf('// Configure session state orientation hook')
);
assert.ok(
validateCommitBlock.includes("matcher: 'Bash'"),
'validate-commit hook should use Bash matcher'
);
assert.ok(
validateCommitBlock.includes('preToolEvent'),
'validate-commit hook should register on preToolEvent (PreToolUse)'
);
});
test('install.js adds all 3 new hooks to the uninstall cleanup list', () => {
const gsdHooksMatch = installSource.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(gsdHooksMatch, 'install.js should define gsdHooks array for uninstall cleanup');
const gsdHooksContent = gsdHooksMatch[1];
assert.ok(
gsdHooksContent.includes('gsd-session-state.sh'),
'gsdHooks should include gsd-session-state.sh'
);
assert.ok(
gsdHooksContent.includes('gsd-validate-commit.sh'),
'gsdHooks should include gsd-validate-commit.sh'
);
assert.ok(
gsdHooksContent.includes('gsd-phase-boundary.sh'),
'gsdHooks should include gsd-phase-boundary.sh'
);
});
test('install.js log messages indicate opt-in behavior', () => {
assert.ok(
installSource.includes('opt-in via config'),
'install.js should mention opt-in in log messages'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Opt-in gating behavior
// ─────────────────────────────────────────────────────────────────────────────
describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (no-op) even with a bad commit message
assert.strictEqual(result.status, 0, `Should be no-op when disabled, got ${result.status}`);
});
test('validate-commit is a no-op when config.json is absent', () => {
// No config.json at all
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-bare-'));
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
try {
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: bareDir,
});
assert.strictEqual(result.status, 0, `Should be no-op without config.json, got ${result.status}`);
} finally {
fs.rmSync(bareDir, { recursive: true, force: true });
}
});
test('session-state is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Should NOT output state info when disabled
assert.ok(
!result.stdout.includes('Project State Reminder'),
`Should not output state reminder when disabled: ${result.stdout}`
);
});
test('phase-boundary is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
!result.stdout.includes('.planning/ file modified'),
`Should not output warning when disabled: ${result.stdout}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Hook execution when enabled
// ─────────────────────────────────────────────────────────────────────────────
describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit allows valid conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(core): add locking mechanism"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit should exit 0, got ${result.status}. stderr: ${result.stderr}`);
});
test('validate-commit blocks non-conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
assert.ok(result.stdout.includes('Conventional Commits'), `stdout should mention "Conventional Commits": ${result.stdout}`);
});
test('validate-commit allows non-commit commands', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git push origin main' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Non-commit command should exit 0, got ${result.status}`);
});
test('session-state outputs state info when enabled', () => {
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('STATE.md exists'),
`stdout should contain "STATE.md exists": ${result.stdout}`
);
});
test('session-state exits 0 without .planning/ (in enabled project)', () => {
// Create a dir with config but no STATE.md
const noStateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-nostate-'));
fs.mkdirSync(path.join(noStateDir, '.planning'), { recursive: true });
writeConfigWithHooks(noStateDir, true);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
try {
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: noStateDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('No .planning/ found') || result.stdout.includes('Project State'),
`Should handle missing STATE.md gracefully: ${result.stdout}`
);
} finally {
fs.rmSync(noStateDir, { recursive: true, force: true });
}
});
test('phase-boundary detects .planning/ writes when enabled', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('.planning/ file modified'),
`stdout should contain ".planning/ file modified": ${result.stdout}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Negative security tests for hooks
// ─────────────────────────────────────────────────────────────────────────────
describe('hook security tests', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit blocks message with shell metacharacters', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "$(rm -rf /)"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
});
test('validate-commit blocks message with backtick injection', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "`whoami`"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
});
test('validate-commit allows commit with scope containing special chars', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(api/v2): handle edge case"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit with / in scope should be allowed: ${result.status}`);
});
test('phase-boundary handles malformed JSON input gracefully', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = 'not json at all';
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should not crash on malformed JSON: ${result.stderr}`);
});
test('hooks handle config.json with broken JSON gracefully', () => {
// Write malformed JSON config
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{ broken json'
);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (treat malformed config as disabled)
assert.strictEqual(result.status, 0, `Malformed config should be treated as disabled: ${result.status}`);
});
});

View File

@@ -26,9 +26,10 @@ const runtimeMap = {
'6': 'copilot', '6': 'copilot',
'7': 'antigravity', '7': 'antigravity',
'8': 'cursor', '8': 'cursor',
'9': 'windsurf' '9': 'windsurf',
'10': 'augment'
}; };
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf']; const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
/** /**
* Simulate the parsing logic from promptRuntime without requiring readline. * Simulate the parsing logic from promptRuntime without requiring readline.
@@ -37,7 +38,7 @@ const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot',
function parseRuntimeInput(input) { function parseRuntimeInput(input) {
input = input.trim() || '1'; input = input.trim() || '1';
if (input === '10') { if (input === '11') {
return allRuntimes; return allRuntimes;
} }
@@ -57,6 +58,7 @@ describe('multi-runtime selection parsing', () => {
test('single choice returns single runtime', () => { test('single choice returns single runtime', () => {
assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('2'), ['kilo']); assert.deepStrictEqual(parseRuntimeInput('2'), ['kilo']);
assert.deepStrictEqual(parseRuntimeInput('3'), ['opencode']);
assert.deepStrictEqual(parseRuntimeInput('4'), ['gemini']); assert.deepStrictEqual(parseRuntimeInput('4'), ['gemini']);
assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']); assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']);
assert.deepStrictEqual(parseRuntimeInput('8'), ['cursor']); assert.deepStrictEqual(parseRuntimeInput('8'), ['cursor']);
@@ -82,8 +84,12 @@ describe('multi-runtime selection parsing', () => {
assert.deepStrictEqual(parseRuntimeInput('9'), ['windsurf']); assert.deepStrictEqual(parseRuntimeInput('9'), ['windsurf']);
}); });
test('choice 10 returns all runtimes', () => { test('single choice for augment', () => {
assert.deepStrictEqual(parseRuntimeInput('10'), allRuntimes); assert.deepStrictEqual(parseRuntimeInput('10'), ['augment']);
});
test('choice 11 returns all runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('11'), allRuntimes);
}); });
test('empty input defaults to claude', () => { test('empty input defaults to claude', () => {
@@ -92,7 +98,7 @@ describe('multi-runtime selection parsing', () => {
}); });
test('invalid choices are ignored, falls back to claude if all invalid', () => { test('invalid choices are ignored, falls back to claude if all invalid', () => {
assert.deepStrictEqual(parseRuntimeInput('11'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('12'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
}); });
@@ -114,7 +120,7 @@ describe('multi-runtime selection parsing', () => {
}); });
describe('install.js source contains multi-select support', () => { describe('install.js source contains multi-select support', () => {
test('runtimeMap is defined with all 9 runtimes', () => { test('runtimeMap is defined with all 10 runtimes', () => {
for (const [key, name] of Object.entries(runtimeMap)) { for (const [key, name] of Object.entries(runtimeMap)) {
assert.ok( assert.ok(
installSrc.includes(`'${key}': '${name}'`), installSrc.includes(`'${key}': '${name}'`),
@@ -131,6 +137,24 @@ describe('install.js source contains multi-select support', () => {
} }
}); });
test('all shortcut uses option 11', () => {
assert.ok(
installSrc.includes("if (input === '11')"),
'all shortcut uses option 11'
);
});
test('prompt lists Augment as option 10 and All as option 11', () => {
assert.ok(
installSrc.includes('10${reset}) Augment'),
'prompt lists Augment as option 10'
);
assert.ok(
installSrc.includes('11${reset}) All'),
'prompt lists All as option 11'
);
});
test('prompt text shows multi-select hint', () => { test('prompt text shows multi-select hint', () => {
assert.ok( assert.ok(
installSrc.includes('Select multiple'), installSrc.includes('Select multiple'),

View File

@@ -1,8 +1,9 @@
/** /**
* GSD Tools Tests - OpenCode permission config * Regression tests for OpenCode permission config handling.
* *
* Regression tests for OpenCode permission handling, including the valid * Ensures the installer does not crash when opencode.json uses the valid
* top-level string form: "permission": "allow". * top-level string form: "permission": "allow", and that path-specific
* permissions are written against the actual resolved install directory.
*/ */
process.env.GSD_TEST_MODE = '1'; process.env.GSD_TEST_MODE = '1';
@@ -17,6 +18,19 @@ const { configureOpencodePermissions } = require('../bin/install.js');
const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8'); const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const envKeys = ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'];
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
function restoreEnv(snapshot) {
for (const key of envKeys) {
if (snapshot[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = snapshot[key];
}
}
}
describe('configureOpencodePermissions', () => { describe('configureOpencodePermissions', () => {
let configDir; let configDir;
@@ -26,9 +40,10 @@ describe('configureOpencodePermissions', () => {
afterEach(() => { afterEach(() => {
cleanup(configDir); cleanup(configDir);
restoreEnv(originalEnv);
}); });
test('does not rewrite top-level string permissions', () => { test('does not crash or rewrite top-level string permissions', () => {
const configPath = path.join(configDir, 'opencode.json'); const configPath = path.join(configDir, 'opencode.json');
const original = JSON.stringify({ const original = JSON.stringify({
$schema: 'https://opencode.ai/config.json', $schema: 'https://opencode.ai/config.json',
@@ -37,6 +52,7 @@ describe('configureOpencodePermissions', () => {
}, null, 2) + '\n'; }, null, 2) + '\n';
fs.writeFileSync(configPath, original); fs.writeFileSync(configPath, original);
process.env.OPENCODE_CONFIG_DIR = configDir;
assert.doesNotThrow(() => configureOpencodePermissions(true, configDir)); assert.doesNotThrow(() => configureOpencodePermissions(true, configDir));
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original); assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
@@ -45,6 +61,7 @@ describe('configureOpencodePermissions', () => {
test('adds path-specific read and external_directory permissions for object configs', () => { test('adds path-specific read and external_directory permissions for object configs', () => {
const configPath = path.join(configDir, 'opencode.json'); const configPath = path.join(configDir, 'opencode.json');
fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n'); fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n');
process.env.OPENCODE_CONFIG_DIR = configDir;
configureOpencodePermissions(true, configDir); configureOpencodePermissions(true, configDir);
@@ -56,6 +73,9 @@ describe('configureOpencodePermissions', () => {
}); });
test('finishInstall passes the actual config dir to OpenCode permissions', () => { test('finishInstall passes the actual config dir to OpenCode permissions', () => {
assert.ok(installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'), 'OpenCode permission config uses actual install dir'); assert.ok(
installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'),
'OpenCode permission config uses actual install dir'
);
}); });
}); });

View File

@@ -0,0 +1,254 @@
/**
* GSD Tools Tests - subagent timeout configuration
*
* Validates that workflow.subagent_timeout is properly registered,
* loaded from config, and emitted in init context.
*
* Closes: #1472
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── config key registration ─────────────────────────────────────────────────
describe('workflow.subagent_timeout config key (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('subagent_timeout has correct default value (300000ms)', () => {
// Write a minimal config.json
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ model_profile: 'balanced' }, null, 2));
// Load config via init and check the value propagates
// Use config-get to verify the field is recognized
const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '600000'], tmpDir);
assert.ok(result.success, `config-set should accept workflow.subagent_timeout: ${result.error}`);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(config.workflow.subagent_timeout, 600000);
});
test('config-set rejects invalid config keys but accepts subagent_timeout', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
// Valid key should succeed
const valid = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir);
assert.ok(valid.success, `workflow.subagent_timeout should be a valid key: ${valid.error}`);
// Invalid key should fail
const invalid = runGsdTools(['config-set', 'workflow.nonexistent_key', 'true'], tmpDir);
assert.ok(!invalid.success, 'nonexistent key should be rejected');
});
test('subagent_timeout appears in map-codebase init context', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
workflow: { subagent_timeout: 600000 }
}, null, 2));
const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init map-codebase should succeed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.subagent_timeout, 600000, 'init context should include configured timeout');
});
test('subagent_timeout defaults to 300000 when not configured', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
const result = runGsdTools('init map-codebase', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `init map-codebase should succeed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.subagent_timeout, 300000, 'default should be 300000ms (5 minutes)');
});
});
describe('map-codebase workflow references configurable timeout (#1472)', () => {
test('workflow file references subagent_timeout from init context', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md');
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('subagent_timeout'),
'map-codebase.md should reference subagent_timeout from init context'
);
assert.ok(
content.includes('workflow.subagent_timeout'),
'map-codebase.md should document the config key'
);
});
test('workflow file no longer has hardcoded 300000 timeout', () => {
const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md');
const content = fs.readFileSync(workflowPath, 'utf8');
// The timeout line should reference the config variable, not a hardcoded value
const timeoutLines = content.split('\n').filter(l => l.includes('timeout:'));
for (const line of timeoutLines) {
assert.ok(
!line.match(/timeout:\s*300000\s*$/),
`found hardcoded timeout: "${line.trim()}". Should reference subagent_timeout from init context.`
);
}
});
});
describe('planning-config.md documents subagent_timeout (#1472)', () => {
test('reference doc includes subagent_timeout entry', () => {
const refPath = path.join(__dirname, '..', 'get-shit-done', 'references', 'planning-config.md');
const content = fs.readFileSync(refPath, 'utf8');
assert.ok(
content.includes('workflow.subagent_timeout'),
'planning-config.md should document workflow.subagent_timeout'
);
assert.ok(
content.includes('300000'),
'planning-config.md should document the default value (300000)'
);
});
});
// ─── init execute-phase includes context_window ─────────────────────────────
describe('init execute-phase context_window (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('init execute-phase output includes context_window from config', () => {
// Write config with a custom context_window value (1M for Opus/Sonnet 4.6)
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
context_window: 1000000,
}, null, 2));
// Create a phase directory with a plan so init execute-phase succeeds
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.context_window, 1000000, 'context_window should reflect configured value');
});
test('init execute-phase uses default context_window when not configured', () => {
// Write minimal config without context_window
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('init execute-phase 1', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.context_window, 200000, 'default context_window should be 200000');
});
});
// ─── config-get context_window ──────────────────────────────────────────────
describe('config-get context_window (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('config-get context_window returns the configured value', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
context_window: 1000000,
}, null, 2));
const result = runGsdTools('config-get context_window', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 1000000);
});
test('config-get context_window errors when key is absent', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
const result = runGsdTools('config-get context_window', tmpDir);
assert.strictEqual(result.success, false);
assert.ok(
result.error.includes('Key not found'),
`Expected "Key not found" in error: ${result.error}`
);
});
});
// ─── config-set workflow.subagent_timeout numeric coercion ──────────────────
describe('config-set workflow.subagent_timeout numeric values (#1472)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
});
afterEach(() => {
cleanup(tmpDir);
});
test('config-set workflow.subagent_timeout coerces string to number', () => {
const result = runGsdTools(['config-set', 'workflow.subagent_timeout', '900000'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true);
assert.strictEqual(output.key, 'workflow.subagent_timeout');
assert.strictEqual(output.value, 900000);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.strictEqual(config.workflow.subagent_timeout, 900000);
assert.strictEqual(typeof config.workflow.subagent_timeout, 'number');
});
test('config-set workflow.subagent_timeout round-trips through config-get', () => {
runGsdTools(['config-set', 'workflow.subagent_timeout', '1200000'], tmpDir);
const result = runGsdTools('config-get workflow.subagent_timeout', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output, 1200000);
});
});