merge: sync Kilo runtime branch with latest main
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
[English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · **日本語**
|
||||
|
||||
**Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Antigravity向けの軽量かつ強力なメタプロンプティング、コンテキストエンジニアリング、仕様駆動開発システム。**
|
||||
**Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf、Antigravity、Augment向けの軽量かつ強力なメタプロンプティング、コンテキストエンジニアリング、仕様駆動開発システム。**
|
||||
|
||||
**コンテキストロット(Claudeがコンテキストウィンドウを消費するにつれ品質が劣化する現象)を解決します。**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
[English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · **한국어**
|
||||
|
||||
**Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Antigravity를 위한 가볍고 강력한 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.**
|
||||
**Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment를 위한 가볍고 강력한 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.**
|
||||
|
||||
**컨텍스트 rot를 해결합니다 — Claude의 컨텍스트 창이 채워질수록 품질이 저하되는 문제.**
|
||||
|
||||
|
||||
19
README.md
19
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md)
|
||||
|
||||
**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and Antigravity.**
|
||||
**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, and Augment.**
|
||||
|
||||
**Solves context rot — the quality degradation that happens as Claude fills its context window.**
|
||||
|
||||
@@ -84,16 +84,13 @@ npx get-shit-done-cc@latest
|
||||
```
|
||||
|
||||
The installer prompts you to choose:
|
||||
1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, or all (interactive multi-select — pick multiple runtimes in a single install session)
|
||||
1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, or all (interactive multi-select — pick multiple runtimes in a single install session)
|
||||
2. **Location** — Global (all projects) or local (current project only)
|
||||
|
||||
Verify with:
|
||||
- Claude Code / Gemini: `/gsd:help`
|
||||
- OpenCode: `/gsd-help`
|
||||
- Kilo: `/gsd-help`
|
||||
- Claude Code / Gemini / Copilot / Antigravity: `/gsd:help`
|
||||
- OpenCode / Kilo / Augment: `/gsd-help`
|
||||
- Codex: `$gsd-help`
|
||||
- Copilot: `/gsd:help`
|
||||
- Antigravity: `/gsd:help`
|
||||
|
||||
> [!NOTE]
|
||||
> Claude Code 2.1.88+ and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. The installer handles this automatically.
|
||||
@@ -144,12 +141,16 @@ npx get-shit-done-cc --windsurf --local # Install to ./.windsurf/
|
||||
npx get-shit-done-cc --antigravity --global # Install to ~/.gemini/antigravity/
|
||||
npx get-shit-done-cc --antigravity --local # Install to ./.agent/
|
||||
|
||||
# Augment
|
||||
npx get-shit-done-cc --augment --global # Install to ~/.augment/
|
||||
npx get-shit-done-cc --augment --local # Install to ./.augment/
|
||||
|
||||
# All runtimes
|
||||
npx get-shit-done-cc --all --global # Install to all directories
|
||||
```
|
||||
|
||||
Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt.
|
||||
Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, or `--all` to skip the runtime prompt.
|
||||
Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, or `--all` to skip the runtime prompt.
|
||||
Use `--sdk` to also install the GSD SDK CLI (`gsd-sdk`) for headless autonomous execution.
|
||||
|
||||
</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 --windsurf --global --uninstall
|
||||
npx get-shit-done-cc --antigravity --global --uninstall
|
||||
npx get-shit-done-cc --augment --global --uninstall
|
||||
|
||||
# Local installs (current project)
|
||||
npx get-shit-done-cc --claude --local --uninstall
|
||||
@@ -817,6 +819,7 @@ npx get-shit-done-cc --copilot --local --uninstall
|
||||
npx get-shit-done-cc --cursor --local --uninstall
|
||||
npx get-shit-done-cc --windsurf --local --uninstall
|
||||
npx get-shit-done-cc --antigravity --local --uninstall
|
||||
npx get-shit-done-cc --augment --local --uninstall
|
||||
```
|
||||
|
||||
This removes all GSD commands, agents, hooks, and settings while preserving your other configurations.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
[English](README.md) · **Português** · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md)
|
||||
|
||||
**Um sistema leve e poderoso de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificação para Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor e Antigravity.**
|
||||
**Um sistema leve e poderoso de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificação para Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity e Augment.**
|
||||
|
||||
**Resolve context rot — a degradação de qualidade que acontece conforme o Claude enche a janela de contexto.**
|
||||
|
||||
|
||||
362
bin/install.js
362
bin/install.js
@@ -68,6 +68,7 @@ const hasCopilot = args.includes('--copilot');
|
||||
const hasAntigravity = args.includes('--antigravity');
|
||||
const hasCursor = args.includes('--cursor');
|
||||
const hasWindsurf = args.includes('--windsurf');
|
||||
const hasAugment = args.includes('--augment');
|
||||
const hasSdk = args.includes('--sdk');
|
||||
const hasBoth = args.includes('--both'); // Legacy flag, keeps working
|
||||
const hasAll = args.includes('--all');
|
||||
@@ -76,7 +77,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
|
||||
// Runtime selection - can be set by flags or interactive prompt
|
||||
let selectedRuntimes = [];
|
||||
if (hasAll) {
|
||||
selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf'];
|
||||
selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
|
||||
} else if (hasBoth) {
|
||||
selectedRuntimes = ['claude', 'opencode'];
|
||||
} else {
|
||||
@@ -89,6 +90,7 @@ if (hasAll) {
|
||||
if (hasAntigravity) selectedRuntimes.push('antigravity');
|
||||
if (hasCursor) selectedRuntimes.push('cursor');
|
||||
if (hasWindsurf) selectedRuntimes.push('windsurf');
|
||||
if (hasAugment) selectedRuntimes.push('augment');
|
||||
}
|
||||
|
||||
// WSL + Windows Node.js detection
|
||||
@@ -135,6 +137,7 @@ function getDirName(runtime) {
|
||||
if (runtime === 'antigravity') return '.agent';
|
||||
if (runtime === 'cursor') return '.cursor';
|
||||
if (runtime === 'windsurf') return '.windsurf';
|
||||
if (runtime === 'augment') return '.augment';
|
||||
return '.claude';
|
||||
}
|
||||
|
||||
@@ -165,6 +168,7 @@ function getConfigDirFromHome(runtime, isGlobal) {
|
||||
}
|
||||
if (runtime === 'cursor') return "'.cursor'";
|
||||
if (runtime === 'windsurf') return "'.windsurf'";
|
||||
if (runtime === 'augment') return "'.augment'";
|
||||
return "'.claude'";
|
||||
}
|
||||
|
||||
@@ -306,6 +310,16 @@ function getGlobalDir(runtime, explicitDir = null) {
|
||||
return path.join(os.homedir(), '.windsurf');
|
||||
}
|
||||
|
||||
if (runtime === 'augment') {
|
||||
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
|
||||
if (explicitDir) {
|
||||
return expandTilde(explicitDir);
|
||||
}
|
||||
if (process.env.AUGMENT_CONFIG_DIR) {
|
||||
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
|
||||
}
|
||||
return path.join(os.homedir(), '.augment');
|
||||
}
|
||||
|
||||
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
|
||||
if (explicitDir) {
|
||||
@@ -327,7 +341,7 @@ const banner = '\n' +
|
||||
'\n' +
|
||||
' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' +
|
||||
' A meta-prompting, context engineering and spec-driven\n' +
|
||||
' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, and Windsurf by TÂCHES.\n';
|
||||
' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, and Augment by TÂCHES.\n';
|
||||
|
||||
// Parse --config-dir argument
|
||||
function parseConfigDirArg() {
|
||||
@@ -365,7 +379,7 @@ if (hasUninstall) {
|
||||
|
||||
// Show help if requested
|
||||
if (hasHelp) {
|
||||
console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}--sdk${reset} Also install GSD SDK CLI (gsd-sdk)\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <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);
|
||||
}
|
||||
|
||||
@@ -1224,6 +1238,189 @@ function convertClaudeAgentToWindsurfAgent(content) {
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
|
||||
// --- Augment converters ---
|
||||
// Augment (auggie CLI) uses a tool set similar to Cursor/Windsurf (VS Code-based).
|
||||
// Config lives in .augment/ (local) and ~/.augment/ (global).
|
||||
|
||||
const claudeToAugmentTools = {
|
||||
Bash: 'launch-process',
|
||||
Edit: 'str-replace-editor',
|
||||
AskUserQuestion: null,
|
||||
SlashCommand: null,
|
||||
TodoWrite: 'add_tasks',
|
||||
};
|
||||
|
||||
function convertAugmentToolName(claudeTool) {
|
||||
if (claudeTool in claudeToAugmentTools) {
|
||||
return claudeToAugmentTools[claudeTool];
|
||||
}
|
||||
if (claudeTool.startsWith('mcp__')) {
|
||||
return claudeTool;
|
||||
}
|
||||
const toolMapping = {
|
||||
Read: 'view',
|
||||
Write: 'save-file',
|
||||
Glob: 'view',
|
||||
Grep: 'grep',
|
||||
Task: null,
|
||||
WebSearch: 'web-search',
|
||||
WebFetch: 'web-fetch',
|
||||
};
|
||||
return toolMapping[claudeTool] || claudeTool;
|
||||
}
|
||||
|
||||
function convertSlashCommandsToAugmentSkillMentions(content) {
|
||||
return content.replace(/gsd:/gi, 'gsd-');
|
||||
}
|
||||
|
||||
function convertClaudeToAugmentMarkdown(content) {
|
||||
let converted = convertSlashCommandsToAugmentSkillMentions(content);
|
||||
converted = converted.replace(/\bBash\(/g, 'launch-process(');
|
||||
converted = converted.replace(/\bEdit\(/g, 'str-replace-editor(');
|
||||
converted = converted.replace(/\bRead\(/g, 'view(');
|
||||
converted = converted.replace(/\bWrite\(/g, 'save-file(');
|
||||
converted = converted.replace(/\bTodoWrite\(/g, 'add_tasks(');
|
||||
converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
|
||||
// Replace subagent_type from Claude to Augment format
|
||||
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
|
||||
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
|
||||
// Replace project-level Claude conventions with Augment equivalents
|
||||
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.augment/rules/`');
|
||||
converted = converted.replace(/\.\/CLAUDE\.md/g, '.augment/rules/');
|
||||
converted = converted.replace(/`CLAUDE\.md`/g, '`.augment/rules/`');
|
||||
converted = converted.replace(/\bCLAUDE\.md\b/g, '.augment/rules/');
|
||||
converted = converted.replace(/\.claude\/skills\//g, '.augment/skills/');
|
||||
// Remove Claude Code-specific bug workarounds before brand replacement
|
||||
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
|
||||
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
|
||||
// Replace "Claude Code" brand references with "Augment"
|
||||
converted = converted.replace(/\bClaude Code\b/g, 'Augment');
|
||||
return converted;
|
||||
}
|
||||
|
||||
function getAugmentSkillAdapterHeader(skillName) {
|
||||
return `<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) {
|
||||
let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
|
||||
return `$gsd-${String(commandName).toLowerCase()}`;
|
||||
@@ -3509,6 +3706,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
||||
const isAntigravity = runtime === 'antigravity';
|
||||
const isCursor = runtime === 'cursor';
|
||||
const isWindsurf = runtime === 'windsurf';
|
||||
const isAugment = runtime === 'augment';
|
||||
const dirName = getDirName(runtime);
|
||||
|
||||
// Clean install: remove existing destination to prevent orphaned files
|
||||
@@ -3777,6 +3975,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
const isAntigravity = runtime === 'antigravity';
|
||||
const isCursor = runtime === 'cursor';
|
||||
const isWindsurf = runtime === 'windsurf';
|
||||
const isAugment = runtime === 'augment';
|
||||
const dirName = getDirName(runtime);
|
||||
|
||||
// Get the target directory based on runtime and install type
|
||||
@@ -3797,6 +3996,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
if (runtime === 'antigravity') runtimeLabel = 'Antigravity';
|
||||
if (runtime === 'cursor') runtimeLabel = 'Cursor';
|
||||
if (runtime === 'windsurf') runtimeLabel = 'Windsurf';
|
||||
if (runtime === 'augment') runtimeLabel = 'Augment';
|
||||
|
||||
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
|
||||
|
||||
@@ -3964,9 +4164,26 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
// Gemini: still uses commands/gsd/
|
||||
const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
|
||||
if (fs.existsSync(gsdCommandsDir)) {
|
||||
// Preserve user-generated files before wipe (#1423)
|
||||
// Note: if more user files are added, consider a naming convention (e.g., USER-*.md)
|
||||
// and preserve all matching files instead of listing each one individually.
|
||||
const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md');
|
||||
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
|
||||
|
||||
fs.rmSync(gsdCommandsDir, { recursive: true });
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed commands/gsd/`);
|
||||
|
||||
// Restore user-generated files
|
||||
if (preservedDevPrefs) {
|
||||
try {
|
||||
fs.mkdirSync(gsdCommandsDir, { recursive: true });
|
||||
fs.writeFileSync(devPrefsPath, preservedDevPrefs);
|
||||
console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
|
||||
} catch (err) {
|
||||
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Claude Code: remove skills/gsd-*/ directories
|
||||
@@ -3989,18 +4206,47 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
// Also clean up legacy commands/gsd/ from older installs
|
||||
const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd');
|
||||
if (fs.existsSync(legacyCommandsDir)) {
|
||||
// Preserve user-generated files before legacy wipe (#1423)
|
||||
const devPrefsPath = path.join(legacyCommandsDir, 'dev-preferences.md');
|
||||
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
|
||||
|
||||
fs.rmSync(legacyCommandsDir, { recursive: true });
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
|
||||
|
||||
if (preservedDevPrefs) {
|
||||
try {
|
||||
fs.mkdirSync(legacyCommandsDir, { recursive: true });
|
||||
fs.writeFileSync(devPrefsPath, preservedDevPrefs);
|
||||
console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
|
||||
} catch (err) {
|
||||
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove get-shit-done directory
|
||||
const gsdDir = path.join(targetDir, 'get-shit-done');
|
||||
if (fs.existsSync(gsdDir)) {
|
||||
// Preserve user-generated files before wipe (#1423)
|
||||
const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md');
|
||||
const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null;
|
||||
|
||||
fs.rmSync(gsdDir, { recursive: true });
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed get-shit-done/`);
|
||||
|
||||
// Restore user-generated files
|
||||
if (preservedProfile) {
|
||||
try {
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
fs.writeFileSync(userProfilePath, preservedProfile);
|
||||
console.log(` ${green}✓${reset} Preserved get-shit-done/USER-PROFILE.md`);
|
||||
} catch (err) {
|
||||
console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove GSD agents (gsd-*.md files only)
|
||||
@@ -4023,7 +4269,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
// 4. Remove GSD hooks
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js'];
|
||||
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
|
||||
let hookCount = 0;
|
||||
for (const hook of gsdHooks) {
|
||||
const hookPath = path.join(hooksDir, hook);
|
||||
@@ -4338,7 +4584,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCode also supports a top-level string permission like "allow".
|
||||
// OpenCode also allows a top-level string permission like "allow".
|
||||
// In that case, path-specific permission entries are unnecessary.
|
||||
if (typeof config.permission === 'string') {
|
||||
return;
|
||||
@@ -4419,7 +4665,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
|
||||
}
|
||||
|
||||
// Ensure permission structure exists
|
||||
if (!config.permission) {
|
||||
if (!config.permission || typeof config.permission !== 'object') {
|
||||
config.permission = {};
|
||||
}
|
||||
|
||||
@@ -4704,6 +4950,7 @@ function install(isGlobal, runtime = 'claude') {
|
||||
const isAntigravity = runtime === 'antigravity';
|
||||
const isCursor = runtime === 'cursor';
|
||||
const isWindsurf = runtime === 'windsurf';
|
||||
const isAugment = runtime === 'augment';
|
||||
const dirName = getDirName(runtime);
|
||||
const src = path.join(__dirname, '..');
|
||||
|
||||
@@ -4736,6 +4983,7 @@ function install(isGlobal, runtime = 'claude') {
|
||||
if (isAntigravity) runtimeLabel = 'Antigravity';
|
||||
if (isCursor) runtimeLabel = 'Cursor';
|
||||
if (isWindsurf) runtimeLabel = 'Windsurf';
|
||||
if (isAugment) runtimeLabel = 'Augment';
|
||||
|
||||
console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
|
||||
|
||||
@@ -4823,11 +5071,19 @@ function install(isGlobal, runtime = 'claude') {
|
||||
} else {
|
||||
failures.push('skills/gsd-*');
|
||||
}
|
||||
} else if (isAugment) {
|
||||
const skillsDir = path.join(targetDir, 'skills');
|
||||
const gsdSrc = path.join(src, 'commands', 'gsd');
|
||||
copyCommandsAsAugmentSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime);
|
||||
const installedSkillNames = listCodexSkillNames(skillsDir);
|
||||
if (installedSkillNames.length > 0) {
|
||||
console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`);
|
||||
} else {
|
||||
failures.push('skills/gsd-*');
|
||||
}
|
||||
} else if (isGemini) {
|
||||
// Gemini: nested structure in commands/ directory (still supported)
|
||||
const commandsDir = path.join(targetDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
|
||||
const gsdSrc = path.join(src, 'commands', 'gsd');
|
||||
const gsdDest = path.join(commandsDir, 'gsd');
|
||||
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
|
||||
@@ -4916,6 +5172,8 @@ function install(isGlobal, runtime = 'claude') {
|
||||
content = convertClaudeAgentToCursorAgent(content);
|
||||
} else if (isWindsurf) {
|
||||
content = convertClaudeAgentToWindsurfAgent(content);
|
||||
} else if (isAugment) {
|
||||
content = convertClaudeAgentToAugmentAgent(content);
|
||||
}
|
||||
const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name;
|
||||
fs.writeFileSync(path.join(agentsDest, destName), content);
|
||||
@@ -5241,6 +5499,75 @@ function install(isGlobal, runtime = 'claude') {
|
||||
});
|
||||
console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
|
||||
}
|
||||
|
||||
// Community hooks — registered on install but opt-in at runtime.
|
||||
// Each hook checks .planning/config.json for hooks.community: true
|
||||
// and exits silently (no-op) if not enabled. This lets users enable
|
||||
// them per-project by adding: "hooks": { "community": true }
|
||||
|
||||
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
|
||||
const validateCommitCommand = isGlobal
|
||||
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-validate-commit.sh'
|
||||
: 'bash ' + dirName + '/hooks/gsd-validate-commit.sh';
|
||||
const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit'))
|
||||
);
|
||||
|
||||
if (!hasValidateCommitHook) {
|
||||
settings.hooks[preToolEvent].push({
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: validateCommitCommand,
|
||||
timeout: 5
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
|
||||
}
|
||||
|
||||
// Configure session state orientation hook (opt-in)
|
||||
const sessionStateCommand = isGlobal
|
||||
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-session-state.sh'
|
||||
: 'bash ' + dirName + '/hooks/gsd-session-state.sh';
|
||||
const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state'))
|
||||
);
|
||||
|
||||
if (!hasSessionStateHook) {
|
||||
settings.hooks.SessionStart.push({
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: sessionStateCommand
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
|
||||
}
|
||||
|
||||
// Configure phase boundary detection hook (opt-in)
|
||||
const phaseBoundaryCommand = isGlobal
|
||||
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-phase-boundary.sh'
|
||||
: 'bash ' + dirName + '/hooks/gsd-phase-boundary.sh';
|
||||
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))
|
||||
);
|
||||
|
||||
if (!hasPhaseBoundaryHook) {
|
||||
settings.hooks[postToolEvent].push({
|
||||
matcher: 'Write|Edit',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: phaseBoundaryCommand,
|
||||
timeout: 5
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { settingsPath, settings, statuslineCommand, runtime, configDir: targetDir };
|
||||
@@ -5309,6 +5636,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
||||
if (runtime === 'copilot') program = 'Copilot';
|
||||
if (runtime === 'antigravity') program = 'Antigravity';
|
||||
if (runtime === 'cursor') program = 'Cursor';
|
||||
if (runtime === 'windsurf') program = 'Windsurf';
|
||||
if (runtime === 'augment') program = 'Augment';
|
||||
|
||||
let command = '/gsd:new-project';
|
||||
if (runtime === 'opencode') command = '/gsd-new-project';
|
||||
@@ -5317,6 +5646,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
||||
if (runtime === 'copilot') command = '/gsd-new-project';
|
||||
if (runtime === 'antigravity') command = '/gsd-new-project';
|
||||
if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)';
|
||||
if (runtime === 'windsurf') command = '/gsd-new-project';
|
||||
if (runtime === 'augment') command = '/gsd-new-project';
|
||||
console.log(`
|
||||
${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
|
||||
|
||||
@@ -5468,9 +5799,10 @@ function promptRuntime(callback) {
|
||||
'6': 'copilot',
|
||||
'7': 'antigravity',
|
||||
'8': 'cursor',
|
||||
'9': 'windsurf'
|
||||
'9': 'windsurf',
|
||||
'10': 'augment'
|
||||
};
|
||||
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf'];
|
||||
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
|
||||
|
||||
console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
|
||||
${cyan}2${reset}) Kilo ${dim}(~/.config/kilo)${reset} - open source, the #1 AI coding platform on OpenRouter
|
||||
@@ -5481,7 +5813,8 @@ function promptRuntime(callback) {
|
||||
${cyan}7${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset}
|
||||
${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset}
|
||||
${cyan}9${reset}) Windsurf ${dim}(~/.windsurf)${reset}
|
||||
${cyan}10${reset}) All
|
||||
${cyan}10${reset}) Augment ${dim}(~/.augment)${reset}
|
||||
${cyan}11${reset}) All
|
||||
|
||||
${dim}Select multiple: 1,2,6 or 1 2 6${reset}
|
||||
`);
|
||||
@@ -5492,7 +5825,7 @@ function promptRuntime(callback) {
|
||||
const input = answer.trim() || '1';
|
||||
|
||||
// "All" shortcut
|
||||
if (input === '10') {
|
||||
if (input === '11') {
|
||||
callback(allRuntimes);
|
||||
return;
|
||||
}
|
||||
@@ -5655,11 +5988,16 @@ if (process.env.GSD_TEST_MODE) {
|
||||
convertClaudeCommandToWindsurfSkill,
|
||||
convertClaudeAgentToWindsurfAgent,
|
||||
copyCommandsAsWindsurfSkills,
|
||||
convertClaudeToAugmentMarkdown,
|
||||
convertClaudeCommandToAugmentSkill,
|
||||
convertClaudeAgentToAugmentAgent,
|
||||
copyCommandsAsAugmentSkills,
|
||||
writeManifest,
|
||||
reportLocalPatches,
|
||||
validateHookFields,
|
||||
installSdk,
|
||||
promptSdk,
|
||||
configureOpencodePermissions,
|
||||
};
|
||||
} else {
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gsd:autonomous
|
||||
description: Run all remaining phases autonomously — discuss→plan→execute per phase
|
||||
argument-hint: "[--from N]"
|
||||
argument-hint: "[--from N] [--only N] [--interactive]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
@@ -30,7 +30,10 @@ Uses ROADMAP.md phase discovery and Skill() flat invocations for each phase comm
|
||||
</execution_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.
|
||||
</context>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gsd:debug
|
||||
description: Systematic debugging with persistent state across context resets
|
||||
argument-hint: [issue description]
|
||||
argument-hint: [--diagnose] [issue description]
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Bash
|
||||
@@ -15,6 +15,9 @@ Debug issues using scientific method with subagent isolation.
|
||||
**Orchestrator role:** Gather symptoms, spawn gsd-debugger agent, handle checkpoints, spawn continuations.
|
||||
|
||||
**Why subagent:** Investigation burns context fast (reading files, forming hypotheses, testing). Fresh 200k context per investigation. Main context stays lean for user interaction.
|
||||
|
||||
**Flags:**
|
||||
- `--diagnose` — Diagnose only. Find root cause without applying a fix. Returns a structured Root Cause Report. Use when you want to validate the diagnosis before committing to a fix.
|
||||
</objective>
|
||||
|
||||
<available_agent_types>
|
||||
@@ -25,6 +28,10 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
|
||||
<context>
|
||||
User's issue: $ARGUMENTS
|
||||
|
||||
Parse flags from $ARGUMENTS:
|
||||
- If `--diagnose` is present, set `diagnose_only=true` and remove the flag from the issue description.
|
||||
- Otherwise, `diagnose_only=false`.
|
||||
|
||||
Check for active sessions:
|
||||
```bash
|
||||
ls .planning/debug/*.md 2>/dev/null | grep -v resolved | head -5
|
||||
@@ -87,7 +94,7 @@ timeline: {timeline}
|
||||
|
||||
<mode>
|
||||
symptoms_prefilled: true
|
||||
goal: find_and_fix
|
||||
goal: {if diagnose_only: "find_root_cause_only", else: "find_and_fix"}
|
||||
</mode>
|
||||
|
||||
<debug_file>
|
||||
@@ -106,12 +113,18 @@ Task(
|
||||
|
||||
## 4. Handle Agent Return
|
||||
|
||||
**If `## ROOT CAUSE FOUND`:**
|
||||
- Display root cause and evidence summary
|
||||
**If `## ROOT CAUSE FOUND` (diagnose-only mode):**
|
||||
- Display root cause, confidence level, files involved, and suggested fix strategies
|
||||
- Offer options:
|
||||
- "Fix now" - spawn fix subagent
|
||||
- "Plan fix" - suggest /gsd:plan-phase --gaps
|
||||
- "Manual fix" - done
|
||||
- "Fix now" — spawn a continuation agent with `goal: find_and_fix` to apply the fix (see step 5)
|
||||
- "Plan fix" — suggest `/gsd:plan-phase --gaps`
|
||||
- "Manual fix" — done
|
||||
|
||||
**If `## DEBUG COMPLETE` (find_and_fix mode):**
|
||||
- Display root cause and fix summary
|
||||
- Offer options:
|
||||
- "Plan fix" — suggest `/gsd:plan-phase --gaps` if further work needed
|
||||
- "Done" — mark resolved
|
||||
|
||||
**If `## CHECKPOINT REACHED`:**
|
||||
- Present checkpoint details to user
|
||||
@@ -128,9 +141,9 @@ Task(
|
||||
- "Manual investigation" - done
|
||||
- "Add more context" - gather more symptoms, spawn again
|
||||
|
||||
## 5. Spawn Continuation Agent (After Checkpoint)
|
||||
## 5. Spawn Continuation Agent (After Checkpoint or "Fix now")
|
||||
|
||||
When user responds to checkpoint, spawn fresh agent:
|
||||
When user responds to checkpoint OR selects "Fix now" from diagnose-only results, spawn fresh agent:
|
||||
|
||||
```markdown
|
||||
<objective>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gsd:review
|
||||
description: Request cross-AI peer review of phase plans from external AI CLIs
|
||||
argument-hint: "--phase N [--gemini] [--claude] [--codex] [--all]"
|
||||
argument-hint: "--phase N [--gemini] [--claude] [--codex] [--opencode] [--all]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
@@ -11,7 +11,7 @@ allowed-tools:
|
||||
---
|
||||
|
||||
<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
|
||||
planning via /gsd:plan-phase --reviews.
|
||||
|
||||
@@ -29,6 +29,7 @@ Phase number: extracted from $ARGUMENTS (required)
|
||||
- `--gemini` — Include Gemini CLI review
|
||||
- `--claude` — Include Claude CLI review (uses separate session)
|
||||
- `--codex` — Include Codex CLI review
|
||||
- `--opencode` — Include OpenCode review (uses model from user's OpenCode config)
|
||||
- `--all` — Include all available CLIs
|
||||
</context>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# GET SHIT DONE
|
||||
|
||||
**一个轻量级且强大的元提示、上下文工程和规格驱动开发系统,支持 Claude Code、OpenCode、Gemini CLI、Kilo 和 Codex。**
|
||||
**一个轻量级且强大的元提示、上下文工程和规格驱动开发系统,支持 Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf、Antigravity 和 Augment。**
|
||||
|
||||
**解决上下文衰减 —— 即 Claude 填充上下文窗口时发生的质量退化问题。**
|
||||
|
||||
|
||||
@@ -430,6 +430,11 @@ async function runCommand(command, args, cwd, raw) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'check-commit': {
|
||||
commands.cmdCheckCommit(cwd, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'commit-to-subrepo': {
|
||||
const message = args[1];
|
||||
const filesIndex = args.indexOf('--files');
|
||||
|
||||
@@ -960,6 +960,39 @@ function cmdStats(cwd, format, raw) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a commit should be allowed based on commit_docs config.
|
||||
* When commit_docs is false, rejects commits that stage .planning/ files.
|
||||
* Intended for use as a pre-commit hook guard.
|
||||
*/
|
||||
function cmdCheckCommit(cwd, raw) {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
// If commit_docs is true (or not set), allow all commits
|
||||
if (config.commit_docs !== false) {
|
||||
output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
// commit_docs is false — check if any .planning/ files are staged
|
||||
try {
|
||||
const staged = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8' }).trim();
|
||||
const planningFiles = staged.split('\n').filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\'));
|
||||
|
||||
if (planningFiles.length > 0) {
|
||||
error(
|
||||
`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
|
||||
planningFiles.map(f => ` ${f}`).join('\n') +
|
||||
`\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// git diff --cached failed (no staged files or not a git repo) — allow
|
||||
}
|
||||
|
||||
output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cmdGenerateSlug,
|
||||
cmdCurrentTimestamp,
|
||||
@@ -976,4 +1009,5 @@ module.exports = {
|
||||
cmdTodoMatchPhase,
|
||||
cmdScaffold,
|
||||
cmdStats,
|
||||
cmdCheckCommit,
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ const VALID_CONFIG_KEYS = new Set([
|
||||
'workflow.skip_discuss',
|
||||
'workflow._auto_chain_active',
|
||||
'workflow.use_worktrees',
|
||||
'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
|
||||
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
|
||||
'planning.commit_docs', 'planning.search_gitignored',
|
||||
'workflow.subagent_timeout',
|
||||
'hooks.context_warnings',
|
||||
'project_code', 'phase_naming',
|
||||
]);
|
||||
|
||||
@@ -195,7 +195,7 @@ function safeReadFile(filePath) {
|
||||
}
|
||||
|
||||
function loadConfig(cwd) {
|
||||
const configPath = path.join(cwd, '.planning', 'config.json');
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
const defaults = {
|
||||
model_profile: 'balanced',
|
||||
commit_docs: true,
|
||||
@@ -218,6 +218,7 @@ function loadConfig(cwd) {
|
||||
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
|
||||
phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
|
||||
project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation')
|
||||
subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms)
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -310,6 +311,7 @@ function loadConfig(cwd) {
|
||||
context_window: get('context_window') ?? defaults.context_window,
|
||||
phase_naming: get('phase_naming') ?? defaults.phase_naming,
|
||||
project_code: get('project_code') ?? defaults.project_code,
|
||||
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
|
||||
model_overrides: parsed.model_overrides || null,
|
||||
agent_skills: parsed.agent_skills || {},
|
||||
};
|
||||
@@ -360,20 +362,44 @@ function normalizeMd(content) {
|
||||
const lines = text.split('\n');
|
||||
const result = [];
|
||||
|
||||
// Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
|
||||
const fenceRegex = /^```/;
|
||||
const insideFence = new Array(lines.length);
|
||||
let fenceOpen = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (fenceRegex.test(lines[i].trimEnd())) {
|
||||
if (fenceOpen) {
|
||||
// This is a closing fence — mark as NOT inside (it's the boundary)
|
||||
insideFence[i] = false;
|
||||
fenceOpen = false;
|
||||
} else {
|
||||
// This is an opening fence
|
||||
insideFence[i] = false;
|
||||
fenceOpen = true;
|
||||
}
|
||||
} else {
|
||||
insideFence[i] = fenceOpen;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const prev = i > 0 ? lines[i - 1] : '';
|
||||
const prevTrimmed = prev.trimEnd();
|
||||
const trimmed = line.trimEnd();
|
||||
const isFenceLine = fenceRegex.test(trimmed);
|
||||
|
||||
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
|
||||
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
|
||||
result.push('');
|
||||
}
|
||||
|
||||
// MD031: Blank line before fenced code blocks
|
||||
if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
|
||||
result.push('');
|
||||
// MD031: Blank line before fenced code blocks (opening fences only)
|
||||
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
|
||||
// Only add blank before opening fences (not closing ones)
|
||||
if (i === 0 || !insideFence[i - 1]) {
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
|
||||
@@ -394,7 +420,7 @@ function normalizeMd(content) {
|
||||
}
|
||||
|
||||
// MD031: Blank line after closing fenced code blocks
|
||||
if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
|
||||
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
|
||||
const next = lines[i + 1];
|
||||
if (next !== undefined && next.trimEnd() !== '') {
|
||||
result.push('');
|
||||
@@ -424,24 +450,6 @@ function normalizeMd(content) {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Check if line index i is inside an already-open fenced code block */
|
||||
function isInsideFencedBlock(lines, i) {
|
||||
let fenceCount = 0;
|
||||
for (let j = 0; j < i; j++) {
|
||||
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
||||
}
|
||||
return fenceCount % 2 === 1;
|
||||
}
|
||||
|
||||
/** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */
|
||||
function isClosingFence(lines, i) {
|
||||
let fenceCount = 0;
|
||||
for (let j = 0; j <= i; j++) {
|
||||
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
||||
}
|
||||
return fenceCount % 2 === 0;
|
||||
}
|
||||
|
||||
function execGit(cwd, args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
@@ -542,20 +550,43 @@ function withPlanningLock(cwd, fn) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the .planning directory path, workstream-aware.
|
||||
* When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var),
|
||||
* returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`.
|
||||
* Get the .planning directory path, project- and workstream-aware.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to
|
||||
* `.planning/{project}/` — supports multi-project workspaces where several
|
||||
* independent projects share a single `.planning/` root directory (e.g.,
|
||||
* an Obsidian vault or monorepo knowledge base used as a command center).
|
||||
* 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`.
|
||||
* 3. Otherwise returns `.planning/`.
|
||||
*
|
||||
* GSD_PROJECT and GSD_WORKSTREAM can be combined:
|
||||
* `.planning/{project}/workstreams/{ws}/`
|
||||
*
|
||||
* @param {string} cwd - project root
|
||||
* @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var
|
||||
* @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var
|
||||
*/
|
||||
function planningDir(cwd, ws) {
|
||||
function planningDir(cwd, ws, project) {
|
||||
if (project === undefined) project = process.env.GSD_PROJECT || null;
|
||||
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
|
||||
if (!ws) return path.join(cwd, '.planning');
|
||||
return path.join(cwd, '.planning', 'workstreams', ws);
|
||||
|
||||
// Reject path separators and traversal components in project/workstream names
|
||||
const BAD_SEGMENT = /[/\\]|\.\./;
|
||||
if (project && BAD_SEGMENT.test(project)) {
|
||||
throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`);
|
||||
}
|
||||
if (ws && BAD_SEGMENT.test(ws)) {
|
||||
throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`);
|
||||
}
|
||||
|
||||
let base = path.join(cwd, '.planning');
|
||||
if (project) base = path.join(base, project);
|
||||
if (ws) base = path.join(base, 'workstreams', ws);
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */
|
||||
/** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */
|
||||
function planningRoot(cwd) {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
@@ -252,6 +252,19 @@ function parseMustHavesBlock(content, blockName) {
|
||||
}
|
||||
if (current) items.push(current);
|
||||
|
||||
// Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
|
||||
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
|
||||
// to Option C (LLM-derived truths) instead of checking documented contracts.
|
||||
if (items.length === 0 && blockLines.length > 0) {
|
||||
const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
|
||||
if (nonEmptyLines > 0) {
|
||||
process.stderr.write(
|
||||
`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
|
||||
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -780,6 +780,7 @@ function cmdInitMapCodebase(cwd, raw) {
|
||||
commit_docs: config.commit_docs,
|
||||
search_gitignored: config.search_gitignored,
|
||||
parallelization: config.parallelization,
|
||||
subagent_timeout: config.subagent_timeout,
|
||||
|
||||
// Paths
|
||||
codebase_dir: '.planning/codebase',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error, readSubdirectories } = require('./core.cjs');
|
||||
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories } = require('./core.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs');
|
||||
|
||||
@@ -328,57 +328,62 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
||||
error('ROADMAP.md not found');
|
||||
}
|
||||
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
const slug = generateSlugInternal(description);
|
||||
|
||||
let newPhaseId;
|
||||
let dirName;
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
|
||||
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
|
||||
const projectCode = config.project_code || '';
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
|
||||
const projectCode = config.project_code || '';
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
|
||||
if (customId || config.phase_naming === 'custom') {
|
||||
// Custom phase naming: use provided ID or generate from description
|
||||
newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
|
||||
if (!newPhaseId) error('--id required when phase_naming is "custom"');
|
||||
dirName = `${prefix}${newPhaseId}-${slug}`;
|
||||
} else {
|
||||
// Sequential mode: find highest integer phase number (in current milestone only)
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
let maxPhase = 0;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
let _newPhaseId;
|
||||
let _dirName;
|
||||
|
||||
if (customId || config.phase_naming === 'custom') {
|
||||
// Custom phase naming: use provided ID or generate from description
|
||||
_newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
|
||||
if (!_newPhaseId) error('--id required when phase_naming is "custom"');
|
||||
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
||||
} else {
|
||||
// Sequential mode: find highest integer phase number (in current milestone only)
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
let maxPhase = 0;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
}
|
||||
|
||||
_newPhaseId = maxPhase + 1;
|
||||
const paddedNum = String(_newPhaseId).padStart(2, '0');
|
||||
_dirName = `${prefix}${paddedNum}-${slug}`;
|
||||
}
|
||||
|
||||
newPhaseId = maxPhase + 1;
|
||||
const paddedNum = String(newPhaseId).padStart(2, '0');
|
||||
dirName = `${prefix}${paddedNum}-${slug}`;
|
||||
}
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||||
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
// Build phase entry
|
||||
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
|
||||
const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_newPhaseId} to break down)\n`;
|
||||
|
||||
// Build phase entry
|
||||
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
||||
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseId} to break down)\n`;
|
||||
// Find insertion point: before last "---" or at end
|
||||
let updatedContent;
|
||||
const lastSeparator = rawContent.lastIndexOf('\n---');
|
||||
if (lastSeparator > 0) {
|
||||
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
|
||||
} else {
|
||||
updatedContent = rawContent + phaseEntry;
|
||||
}
|
||||
|
||||
// Find insertion point: before last "---" or at end
|
||||
let updatedContent;
|
||||
const lastSeparator = rawContent.lastIndexOf('\n---');
|
||||
if (lastSeparator > 0) {
|
||||
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
|
||||
} else {
|
||||
updatedContent = rawContent + phaseEntry;
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
||||
});
|
||||
|
||||
const result = {
|
||||
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
||||
@@ -402,71 +407,75 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
||||
error('ROADMAP.md not found');
|
||||
}
|
||||
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
const slug = generateSlugInternal(description);
|
||||
|
||||
// Normalize input then strip leading zeros for flexible matching
|
||||
const normalizedAfter = normalizePhaseName(afterPhase);
|
||||
const unpadded = normalizedAfter.replace(/^0+/, '');
|
||||
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
|
||||
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
||||
if (!targetPattern.test(content)) {
|
||||
error(`Phase ${afterPhase} not found in ROADMAP.md`);
|
||||
}
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
|
||||
// Calculate next decimal using existing logic
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const normalizedBase = normalizePhaseName(afterPhase);
|
||||
let existingDecimals = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`);
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(decimalPattern);
|
||||
if (dm) existingDecimals.push(parseInt(dm[1], 10));
|
||||
// Normalize input then strip leading zeros for flexible matching
|
||||
const normalizedAfter = normalizePhaseName(afterPhase);
|
||||
const unpadded = normalizedAfter.replace(/^0+/, '');
|
||||
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
|
||||
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
||||
if (!targetPattern.test(content)) {
|
||||
error(`Phase ${afterPhase} not found in ROADMAP.md`);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
|
||||
const decimalPhase = `${normalizedBase}.${nextDecimal}`;
|
||||
// Calculate next decimal using existing logic
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const normalizedBase = normalizePhaseName(afterPhase);
|
||||
let existingDecimals = [];
|
||||
|
||||
// Optional project code prefix
|
||||
const config = loadConfig(cwd);
|
||||
const projectCode = config.project_code || '';
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
const dirName = `${prefix}${decimalPhase}-${slug}`;
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`);
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(decimalPattern);
|
||||
if (dm) existingDecimals.push(parseInt(dm[1], 10));
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
|
||||
const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
|
||||
// Optional project code prefix
|
||||
const insertConfig = loadConfig(cwd);
|
||||
const projectCode = insertConfig.project_code || '';
|
||||
const pfx = projectCode ? `${projectCode}-` : '';
|
||||
const _dirName = `${pfx}${_decimalPhase}-${slug}`;
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||||
|
||||
// Build phase entry
|
||||
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`;
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
|
||||
// Insert after the target phase section
|
||||
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
||||
const headerMatch = rawContent.match(headerPattern);
|
||||
if (!headerMatch) {
|
||||
error(`Could not find Phase ${afterPhase} header`);
|
||||
}
|
||||
// Build phase entry
|
||||
const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_decimalPhase} to break down)\n`;
|
||||
|
||||
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
||||
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
||||
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
// Insert after the target phase section
|
||||
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
||||
const headerMatch = rawContent.match(headerPattern);
|
||||
if (!headerMatch) {
|
||||
error(`Could not find Phase ${afterPhase} header`);
|
||||
}
|
||||
|
||||
let insertIdx;
|
||||
if (nextPhaseMatch) {
|
||||
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
||||
} else {
|
||||
insertIdx = rawContent.length;
|
||||
}
|
||||
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
||||
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
||||
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
|
||||
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
let insertIdx;
|
||||
if (nextPhaseMatch) {
|
||||
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
||||
} else {
|
||||
insertIdx = rawContent.length;
|
||||
}
|
||||
|
||||
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
return { decimalPhase: _decimalPhase, dirName: _dirName };
|
||||
});
|
||||
|
||||
const result = {
|
||||
phase_number: decimalPhase,
|
||||
@@ -554,29 +563,32 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
||||
/**
|
||||
* Remove a phase section from ROADMAP.md and renumber all subsequent integer phases.
|
||||
*/
|
||||
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt) {
|
||||
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const escaped = escapeRegex(targetPhase);
|
||||
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
withPlanningLock(cwd, () => {
|
||||
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const escaped = escapeRegex(targetPhase);
|
||||
|
||||
content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), '');
|
||||
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
|
||||
content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
|
||||
content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), '');
|
||||
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
|
||||
content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
|
||||
|
||||
if (!isDecimal) {
|
||||
const MAX_PHASE = 99;
|
||||
for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
|
||||
const newNum = oldNum - 1;
|
||||
const oldStr = String(oldNum), newStr = String(newNum);
|
||||
const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0');
|
||||
content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`);
|
||||
content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `);
|
||||
content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`);
|
||||
if (!isDecimal) {
|
||||
const MAX_PHASE = 99;
|
||||
for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
|
||||
const newNum = oldNum - 1;
|
||||
const oldStr = String(oldNum), newStr = String(newNum);
|
||||
const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0');
|
||||
content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`);
|
||||
content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `);
|
||||
content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, content, 'utf-8');
|
||||
fs.writeFileSync(roadmapPath, content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
||||
@@ -617,7 +629,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Update ROADMAP.md
|
||||
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10));
|
||||
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
|
||||
|
||||
// Update STATE.md phase count
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
@@ -686,98 +698,100 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Update ROADMAP.md: mark phase complete
|
||||
// Update ROADMAP.md and REQUIREMENTS.md atomically under lock
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
withPlanningLock(cwd, () => {
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
|
||||
// Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1);
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ' Complete ';
|
||||
cells[4] = ` ${today} `;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ' Complete ';
|
||||
cells[3] = ` ${today} `;
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update plan count in phase section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(
|
||||
roadmapContent, planCountPattern,
|
||||
`$1${summaryCount}/${planCount} plans complete`
|
||||
);
|
||||
|
||||
// Mark completed plan checkboxes (safety net for missed per-plan updates)
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
// Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1);
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ' Complete ';
|
||||
cells[4] = ` ${today} `;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ' Complete ';
|
||||
cells[3] = ` ${today} `;
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update REQUIREMENTS.md traceability for this phase's requirements
|
||||
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
|
||||
if (fs.existsSync(reqPath)) {
|
||||
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
|
||||
const phaseEsc = escapeRegex(phaseNum);
|
||||
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const phaseSectionMatch = currentMilestoneRoadmap.match(
|
||||
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
|
||||
// Update plan count in phase section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(
|
||||
roadmapContent, planCountPattern,
|
||||
`$1${summaryCount}/${planCount} plans complete`
|
||||
);
|
||||
|
||||
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
||||
const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
|
||||
|
||||
if (reqMatch) {
|
||||
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
||||
'$1x$2'
|
||||
);
|
||||
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
|
||||
'$1 Complete $2'
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(reqPath, reqContent, 'utf-8');
|
||||
requirementsUpdated = true;
|
||||
// Mark completed plan checkboxes (safety net for missed per-plan updates)
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
|
||||
// Update REQUIREMENTS.md traceability for this phase's requirements
|
||||
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
|
||||
if (fs.existsSync(reqPath)) {
|
||||
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
|
||||
const phaseEsc = escapeRegex(phaseNum);
|
||||
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const phaseSectionMatch = currentMilestoneRoadmap.match(
|
||||
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
|
||||
);
|
||||
|
||||
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
||||
const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
|
||||
|
||||
if (reqMatch) {
|
||||
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
||||
'$1x$2'
|
||||
);
|
||||
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
|
||||
'$1 Complete $2'
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(reqPath, reqContent, 'utf-8');
|
||||
requirementsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find next phase — check both filesystem AND roadmap
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, normalizePhaseName, planningPaths, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
|
||||
const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
|
||||
|
||||
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
@@ -254,64 +254,67 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
withPlanningLock(cwd, () => {
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
|
||||
// Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
const dateField = isComplete ? ` ${today} ` : ' ';
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ` ${status.padEnd(11)}`;
|
||||
cells[4] = dateField;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ` ${status.padEnd(11)}`;
|
||||
cells[3] = dateField;
|
||||
// Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
const dateField = isComplete ? ` ${today} ` : ' ';
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ` ${status.padEnd(11)}`;
|
||||
cells[4] = dateField;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ` ${status.padEnd(11)}`;
|
||||
cells[3] = dateField;
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update plan count in phase detail section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
const planCountText = isComplete
|
||||
? `${summaryCount}/${planCount} plans complete`
|
||||
: `${summaryCount}/${planCount} plans executed`;
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
|
||||
|
||||
// If complete: check checkbox
|
||||
if (isComplete) {
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
|
||||
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:")
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
});
|
||||
|
||||
// Update plan count in phase detail section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
const planCountText = isComplete
|
||||
? `${summaryCount}/${planCount} plans complete`
|
||||
: `${summaryCount}/${planCount} plans executed`;
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
|
||||
|
||||
// If complete: check checkbox
|
||||
if (isComplete) {
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
}
|
||||
|
||||
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:")
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
|
||||
output({
|
||||
updated: true,
|
||||
phase: phaseNum,
|
||||
|
||||
@@ -141,29 +141,28 @@ function cmdStatePatch(cwd, patches, raw) {
|
||||
|
||||
const statePath = planningPaths(cwd).state;
|
||||
try {
|
||||
let content = fs.readFileSync(statePath, 'utf-8');
|
||||
const results = { updated: [], failed: [] };
|
||||
|
||||
for (const [field, value] of Object.entries(patches)) {
|
||||
const fieldEscaped = escapeRegex(field);
|
||||
// Try **Field:** bold format first, then plain Field: format
|
||||
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
|
||||
// Use atomic read-modify-write to prevent lost updates from concurrent agents
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
for (const [field, value] of Object.entries(patches)) {
|
||||
const fieldEscaped = escapeRegex(field);
|
||||
// Try **Field:** bold format first, then plain Field: format
|
||||
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
|
||||
|
||||
if (boldPattern.test(content)) {
|
||||
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else if (plainPattern.test(content)) {
|
||||
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else {
|
||||
results.failed.push(field);
|
||||
if (boldPattern.test(content)) {
|
||||
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else if (plainPattern.test(content)) {
|
||||
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else {
|
||||
results.failed.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.updated.length > 0) {
|
||||
writeStateMd(statePath, content, cwd);
|
||||
}
|
||||
return content;
|
||||
}, cwd);
|
||||
|
||||
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
||||
} catch {
|
||||
@@ -236,6 +235,12 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
|
||||
result = stateReplaceField(content, fallback, value);
|
||||
if (result) return result;
|
||||
}
|
||||
// Neither pattern matched — field may have been reformatted or removed.
|
||||
// Log diagnostic so template drift is detected early rather than silently swallowed.
|
||||
process.stderr.write(
|
||||
`[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
|
||||
`This may indicate STATE.md was externally modified or uses an unexpected format.\n`
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -780,6 +785,50 @@ function syncStateFrontmatter(content, cwd) {
|
||||
return `---\n${yamlStr}\n---\n\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a lockfile for STATE.md operations.
|
||||
* Returns the lock path for later release.
|
||||
*/
|
||||
function acquireStateLock(statePath) {
|
||||
const lockPath = statePath + '.lock';
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 200; // ms
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
||||
fs.writeSync(fd, String(process.pid));
|
||||
fs.closeSync(fd);
|
||||
return lockPath;
|
||||
} catch (err) {
|
||||
if (err.code === 'EEXIST') {
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > 10000) {
|
||||
fs.unlinkSync(lockPath);
|
||||
continue;
|
||||
}
|
||||
} catch { /* lock was released between check — retry */ }
|
||||
|
||||
if (i === maxRetries - 1) {
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
return lockPath;
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * 50);
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
|
||||
continue;
|
||||
}
|
||||
return lockPath; // non-EEXIST error — proceed without lock
|
||||
}
|
||||
}
|
||||
return statePath + '.lock';
|
||||
}
|
||||
|
||||
function releaseStateLock(lockPath) {
|
||||
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write STATE.md with synchronized YAML frontmatter.
|
||||
* All STATE.md writes should use this instead of raw writeFileSync.
|
||||
@@ -788,48 +837,29 @@ function syncStateFrontmatter(content, cwd) {
|
||||
*/
|
||||
function writeStateMd(statePath, content, cwd) {
|
||||
const synced = syncStateFrontmatter(content, cwd);
|
||||
const lockPath = statePath + '.lock';
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 200; // ms
|
||||
|
||||
// Acquire lock (spin with backoff)
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
// O_EXCL fails if file already exists — atomic lock
|
||||
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
||||
fs.writeSync(fd, String(process.pid));
|
||||
fs.closeSync(fd);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code === 'EEXIST') {
|
||||
// Check for stale lock (> 10s old)
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > 10000) {
|
||||
fs.unlinkSync(lockPath);
|
||||
continue; // retry immediately after clearing stale lock
|
||||
}
|
||||
} catch { /* lock was released between check — retry */ }
|
||||
|
||||
if (i === maxRetries - 1) {
|
||||
// Last resort: write anyway rather than losing data
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
break;
|
||||
}
|
||||
// Spin-wait with small jitter
|
||||
const jitter = Math.floor(Math.random() * 50);
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
|
||||
continue;
|
||||
}
|
||||
break; // non-EEXIST error — proceed without lock
|
||||
}
|
||||
}
|
||||
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
|
||||
} finally {
|
||||
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write for STATE.md.
|
||||
* Holds the lock across the entire read -> transform -> write cycle,
|
||||
* preventing the lost-update problem where two agents read the same
|
||||
* content and the second write clobbers the first.
|
||||
*/
|
||||
function readModifyWriteStateMd(statePath, transformFn, cwd) {
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
||||
const modified = transformFn(content);
|
||||
const synced = syncStateFrontmatter(modified, cwd);
|
||||
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { safeReadFile, loadConfig, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error, checkAgentsInstalled } = require('./core.cjs');
|
||||
const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error, checkAgentsInstalled } = require('./core.cjs');
|
||||
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
||||
const { writeStateMd } = require('./state.cjs');
|
||||
|
||||
@@ -757,6 +757,71 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Check 9: STATE.md / ROADMAP.md cross-validation ─────────────────────
|
||||
if (fs.existsSync(statePath) && fs.existsSync(roadmapPath)) {
|
||||
try {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
// Extract current phase from STATE.md
|
||||
const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/i) ||
|
||||
stateContent.match(/Current Phase:\s*(\S+)/i);
|
||||
if (currentPhaseMatch) {
|
||||
const statePhase = currentPhaseMatch[1].replace(/^0+/, '');
|
||||
// Check if ROADMAP shows this phase as already complete
|
||||
const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}[:\\s]`, 'i');
|
||||
if (phaseCheckboxRe.test(roadmapContentFull)) {
|
||||
// STATE says "current" but ROADMAP says "complete" — divergence
|
||||
const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i);
|
||||
const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : '';
|
||||
if (statusVal !== 'complete' && statusVal !== 'done') {
|
||||
addIssue('warning', 'W011',
|
||||
`STATE.md says current phase is ${statePhase} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`,
|
||||
'Run /gsd:progress to re-derive current position, or manually update STATE.md');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty — cross-validation is advisory */ }
|
||||
}
|
||||
|
||||
// ─── Check 10: Config field validation ────────────────────────────────────
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
||||
const configParsed = JSON.parse(configRaw);
|
||||
|
||||
// Validate branching_strategy
|
||||
const validStrategies = ['none', 'phase', 'milestone'];
|
||||
if (configParsed.branching_strategy && !validStrategies.includes(configParsed.branching_strategy)) {
|
||||
addIssue('warning', 'W012',
|
||||
`config.json: invalid branching_strategy "${configParsed.branching_strategy}"`,
|
||||
`Valid values: ${validStrategies.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate context_window is a positive integer
|
||||
if (configParsed.context_window !== undefined) {
|
||||
const cw = configParsed.context_window;
|
||||
if (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw)) {
|
||||
addIssue('warning', 'W013',
|
||||
`config.json: context_window should be a positive integer, got "${cw}"`,
|
||||
'Set to 200000 (default) or 1000000 (for 1M models)');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate branch templates have required placeholders
|
||||
if (configParsed.phase_branch_template && !configParsed.phase_branch_template.includes('{phase}')) {
|
||||
addIssue('warning', 'W014',
|
||||
'config.json: phase_branch_template missing {phase} placeholder',
|
||||
'Template must include {phase} for phase number substitution');
|
||||
}
|
||||
if (configParsed.milestone_branch_template && !configParsed.milestone_branch_template.includes('{milestone}')) {
|
||||
addIssue('warning', 'W015',
|
||||
'config.json: milestone_branch_template missing {milestone} placeholder',
|
||||
'Template must include {milestone} for version substitution');
|
||||
}
|
||||
} catch { /* parse error already caught in Check 5 */ }
|
||||
}
|
||||
|
||||
// ─── Perform repairs if requested ─────────────────────────────────────────
|
||||
const repairActions = [];
|
||||
if (options.repair && repairs.length > 0) {
|
||||
|
||||
@@ -10,6 +10,7 @@ Configuration options for `.planning/` directory behavior.
|
||||
},
|
||||
"git": {
|
||||
"branching_strategy": "none",
|
||||
"base_branch": null,
|
||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||
"quick_branch_template": null
|
||||
@@ -21,10 +22,12 @@ Configuration options for `.planning/` directory behavior.
|
||||
| `commit_docs` | `true` | Whether to commit planning artifacts to git |
|
||||
| `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches |
|
||||
| `git.branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` |
|
||||
| `git.base_branch` | `null` (auto-detect) | Target branch for PRs and merges (e.g. `"master"`, `"develop"`). When `null`, auto-detects from `git symbolic-ref refs/remotes/origin/HEAD`, falling back to `"main"`. |
|
||||
| `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy |
|
||||
| `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy |
|
||||
| `git.quick_branch_template` | `null` | Optional branch template for quick-task runs |
|
||||
| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. |
|
||||
| `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). |
|
||||
</config_schema>
|
||||
|
||||
<commit_docs_behavior>
|
||||
|
||||
@@ -16,7 +16,7 @@ Read all files referenced by the invoking prompt's execution_context before star
|
||||
|
||||
## 1. Initialize
|
||||
|
||||
Parse `$ARGUMENTS` for `--from N` and `--only N` flags:
|
||||
Parse `$ARGUMENTS` for `--from N`, `--only N`, and `--interactive` flags:
|
||||
|
||||
```bash
|
||||
FROM_PHASE=""
|
||||
@@ -29,10 +29,17 @@ if echo "$ARGUMENTS" | grep -qE '\-\-only\s+[0-9]'; then
|
||||
ONLY_PHASE=$(echo "$ARGUMENTS" | grep -oE '\-\-only\s+[0-9]+\.?[0-9]*' | awk '{print $2}')
|
||||
FROM_PHASE="$ONLY_PHASE"
|
||||
fi
|
||||
|
||||
INTERACTIVE=""
|
||||
if echo "$ARGUMENTS" | grep -q '\-\-interactive'; then
|
||||
INTERACTIVE="true"
|
||||
fi
|
||||
```
|
||||
|
||||
When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies.
|
||||
|
||||
When `--interactive` is set, discuss runs inline with questions (not auto-answered), while plan and execute are dispatched as background agents. This keeps the main context lean — only discuss conversations accumulate — while preserving user input on all design decisions.
|
||||
|
||||
Bootstrap via milestone-level init:
|
||||
|
||||
```bash
|
||||
@@ -58,6 +65,7 @@ Display startup banner:
|
||||
|
||||
If `ONLY_PHASE` is set, display: `Single phase mode: Phase ${ONLY_PHASE}`
|
||||
Else if `FROM_PHASE` is set, display: `Starting from phase ${FROM_PHASE}`
|
||||
If `INTERACTIVE` is set, display: `Mode: Interactive (discuss inline, plan+execute in background)`
|
||||
|
||||
</step>
|
||||
|
||||
@@ -230,18 +238,26 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${PADDED_PHASE
|
||||
|
||||
Proceed to 3b.
|
||||
|
||||
**If SKIP_DISCUSS is `false` (or unset):** Execute the smart_discuss step for this phase.
|
||||
**If SKIP_DISCUSS is `false` (or unset):**
|
||||
|
||||
**IMPORTANT — Discuss must be single-pass in autonomous mode.**
|
||||
The discuss step in `--auto` mode MUST NOT loop. If CONTEXT.md already exists after discuss completes, do NOT re-invoke discuss for the same phase. The `has_context` check below is authoritative — once true, discuss is done for this phase regardless of perceived "gaps" in the context file.
|
||||
|
||||
After smart_discuss completes, verify context was written:
|
||||
**If `INTERACTIVE` is set:** Run the standard discuss-phase skill inline (asks interactive questions, waits for user answers). This preserves user input on all design decisions while keeping plan+execute out of the main context:
|
||||
|
||||
```
|
||||
Skill(skill="gsd:discuss-phase", args="${PHASE_NUM}")
|
||||
```
|
||||
|
||||
**If `INTERACTIVE` is NOT set:** Execute the smart_discuss step for this phase (batch table proposals, auto-optimized).
|
||||
|
||||
After discuss completes (either mode), verify context was written:
|
||||
|
||||
```bash
|
||||
PHASE_STATE=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init phase-op ${PHASE_NUM})
|
||||
```
|
||||
|
||||
Check `has_context`. If false → go to handle_blocker: "Smart discuss for phase ${PHASE_NUM} did not produce CONTEXT.md."
|
||||
Check `has_context`. If false → go to handle_blocker: "Discuss for phase ${PHASE_NUM} did not produce CONTEXT.md."
|
||||
|
||||
**3a.5. UI Design Contract (Frontend Phases)**
|
||||
|
||||
@@ -284,6 +300,20 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
|
||||
|
||||
**3b. Plan**
|
||||
|
||||
**If `INTERACTIVE` is set:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4).
|
||||
|
||||
```
|
||||
Agent(
|
||||
description="Plan phase ${PHASE_NUM}: ${PHASE_NAME}",
|
||||
run_in_background=true,
|
||||
prompt="Run plan-phase for phase ${PHASE_NUM}: Skill(skill=\"gsd:plan-phase\", args=\"${PHASE_NUM}\")"
|
||||
)
|
||||
```
|
||||
|
||||
Store the agent task_id. After discuss for the next phase completes (or if no next phase), wait for the plan agent to finish before proceeding to execute.
|
||||
|
||||
**If `INTERACTIVE` is NOT set (default):** Run plan inline as before.
|
||||
|
||||
```
|
||||
Skill(skill="gsd:plan-phase", args="${PHASE_NUM}")
|
||||
```
|
||||
@@ -292,13 +322,29 @@ Verify plan produced output — re-run `init phase-op` and check `has_plans`. If
|
||||
|
||||
**3c. Execute**
|
||||
|
||||
**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already), verify plans exist, then dispatch execute as a background agent:
|
||||
|
||||
```
|
||||
Agent(
|
||||
description="Execute phase ${PHASE_NUM}: ${PHASE_NAME}",
|
||||
run_in_background=true,
|
||||
prompt="Run execute-phase for phase ${PHASE_NUM}: Skill(skill=\"gsd:execute-phase\", args=\"${PHASE_NUM} --no-transition\")"
|
||||
)
|
||||
```
|
||||
|
||||
Store the agent task_id. The workflow can now start discussing the next phase while this phase executes in the background. Before starting post-execution routing for this phase, wait for the execute agent to complete.
|
||||
|
||||
**If `INTERACTIVE` is NOT set (default):** Run execute inline as before.
|
||||
|
||||
```
|
||||
Skill(skill="gsd:execute-phase", args="${PHASE_NUM} --no-transition")
|
||||
```
|
||||
|
||||
**3d. Post-Execution Routing**
|
||||
|
||||
After execute-phase returns, read the verification result:
|
||||
**If `INTERACTIVE` is set:** Wait for the execute agent to complete before reading verification results.
|
||||
|
||||
After execute-phase returns (or the execute agent completes), read the verification result:
|
||||
|
||||
```bash
|
||||
VERIFY_STATUS=$(grep "^status:" "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1 | cut -d: -f2 | tr -d ' ')
|
||||
@@ -733,6 +779,13 @@ Check for blockers in the Blockers/Concerns section. If blockers are found, go t
|
||||
|
||||
If incomplete phases remain: proceed to next phase, loop back to execute_phase.
|
||||
|
||||
**Interactive mode overlap:** When `INTERACTIVE` is set, the iterate step enables pipeline parallelism:
|
||||
1. After discuss completes for Phase N, dispatch plan+execute as background agents
|
||||
2. Immediately start discuss for Phase N+1 (the next incomplete phase) while Phase N builds
|
||||
3. Before starting plan for Phase N+1, wait for Phase N's execute agent to complete and handle its post-execution routing (verification, gap closure, etc.)
|
||||
|
||||
This means the user is always answering discuss questions (lightweight, interactive) while the heavy work (planning, code generation) runs in the background. The main context only accumulates discuss conversations — plan and execute contexts are isolated in their agents.
|
||||
|
||||
If all phases complete, proceed to lifecycle step.
|
||||
|
||||
</step>
|
||||
@@ -935,4 +988,10 @@ When any phase operation fails or a blocker is detected, present 3 options via A
|
||||
- [ ] `--only N` exits cleanly after single phase completes
|
||||
- [ ] `--only N` on already-complete phase exits with message
|
||||
- [ ] `--only N` handle_blocker resume message uses --only flag
|
||||
- [ ] `--interactive` runs discuss inline via gsd:discuss-phase (asks questions, waits for user)
|
||||
- [ ] `--interactive` dispatches plan and execute as background agents (context isolation)
|
||||
- [ ] `--interactive` enables pipeline parallelism: discuss Phase N+1 while Phase N builds
|
||||
- [ ] `--interactive` main context only accumulates discuss conversations (lean)
|
||||
- [ ] `--interactive` waits for background agents before post-execution routing
|
||||
- [ ] `--interactive` compatible with `--only` and `--from` flags
|
||||
</success_criteria>
|
||||
|
||||
@@ -537,6 +537,15 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
|
||||
Extract `branching_strategy`, `phase_branch_template`, `milestone_branch_template`, and `commit_docs` from init JSON.
|
||||
|
||||
Detect base branch:
|
||||
```bash
|
||||
BASE_BRANCH=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get git.base_branch 2>/dev/null || echo "")
|
||||
if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then
|
||||
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||')
|
||||
BASE_BRANCH="${BASE_BRANCH:-main}"
|
||||
fi
|
||||
```
|
||||
|
||||
**If "none":** Skip to git_tag.
|
||||
|
||||
**For "phase" strategy:**
|
||||
@@ -575,7 +584,7 @@ AskUserQuestion with options: Squash merge (Recommended), Merge with history, De
|
||||
|
||||
```bash
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
git checkout main
|
||||
git checkout ${BASE_BRANCH}
|
||||
|
||||
if [ "$BRANCHING_STRATEGY" = "phase" ]; then
|
||||
for branch in $PHASE_BRANCHES; do
|
||||
@@ -604,7 +613,7 @@ git checkout "$CURRENT_BRANCH"
|
||||
|
||||
```bash
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
git checkout main
|
||||
git checkout ${BASE_BRANCH}
|
||||
|
||||
if [ "$BRANCHING_STRATEGY" = "phase" ]; then
|
||||
for branch in $PHASE_BRANCHES; do
|
||||
|
||||
@@ -76,6 +76,17 @@ USE_WORKTREES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get
|
||||
|
||||
When `USE_WORKTREES` is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees.
|
||||
|
||||
Read context window size for adaptive prompt enrichment:
|
||||
|
||||
```bash
|
||||
CONTEXT_WINDOW=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get context_window 2>/dev/null || echo "200000")
|
||||
```
|
||||
|
||||
When `CONTEXT_WINDOW >= 500000` (1M-class models), subagent prompts include richer context:
|
||||
- Executor agents receive prior wave SUMMARY.md files and the phase CONTEXT.md/RESEARCH.md
|
||||
- Verifier agents receive all PLAN.md, SUMMARY.md, CONTEXT.md files plus REQUIREMENTS.md
|
||||
- This enables cross-phase awareness and history-aware verification
|
||||
|
||||
**If `phase_found` is false:** Error — phase directory not found.
|
||||
**If `plan_count` is 0:** Error — no plans found in phase.
|
||||
**If `state_exists` is false but `.planning/` exists:** Offer reconstruct or continue.
|
||||
@@ -236,6 +247,7 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
|
||||
```
|
||||
Task(
|
||||
subagent_type="gsd-executor",
|
||||
description="Execute plan {plan_number} of phase {phase_number}",
|
||||
model="{executor_model}",
|
||||
isolation="worktree",
|
||||
prompt="
|
||||
@@ -265,6 +277,11 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT
|
||||
- .planning/PROJECT.md (Project context — core value, requirements, evolution rules)
|
||||
- .planning/STATE.md (State)
|
||||
- .planning/config.json (Config, if exists)
|
||||
${CONTEXT_WINDOW >= 500000 ? `
|
||||
- ${phase_dir}/*-CONTEXT.md (User decisions from discuss-phase — honors locked choices)
|
||||
- ${phase_dir}/*-RESEARCH.md (Technical research — pitfalls and patterns to follow)
|
||||
- ${prior_wave_summaries} (SUMMARY.md files from earlier waves in this phase — what was already built)
|
||||
` : ''}
|
||||
- ./CLAUDE.md (Project instructions, if exists — follow project-specific guidelines and coding conventions)
|
||||
- .claude/skills/ or .agents/skills/ (Project skills, if either exists — list skills, read SKILL.md for each, follow relevant rules during implementation)
|
||||
</files_to_read>
|
||||
@@ -732,6 +749,7 @@ VERIFIER_SKILLS=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-ski
|
||||
|
||||
```
|
||||
Task(
|
||||
description="Verify phase {phase_number} goal achievement",
|
||||
prompt="Verify phase {phase_number} goal achievement.
|
||||
Phase directory: {phase_dir}
|
||||
Phase goal: {goal from ROADMAP.md}
|
||||
@@ -739,6 +757,18 @@ Phase requirement IDs: {phase_req_ids}
|
||||
Check must_haves against actual codebase.
|
||||
Cross-reference requirement IDs from PLAN frontmatter against REQUIREMENTS.md — every ID MUST be accounted for.
|
||||
Create VERIFICATION.md.
|
||||
|
||||
<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}",
|
||||
subagent_type="gsd-verifier",
|
||||
model="{verifier_model}"
|
||||
|
||||
@@ -36,7 +36,7 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_MAPPER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-codebase-mapper 2>/dev/null)
|
||||
```
|
||||
|
||||
Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`.
|
||||
Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`, `subagent_timeout`.
|
||||
</step>
|
||||
|
||||
<step name="check_existing">
|
||||
@@ -199,9 +199,11 @@ Wait for all 4 agents to complete using TaskOutput tool.
|
||||
TaskOutput tool:
|
||||
task_id: "{task_id from Agent result}"
|
||||
block: true
|
||||
timeout: 300000
|
||||
timeout: {subagent_timeout from init context, default 300000}
|
||||
```
|
||||
|
||||
> The timeout is configurable via `workflow.subagent_timeout` in `.planning/config.json` (milliseconds). Default: 300000 (5 minutes). Increase for large codebases or slower models.
|
||||
|
||||
Call TaskOutput for all 4 agents in parallel (single message with 4 TaskOutput calls).
|
||||
|
||||
Once all TaskOutput calls return, read each agent's output file to collect confirmations.
|
||||
|
||||
@@ -27,8 +27,11 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_RESEARCHER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-researcher 2>/dev/null)
|
||||
AGENT_SKILLS_PLANNER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-planner 2>/dev/null)
|
||||
AGENT_SKILLS_CHECKER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-checker 2>/dev/null)
|
||||
CONTEXT_WINDOW=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get context_window 2>/dev/null || echo "200000")
|
||||
```
|
||||
|
||||
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes prior phase CONTEXT.md files so cross-phase decisions are consistent (e.g., "use library X for all data fetching" from Phase 2 is visible to Phase 5's planner).
|
||||
|
||||
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `planning_exists`, `roadmap_exists`, `phase_req_ids`.
|
||||
|
||||
**File paths (for <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)
|
||||
- {reviews_path} (Cross-AI Review Feedback - if --reviews)
|
||||
- {UI_SPEC_PATH} (UI Design Contract — visual/interaction specs, if exists)
|
||||
${CONTEXT_WINDOW >= 500000 ? `
|
||||
**Cross-phase context (1M model enrichment):**
|
||||
- Prior phase CONTEXT.md files (locked decisions from earlier phases — maintain consistency)
|
||||
- Prior phase SUMMARY.md files (what was actually built — reuse patterns, avoid duplication)
|
||||
` : ''}
|
||||
</files_to_read>
|
||||
|
||||
${AGENT_SKILLS_PLANNER}
|
||||
|
||||
@@ -19,6 +19,7 @@ command -v gemini >/dev/null 2>&1 && echo "gemini:available" || echo "gemini:mis
|
||||
command -v claude >/dev/null 2>&1 && echo "claude:available" || echo "claude:missing"
|
||||
command -v codex >/dev/null 2>&1 && echo "codex:available" || echo "codex:missing"
|
||||
command -v coderabbit >/dev/null 2>&1 && echo "coderabbit:available" || echo "coderabbit:missing"
|
||||
command -v opencode >/dev/null 2>&1 && echo "opencode:available" || echo "opencode:missing"
|
||||
```
|
||||
|
||||
Parse flags from `$ARGUMENTS`:
|
||||
@@ -26,6 +27,7 @@ Parse flags from `$ARGUMENTS`:
|
||||
- `--claude` → include Claude
|
||||
- `--codex` → include Codex
|
||||
- `--coderabbit` → include CodeRabbit
|
||||
- `--opencode` → include OpenCode
|
||||
- `--all` → include all available
|
||||
- No flags → include all available
|
||||
|
||||
@@ -35,6 +37,7 @@ No external AI CLIs found. Install at least one:
|
||||
- gemini: https://github.com/google-gemini/gemini-cli
|
||||
- codex: https://github.com/openai/codex
|
||||
- claude: https://github.com/anthropics/claude-code
|
||||
- opencode: https://opencode.ai (leverages GitHub Copilot subscription models)
|
||||
|
||||
Then run /gsd:review again.
|
||||
```
|
||||
@@ -141,6 +144,14 @@ Note: CodeRabbit reviews the current git diff/working tree — it does not accep
|
||||
coderabbit review --prompt-only 2>/dev/null > /tmp/gsd-review-coderabbit-{phase}.md
|
||||
```
|
||||
|
||||
**OpenCode (via GitHub Copilot):**
|
||||
```bash
|
||||
cat /tmp/gsd-review-prompt-{phase}.md | opencode run - 2>/dev/null > /tmp/gsd-review-opencode-{phase}.md
|
||||
if [ ! -s /tmp/gsd-review-opencode-{phase}.md ]; then
|
||||
echo "OpenCode review failed or returned empty output." > /tmp/gsd-review-opencode-{phase}.md
|
||||
fi
|
||||
```
|
||||
|
||||
If a CLI fails, log the error and continue with remaining CLIs.
|
||||
|
||||
Display progress:
|
||||
@@ -160,7 +171,7 @@ Combine all review responses into `{phase_dir}/{padded_phase}-REVIEWS.md`:
|
||||
```markdown
|
||||
---
|
||||
phase: {N}
|
||||
reviewers: [gemini, claude, codex, coderabbit]
|
||||
reviewers: [gemini, claude, codex, coderabbit, opencode]
|
||||
reviewed_at: {ISO timestamp}
|
||||
plans_reviewed: [{list of PLAN.md files}]
|
||||
---
|
||||
@@ -191,6 +202,12 @@ plans_reviewed: [{list of PLAN.md files}]
|
||||
|
||||
---
|
||||
|
||||
## OpenCode Review
|
||||
|
||||
{opencode review content}
|
||||
|
||||
---
|
||||
|
||||
## Consensus Summary
|
||||
|
||||
{synthesize common concerns across all reviewers}
|
||||
|
||||
@@ -24,6 +24,15 @@ CONFIG=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state load)
|
||||
```
|
||||
|
||||
Extract: `branching_strategy`, `branch_name`.
|
||||
|
||||
Detect base branch for PRs and merges:
|
||||
```bash
|
||||
BASE_BRANCH=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get git.base_branch 2>/dev/null || echo "")
|
||||
if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then
|
||||
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||')
|
||||
BASE_BRANCH="${BASE_BRANCH:-main}"
|
||||
fi
|
||||
```
|
||||
</step>
|
||||
|
||||
<step name="preflight_checks">
|
||||
@@ -46,7 +55,7 @@ Verify the work is ready to ship:
|
||||
```bash
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
```
|
||||
If on `main`/`master`: warn — should be on a feature branch.
|
||||
If on `${BASE_BRANCH}`: warn — should be on a feature branch.
|
||||
If branching_strategy is `none`: offer to create a branch now.
|
||||
|
||||
4. **Remote configured?**
|
||||
@@ -74,7 +83,7 @@ If push fails (e.g., no upstream): set upstream:
|
||||
git push --set-upstream origin ${CURRENT_BRANCH} 2>&1
|
||||
```
|
||||
|
||||
Report: "Pushed `{branch}` to origin ({commit_count} commits ahead of main)"
|
||||
Report: "Pushed `{branch}` to origin ({commit_count} commits ahead of ${BASE_BRANCH})"
|
||||
</step>
|
||||
|
||||
<step name="generate_pr_body">
|
||||
@@ -141,7 +150,7 @@ Create the PR using the generated body:
|
||||
gh pr create \
|
||||
--title "Phase ${PHASE_NUMBER}: ${PHASE_NAME}" \
|
||||
--body "${PR_BODY}" \
|
||||
--base main
|
||||
--base ${BASE_BRANCH}
|
||||
```
|
||||
|
||||
If `--draft` flag was passed: add `--draft`.
|
||||
@@ -194,7 +203,7 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(${padded_phase
|
||||
## ✓ Phase {X}: {Name} — Shipped
|
||||
|
||||
PR: #{number} ({url})
|
||||
Branch: {branch} → main
|
||||
Branch: {branch} → ${BASE_BRANCH}
|
||||
Commits: {count}
|
||||
Verification: ✓ Passed
|
||||
Requirements: {N} REQ-IDs addressed
|
||||
|
||||
27
hooks/gsd-phase-boundary.sh
Executable file
27
hooks/gsd-phase-boundary.sh
Executable 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
33
hooks/gsd-session-state.sh
Executable 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
47
hooks/gsd-validate-commit.sh
Executable 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
|
||||
171
tests/augment-conversion.test.cjs
Normal file
171
tests/augment-conversion.test.cjs
Normal 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.');
|
||||
});
|
||||
});
|
||||
84
tests/autonomous-interactive.test.cjs
Normal file
84
tests/autonomous-interactive.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1694,3 +1694,60 @@ describe('stats command', () => {
|
||||
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// check-commit command (#1395)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('check-commit command', () => {
|
||||
const { createTempGitProject } = require('./helpers.cjs');
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempGitProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('allows commit when commit_docs is true', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ commit_docs: true })
|
||||
);
|
||||
const result = runGsdTools('check-commit', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.allowed, true);
|
||||
});
|
||||
|
||||
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ commit_docs: false })
|
||||
);
|
||||
// Stage a non-planning file
|
||||
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
|
||||
execSync('git add src.js', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const result = runGsdTools('check-commit', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.allowed, true);
|
||||
});
|
||||
|
||||
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ commit_docs: false })
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
|
||||
execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const result = runGsdTools('check-commit', tmpDir);
|
||||
assert.ok(!result.success, 'should block commit');
|
||||
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
|
||||
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
|
||||
});
|
||||
});
|
||||
|
||||
819
tests/concurrency-safety.test.cjs
Normal file
819
tests/concurrency-safety.test.cjs
Normal 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]');
|
||||
});
|
||||
});
|
||||
@@ -242,6 +242,16 @@ describe('config-set command', () => {
|
||||
assert.strictEqual(config.workflow.use_worktrees, false);
|
||||
});
|
||||
|
||||
test('sets git.base_branch for non-main default branches', () => {
|
||||
writeConfig(tmpDir, {});
|
||||
|
||||
const result = runGsdTools('config-set git.base_branch master', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const config = readConfig(tmpDir);
|
||||
assert.strictEqual(config.git.base_branch, 'master');
|
||||
});
|
||||
|
||||
test('errors when no key path provided', () => {
|
||||
const result = runGsdTools('config-set', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
@@ -329,6 +339,27 @@ describe('config-get command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('gets git.base_branch after it is set', () => {
|
||||
runGsdTools('config-set git.base_branch master', tmpDir);
|
||||
const result = runGsdTools('config-get git.base_branch', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output, 'master');
|
||||
});
|
||||
|
||||
test('errors for git.base_branch when not explicitly set', () => {
|
||||
// Default config from config-ensure-section does not include git.base_branch,
|
||||
// so config-get should return "Key not found" — this triggers auto-detect
|
||||
// fallback in the workflow (origin/HEAD detection).
|
||||
const result = runGsdTools('config-get git.base_branch', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.ok(
|
||||
result.error.includes('Key not found'),
|
||||
`Expected "Key not found" in error: ${result.error}`
|
||||
);
|
||||
});
|
||||
|
||||
test('errors when no key path provided', () => {
|
||||
const result = runGsdTools('config-get', tmpDir);
|
||||
assert.strictEqual(result.success, false);
|
||||
|
||||
145
tests/context-enrichment.test.cjs
Normal file
145
tests/context-enrichment.test.cjs
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -659,7 +659,7 @@ describe('copyCommandsAsCopilotSkills', () => {
|
||||
assert.ok(skillContent.includes('description: Run all remaining phases autonomously'),
|
||||
'description preserved');
|
||||
// argument-hint present and double-quoted
|
||||
assert.ok(skillContent.includes('argument-hint: "[--from N]"'), 'argument-hint present and quoted');
|
||||
assert.ok(skillContent.includes('argument-hint: "[--from N] [--only N] [--interactive]"'), 'argument-hint present and quoted');
|
||||
// allowed-tools comma-separated
|
||||
assert.ok(skillContent.includes('allowed-tools: Read, Write, Bash, Glob, Grep, AskUserQuestion, Task'),
|
||||
'allowed-tools is comma-separated');
|
||||
@@ -1349,3 +1349,89 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Claude uninstall: user file preservation (#1423) ─────────────────────────
|
||||
|
||||
function runClaudeInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
function runClaudeUninstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--uninstall'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Claude uninstall preserves user-generated files (#1423)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-preserve-'));
|
||||
runClaudeInstall(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('preserves USER-PROFILE.md across uninstall', () => {
|
||||
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
|
||||
const content = '# Developer Profile\n\nAutonomy: High\nGenerated: 2026-03-29\n';
|
||||
fs.writeFileSync(profilePath, content);
|
||||
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive uninstall');
|
||||
assert.strictEqual(fs.readFileSync(profilePath, 'utf-8'), content, 'content should be identical');
|
||||
});
|
||||
|
||||
test('preserves dev-preferences.md across uninstall', () => {
|
||||
const prefsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
fs.mkdirSync(prefsDir, { recursive: true });
|
||||
const prefsPath = path.join(prefsDir, 'dev-preferences.md');
|
||||
const content = '---\nname: dev-preferences\n---\n# Preferences\nUse TypeScript strict.\n';
|
||||
fs.writeFileSync(prefsPath, content);
|
||||
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
assert.ok(fs.existsSync(prefsPath), 'dev-preferences.md should survive uninstall');
|
||||
assert.strictEqual(fs.readFileSync(prefsPath, 'utf-8'), content, 'content should be identical');
|
||||
});
|
||||
|
||||
test('still removes GSD engine files during uninstall', () => {
|
||||
const profilePath = path.join(tmpDir, '.claude', 'get-shit-done', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# Profile\n');
|
||||
|
||||
// Verify engine files exist before uninstall
|
||||
const binDir = path.join(tmpDir, '.claude', 'get-shit-done', 'bin');
|
||||
assert.ok(fs.existsSync(binDir), 'bin/ should exist before uninstall');
|
||||
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
// Engine files gone, user file preserved
|
||||
assert.ok(!fs.existsSync(binDir), 'bin/ should be removed after uninstall');
|
||||
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md should survive');
|
||||
});
|
||||
|
||||
test('clean uninstall when no user files exist', () => {
|
||||
runClaudeUninstall(tmpDir);
|
||||
|
||||
const gsdDir = path.join(tmpDir, '.claude', 'get-shit-done');
|
||||
const cmdDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
// Directories should be fully removed when no user files to preserve
|
||||
assert.ok(!fs.existsSync(gsdDir), 'get-shit-done/ should not exist after clean uninstall');
|
||||
assert.ok(!fs.existsSync(cmdDir), 'commands/gsd/ should not exist after clean uninstall');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
findPhaseInternal,
|
||||
findProjectRoot,
|
||||
detectSubRepos,
|
||||
planningDir,
|
||||
} = require('../get-shit-done/bin/lib/core.cjs');
|
||||
|
||||
// ─── loadConfig ────────────────────────────────────────────────────────────────
|
||||
@@ -1611,3 +1612,78 @@ describe('reapStaleTempFiles', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── planningDir ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('planningDir', () => {
|
||||
const cwd = '/fake/repo';
|
||||
let savedProject, savedWorkstream;
|
||||
|
||||
beforeEach(() => {
|
||||
savedProject = process.env.GSD_PROJECT;
|
||||
savedWorkstream = process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
|
||||
else delete process.env.GSD_PROJECT;
|
||||
if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
|
||||
else delete process.env.GSD_WORKSTREAM;
|
||||
});
|
||||
|
||||
test('returns .planning/ when neither project nor workstream is set', () => {
|
||||
const result = planningDir(cwd, null, null);
|
||||
assert.strictEqual(result, path.join(cwd, '.planning'));
|
||||
});
|
||||
|
||||
test('returns .planning/{project}/ when project is set', () => {
|
||||
const result = planningDir(cwd, null, 'my-app');
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app'));
|
||||
});
|
||||
|
||||
test('returns .planning/workstreams/{ws}/ when workstream is set', () => {
|
||||
const result = planningDir(cwd, 'feature-x', null);
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x'));
|
||||
});
|
||||
|
||||
test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => {
|
||||
const result = planningDir(cwd, 'feature-x', 'my-app');
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'));
|
||||
});
|
||||
|
||||
test('reads GSD_PROJECT from env when project param is undefined', () => {
|
||||
process.env.GSD_PROJECT = 'env-project';
|
||||
const result = planningDir(cwd);
|
||||
assert.strictEqual(result, path.join(cwd, '.planning', 'env-project'));
|
||||
});
|
||||
|
||||
test('rejects path traversal in project name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, null, '../../etc'),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects forward slash in project name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, null, 'foo/bar'),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects backslash in project name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, null, 'foo\\bar'),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects path traversal in workstream name', () => {
|
||||
assert.throws(
|
||||
() => planningDir(cwd, '../../../tmp', null),
|
||||
/invalid path characters/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
388
tests/health-validation.test.cjs
Normal file
388
tests/health-validation.test.cjs
Normal 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
507
tests/hooks-opt-in.test.cjs
Normal 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}`);
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,10 @@ const runtimeMap = {
|
||||
'6': 'copilot',
|
||||
'7': 'antigravity',
|
||||
'8': 'cursor',
|
||||
'9': 'windsurf'
|
||||
'9': 'windsurf',
|
||||
'10': 'augment'
|
||||
};
|
||||
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf'];
|
||||
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
|
||||
|
||||
/**
|
||||
* Simulate the parsing logic from promptRuntime without requiring readline.
|
||||
@@ -37,7 +38,7 @@ const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot',
|
||||
function parseRuntimeInput(input) {
|
||||
input = input.trim() || '1';
|
||||
|
||||
if (input === '10') {
|
||||
if (input === '11') {
|
||||
return allRuntimes;
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
test('single choice returns single runtime', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('2'), ['kilo']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('3'), ['opencode']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('4'), ['gemini']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('8'), ['cursor']);
|
||||
@@ -82,8 +84,12 @@ describe('multi-runtime selection parsing', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('9'), ['windsurf']);
|
||||
});
|
||||
|
||||
test('choice 10 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('10'), allRuntimes);
|
||||
test('single choice for augment', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('10'), ['augment']);
|
||||
});
|
||||
|
||||
test('choice 11 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), allRuntimes);
|
||||
});
|
||||
|
||||
test('empty input defaults to claude', () => {
|
||||
@@ -92,7 +98,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
});
|
||||
|
||||
test('invalid choices are ignored, falls back to claude if all invalid', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('12'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
|
||||
});
|
||||
@@ -114,7 +120,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
});
|
||||
|
||||
describe('install.js source contains multi-select support', () => {
|
||||
test('runtimeMap is defined with all 9 runtimes', () => {
|
||||
test('runtimeMap is defined with all 10 runtimes', () => {
|
||||
for (const [key, name] of Object.entries(runtimeMap)) {
|
||||
assert.ok(
|
||||
installSrc.includes(`'${key}': '${name}'`),
|
||||
@@ -131,6 +137,24 @@ describe('install.js source contains multi-select support', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('all shortcut uses option 11', () => {
|
||||
assert.ok(
|
||||
installSrc.includes("if (input === '11')"),
|
||||
'all shortcut uses option 11'
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt lists Augment as option 10 and All as option 11', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('10${reset}) Augment'),
|
||||
'prompt lists Augment as option 10'
|
||||
);
|
||||
assert.ok(
|
||||
installSrc.includes('11${reset}) All'),
|
||||
'prompt lists All as option 11'
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt text shows multi-select hint', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('Select multiple'),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* GSD Tools Tests - OpenCode permission config
|
||||
* Regression tests for OpenCode permission config handling.
|
||||
*
|
||||
* Regression tests for OpenCode permission handling, including the valid
|
||||
* top-level string form: "permission": "allow".
|
||||
* Ensures the installer does not crash when opencode.json uses the valid
|
||||
* top-level string form: "permission": "allow", and that path-specific
|
||||
* permissions are written against the actual resolved install directory.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
@@ -17,6 +18,19 @@ const { configureOpencodePermissions } = require('../bin/install.js');
|
||||
|
||||
const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
|
||||
|
||||
const envKeys = ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'];
|
||||
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
function restoreEnv(snapshot) {
|
||||
for (const key of envKeys) {
|
||||
if (snapshot[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = snapshot[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('configureOpencodePermissions', () => {
|
||||
let configDir;
|
||||
|
||||
@@ -26,9 +40,10 @@ describe('configureOpencodePermissions', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(configDir);
|
||||
restoreEnv(originalEnv);
|
||||
});
|
||||
|
||||
test('does not rewrite top-level string permissions', () => {
|
||||
test('does not crash or rewrite top-level string permissions', () => {
|
||||
const configPath = path.join(configDir, 'opencode.json');
|
||||
const original = JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
@@ -37,6 +52,7 @@ describe('configureOpencodePermissions', () => {
|
||||
}, null, 2) + '\n';
|
||||
|
||||
fs.writeFileSync(configPath, original);
|
||||
process.env.OPENCODE_CONFIG_DIR = configDir;
|
||||
|
||||
assert.doesNotThrow(() => configureOpencodePermissions(true, configDir));
|
||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||
@@ -45,6 +61,7 @@ describe('configureOpencodePermissions', () => {
|
||||
test('adds path-specific read and external_directory permissions for object configs', () => {
|
||||
const configPath = path.join(configDir, 'opencode.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n');
|
||||
process.env.OPENCODE_CONFIG_DIR = configDir;
|
||||
|
||||
configureOpencodePermissions(true, configDir);
|
||||
|
||||
@@ -56,6 +73,9 @@ describe('configureOpencodePermissions', () => {
|
||||
});
|
||||
|
||||
test('finishInstall passes the actual config dir to OpenCode permissions', () => {
|
||||
assert.ok(installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'), 'OpenCode permission config uses actual install dir');
|
||||
assert.ok(
|
||||
installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'),
|
||||
'OpenCode permission config uses actual install dir'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
254
tests/subagent-timeout.test.cjs
Normal file
254
tests/subagent-timeout.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user