#!/usr/bin/env node /** * GSD Tools — CLI utility for GSD workflow operations * * Replaces repetitive inline bash patterns across ~50 GSD command/workflow/agent files. * Centralizes: config parsing, model resolution, phase lookup, git commits, summary verification. * * Usage: node gsd-tools.js [args] [--raw] * * Atomic Commands: * state load Load project config + state * state update Update a STATE.md field * resolve-model Get model for agent based on profile * find-phase Find phase directory by number * commit [--files f1 f2] Commit planning docs * verify-summary Verify a SUMMARY.md file * * Compound Commands (workflow-specific initialization): * init execute-phase All context for execute-phase workflow * init plan-phase All context for plan-phase workflow * init new-project All context for new-project workflow * init new-milestone All context for new-milestone workflow * init quick All context for quick workflow * init resume All context for resume-project workflow * init verify-work All context for verify-work workflow * init phase-op Generic phase operation context * init todos [area] All context for todo workflows * init milestone-op All context for milestone operations * init map-codebase All context for map-codebase workflow * init progress All context for progress workflow */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // ─── Model Profile Table ───────────────────────────────────────────────────── const MODEL_PROFILES = { 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' }, 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' }, 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' }, 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' }, 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' }, }; // ─── Helpers ────────────────────────────────────────────────────────────────── function loadConfig(cwd) { const configPath = path.join(cwd, '.planning', 'config.json'); const defaults = { model_profile: 'balanced', commit_docs: true, search_gitignored: false, branching_strategy: 'none', phase_branch_template: 'gsd/phase-{phase}-{slug}', milestone_branch_template: 'gsd/{milestone}-{slug}', research: true, plan_checker: true, verifier: true, parallelization: true, }; try { const raw = fs.readFileSync(configPath, 'utf-8'); const parsed = JSON.parse(raw); const get = (key, nested) => { if (parsed[key] !== undefined) return parsed[key]; if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) { return parsed[nested.section][nested.field]; } return undefined; }; const parallelization = (() => { const val = get('parallelization'); if (typeof val === 'boolean') return val; if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled; return defaults.parallelization; })(); return { model_profile: get('model_profile') ?? defaults.model_profile, commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs, search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored, branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy, phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template, milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template, research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research, plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker, verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier, parallelization, }; } catch { return defaults; } } function isGitIgnored(cwd, targetPath) { try { execSync('git check-ignore -q -- ' + targetPath.replace(/[^a-zA-Z0-9._\-/]/g, ''), { cwd, stdio: 'pipe', }); return true; } catch { return false; } } function execGit(cwd, args) { try { const escaped = args.map(a => { if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a; return "'" + a.replace(/'/g, "'\\''") + "'"; }); const stdout = execSync('git ' + escaped.join(' '), { cwd, stdio: 'pipe', encoding: 'utf-8', }); return { exitCode: 0, stdout: stdout.trim(), stderr: '' }; } catch (err) { return { exitCode: err.status ?? 1, stdout: (err.stdout ?? '').toString().trim(), stderr: (err.stderr ?? '').toString().trim(), }; } } function normalizePhaseName(phase) { const match = phase.match(/^(\d+(?:\.\d+)?)/); if (!match) return phase; const num = match[1]; const parts = num.split('.'); const padded = parts[0].padStart(2, '0'); return parts.length > 1 ? `${padded}.${parts[1]}` : padded; } function output(result, raw, rawValue) { if (raw && rawValue !== undefined) { process.stdout.write(String(rawValue)); } else { process.stdout.write(JSON.stringify(result, null, 2)); } process.exit(0); } function error(message) { process.stderr.write('Error: ' + message + '\n'); process.exit(1); } // ─── Commands ───────────────────────────────────────────────────────────────── function cmdGenerateSlug(text, raw) { if (!text) { error('text required for slug generation'); } const slug = text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); const result = { slug }; output(result, raw, slug); } function cmdCurrentTimestamp(format, raw) { const now = new Date(); let result; switch (format) { case 'date': result = now.toISOString().split('T')[0]; break; case 'filename': result = now.toISOString().replace(/:/g, '-').replace(/\..+/, ''); break; case 'full': default: result = now.toISOString(); break; } output({ timestamp: result }, raw, result); } function cmdListTodos(cwd, area, raw) { const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); let count = 0; const todos = []; try { const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md')); for (const file of files) { try { const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8'); const createdMatch = content.match(/^created:\s*(.+)$/m); const titleMatch = content.match(/^title:\s*(.+)$/m); const areaMatch = content.match(/^area:\s*(.+)$/m); const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; // Apply area filter if specified if (area && todoArea !== area) continue; count++; todos.push({ file, created: createdMatch ? createdMatch[1].trim() : 'unknown', title: titleMatch ? titleMatch[1].trim() : 'Untitled', area: todoArea, path: path.join('.planning', 'todos', 'pending', file), }); } catch {} } } catch {} const result = { count, todos }; output(result, raw, count.toString()); } function cmdVerifyPathExists(cwd, targetPath, raw) { if (!targetPath) { error('path required for verification'); } const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); try { const stats = fs.statSync(fullPath); const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other'; const result = { exists: true, type }; output(result, raw, 'true'); } catch { const result = { exists: false, type: null }; output(result, raw, 'false'); } } function cmdConfigEnsureSection(cwd, raw) { const configPath = path.join(cwd, '.planning', 'config.json'); const planningDir = path.join(cwd, '.planning'); // Ensure .planning directory exists try { if (!fs.existsSync(planningDir)) { fs.mkdirSync(planningDir, { recursive: true }); } } catch (err) { error('Failed to create .planning directory: ' + err.message); } // Check if config already exists if (fs.existsSync(configPath)) { const result = { created: false, reason: 'already_exists' }; output(result, raw, 'exists'); return; } // Create default config const defaults = { model_profile: 'balanced', commit_docs: true, search_gitignored: false, branching_strategy: 'none', phase_branch_template: 'gsd/phase-{phase}-{slug}', milestone_branch_template: 'gsd/{milestone}-{slug}', workflow: { research: true, plan_check: true, verifier: true, }, parallelization: true, }; try { fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); const result = { created: true, path: '.planning/config.json' }; output(result, raw, 'created'); } catch (err) { error('Failed to create config.json: ' + err.message); } } function cmdStateLoad(cwd, raw) { const config = loadConfig(cwd); const planningDir = path.join(cwd, '.planning'); let stateRaw = ''; try { stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8'); } catch {} const configExists = fs.existsSync(path.join(planningDir, 'config.json')); const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md')); const stateExists = stateRaw.length > 0; const result = { config, state_raw: stateRaw, state_exists: stateExists, roadmap_exists: roadmapExists, config_exists: configExists, }; // For --raw, output a condensed key=value format if (raw) { const c = config; const lines = [ `model_profile=${c.model_profile}`, `commit_docs=${c.commit_docs}`, `branching_strategy=${c.branching_strategy}`, `phase_branch_template=${c.phase_branch_template}`, `milestone_branch_template=${c.milestone_branch_template}`, `parallelization=${c.parallelization}`, `research=${c.research}`, `plan_checker=${c.plan_checker}`, `verifier=${c.verifier}`, `config_exists=${configExists}`, `roadmap_exists=${roadmapExists}`, `state_exists=${stateExists}`, ]; process.stdout.write(lines.join('\n')); process.exit(0); } output(result); } function cmdStateUpdate(cwd, field, value) { if (!field || value === undefined) { error('field and value required for state update'); } const statePath = path.join(cwd, '.planning', 'STATE.md'); try { let content = fs.readFileSync(statePath, 'utf-8'); const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); if (pattern.test(content)) { content = content.replace(pattern, `$1${value}`); fs.writeFileSync(statePath, content, 'utf-8'); output({ updated: true }); } else { output({ updated: false, reason: `Field "${field}" not found in STATE.md` }); } } catch { output({ updated: false, reason: 'STATE.md not found' }); } } function cmdResolveModel(cwd, agentType, raw) { if (!agentType) { error('agent-type required'); } const config = loadConfig(cwd); const profile = config.model_profile || 'balanced'; const agentModels = MODEL_PROFILES[agentType]; if (!agentModels) { const result = { model: 'sonnet', profile, unknown_agent: true }; output(result, raw, 'sonnet'); return; } const model = agentModels[profile] || agentModels['balanced'] || 'sonnet'; const result = { model, profile }; output(result, raw, model); } function cmdFindPhase(cwd, phase, raw) { if (!phase) { error('phase identifier required'); } const phasesDir = path.join(cwd, '.planning', 'phases'); const normalized = normalizePhaseName(phase); const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] }; try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); const match = dirs.find(d => d.startsWith(normalized)); if (!match) { output(notFound, raw, ''); return; } const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = path.join(phasesDir, match); const phaseFiles = fs.readdirSync(phaseDir); const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); const result = { found: true, directory: path.join('.planning', 'phases', match), phase_number: phaseNumber, phase_name: phaseName, plans, summaries, }; output(result, raw, result.directory); } catch { output(notFound, raw, ''); } } function cmdCommit(cwd, message, files, raw) { if (!message) { error('commit message required'); } const config = loadConfig(cwd); // Check commit_docs config if (!config.commit_docs) { const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' }; output(result, raw, 'skipped'); return; } // Check if .planning is gitignored if (isGitIgnored(cwd, '.planning')) { const result = { committed: false, hash: null, reason: 'skipped_gitignored' }; output(result, raw, 'skipped'); return; } // Stage files const filesToStage = files && files.length > 0 ? files : ['.planning/']; for (const file of filesToStage) { execGit(cwd, ['add', file]); } // Commit const commitResult = execGit(cwd, ['commit', '-m', message]); if (commitResult.exitCode !== 0) { if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) { const result = { committed: false, hash: null, reason: 'nothing_to_commit' }; output(result, raw, 'nothing'); return; } const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr }; output(result, raw, 'nothing'); return; } // Get short hash const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']); const hash = hashResult.exitCode === 0 ? hashResult.stdout : null; const result = { committed: true, hash, reason: 'committed' }; output(result, raw, hash || 'committed'); } function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) { if (!summaryPath) { error('summary-path required'); } const fullPath = path.join(cwd, summaryPath); const checkCount = checkFileCount || 2; // Check 1: Summary exists if (!fs.existsSync(fullPath)) { const result = { passed: false, checks: { summary_exists: false, files_created: { checked: 0, found: 0, missing: [] }, commits_exist: false, self_check: 'not_found', }, errors: ['SUMMARY.md not found'], }; output(result, raw, 'failed'); return; } const content = fs.readFileSync(fullPath, 'utf-8'); const errors = []; // Check 2: Spot-check files mentioned in summary const mentionedFiles = new Set(); const patterns = [ /`([^`]+\.[a-zA-Z]+)`/g, /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi, ]; for (const pattern of patterns) { let m; while ((m = pattern.exec(content)) !== null) { const filePath = m[1]; if (filePath && !filePath.startsWith('http') && filePath.includes('/')) { mentionedFiles.add(filePath); } } } const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount); const missing = []; for (const file of filesToCheck) { if (!fs.existsSync(path.join(cwd, file))) { missing.push(file); } } // Check 3: Commits exist const commitHashPattern = /\b[0-9a-f]{7,40}\b/g; const hashes = content.match(commitHashPattern) || []; let commitsExist = false; if (hashes.length > 0) { for (const hash of hashes.slice(0, 3)) { const result = execGit(cwd, ['cat-file', '-t', hash]); if (result.exitCode === 0 && result.stdout === 'commit') { commitsExist = true; break; } } } // Check 4: Self-check section let selfCheck = 'not_found'; const selfCheckPattern = /##\s*(?:Self[- ]?Check|Verification|Quality Check)/i; if (selfCheckPattern.test(content)) { const passPattern = /(?:all\s+)?(?:pass|✓|✅|complete|succeeded)/i; const failPattern = /(?:fail|✗|❌|incomplete|blocked)/i; const checkSection = content.slice(content.search(selfCheckPattern)); if (failPattern.test(checkSection)) { selfCheck = 'failed'; } else if (passPattern.test(checkSection)) { selfCheck = 'passed'; } } if (missing.length > 0) errors.push('Missing files: ' + missing.join(', ')); if (!commitsExist && hashes.length > 0) errors.push('Referenced commit hashes not found in git history'); if (selfCheck === 'failed') errors.push('Self-check section indicates failure'); const checks = { summary_exists: true, files_created: { checked: filesToCheck.length, found: filesToCheck.length - missing.length, missing }, commits_exist: commitsExist, self_check: selfCheck, }; const passed = missing.length === 0 && selfCheck !== 'failed'; const result = { passed, checks, errors }; output(result, raw, passed ? 'passed' : 'failed'); } // ─── Compound Commands ──────────────────────────────────────────────────────── function resolveModelInternal(cwd, agentType) { const config = loadConfig(cwd); const profile = config.model_profile || 'balanced'; const agentModels = MODEL_PROFILES[agentType]; if (!agentModels) return 'sonnet'; return agentModels[profile] || agentModels['balanced'] || 'sonnet'; } function findPhaseInternal(cwd, phase) { if (!phase) return null; const phasesDir = path.join(cwd, '.planning', 'phases'); const normalized = normalizePhaseName(phase); try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); const match = dirs.find(d => d.startsWith(normalized)); if (!match) return null; const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = path.join(phasesDir, match); const phaseFiles = fs.readdirSync(phaseDir); const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); // Determine incomplete plans (plans without matching summaries) const completedPlanIds = new Set( summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) ); const incompletePlans = plans.filter(p => { const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); return !completedPlanIds.has(planId); }); return { found: true, directory: path.join('.planning', 'phases', match), phase_number: phaseNumber, phase_name: phaseName, phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, plans, summaries, incomplete_plans: incompletePlans, has_research: hasResearch, has_context: hasContext, has_verification: hasVerification, }; } catch { return null; } } function pathExistsInternal(cwd, targetPath) { const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath); try { fs.statSync(fullPath); return true; } catch { return false; } } function generateSlugInternal(text) { if (!text) return null; return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); } function getMilestoneInfo(cwd) { try { const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'); const versionMatch = roadmap.match(/v(\d+\.\d+)/); const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/); return { version: versionMatch ? versionMatch[0] : 'v1.0', name: nameMatch ? nameMatch[1].trim() : 'milestone', }; } catch { return { version: 'v1.0', name: 'milestone' }; } } function cmdInitExecutePhase(cwd, phase, raw) { if (!phase) { error('phase required for init execute-phase'); } const config = loadConfig(cwd); const phaseInfo = findPhaseInternal(cwd, phase); const milestone = getMilestoneInfo(cwd); const result = { // Models executor_model: resolveModelInternal(cwd, 'gsd-executor'), verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), // Config flags commit_docs: config.commit_docs, parallelization: config.parallelization, branching_strategy: config.branching_strategy, phase_branch_template: config.phase_branch_template, milestone_branch_template: config.milestone_branch_template, verifier_enabled: config.verifier, // Phase info phase_found: !!phaseInfo, phase_dir: phaseInfo?.directory || null, phase_number: phaseInfo?.phase_number || null, phase_name: phaseInfo?.phase_name || null, phase_slug: phaseInfo?.phase_slug || null, // Plan inventory plans: phaseInfo?.plans || [], summaries: phaseInfo?.summaries || [], incomplete_plans: phaseInfo?.incomplete_plans || [], plan_count: phaseInfo?.plans?.length || 0, incomplete_count: phaseInfo?.incomplete_plans?.length || 0, // Branch name (pre-computed) branch_name: config.branching_strategy === 'phase' && phaseInfo ? config.phase_branch_template .replace('{phase}', phaseInfo.phase_number) .replace('{slug}', phaseInfo.phase_slug || 'phase') : config.branching_strategy === 'milestone' ? config.milestone_branch_template .replace('{milestone}', milestone.version) .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone') : null, // Milestone info milestone_version: milestone.version, milestone_name: milestone.name, milestone_slug: generateSlugInternal(milestone.name), // File existence state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), config_exists: pathExistsInternal(cwd, '.planning/config.json'), }; output(result, raw); } function cmdInitPlanPhase(cwd, phase, raw) { if (!phase) { error('phase required for init plan-phase'); } const config = loadConfig(cwd); const phaseInfo = findPhaseInternal(cwd, phase); const result = { // Models researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'), planner_model: resolveModelInternal(cwd, 'gsd-planner'), checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), // Workflow flags research_enabled: config.research, plan_checker_enabled: config.plan_checker, commit_docs: config.commit_docs, // Phase info phase_found: !!phaseInfo, phase_dir: phaseInfo?.directory || null, phase_number: phaseInfo?.phase_number || null, phase_name: phaseInfo?.phase_name || null, phase_slug: phaseInfo?.phase_slug || null, padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null, // Existing artifacts has_research: phaseInfo?.has_research || false, has_context: phaseInfo?.has_context || false, has_plans: (phaseInfo?.plans?.length || 0) > 0, plan_count: phaseInfo?.plans?.length || 0, // Environment planning_exists: pathExistsInternal(cwd, '.planning'), roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), }; output(result, raw); } function cmdInitNewProject(cwd, raw) { const config = loadConfig(cwd); // Detect existing code let hasCode = false; let hasPackageFile = false; try { const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); hasCode = files.trim().length > 0; } catch {} hasPackageFile = pathExistsInternal(cwd, 'package.json') || pathExistsInternal(cwd, 'requirements.txt') || pathExistsInternal(cwd, 'Cargo.toml') || pathExistsInternal(cwd, 'go.mod') || pathExistsInternal(cwd, 'Package.swift'); const result = { // Models researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), // Config commit_docs: config.commit_docs, // Existing state project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), has_codebase_map: pathExistsInternal(cwd, '.planning/codebase'), planning_exists: pathExistsInternal(cwd, '.planning'), // Brownfield detection has_existing_code: hasCode, has_package_file: hasPackageFile, is_brownfield: hasCode || hasPackageFile, needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'), // Git state has_git: pathExistsInternal(cwd, '.git'), }; output(result, raw); } function cmdInitNewMilestone(cwd, raw) { const config = loadConfig(cwd); const milestone = getMilestoneInfo(cwd); const result = { // Models researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), // Config commit_docs: config.commit_docs, research_enabled: config.research, // Current milestone current_milestone: milestone.version, current_milestone_name: milestone.name, // File existence project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), }; output(result, raw); } function cmdInitQuick(cwd, description, raw) { const config = loadConfig(cwd); const now = new Date(); const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null; // Find next quick task number const quickDir = path.join(cwd, '.planning', 'quick'); let nextNum = 1; try { const existing = fs.readdirSync(quickDir) .filter(f => /^\d+-/.test(f)) .map(f => parseInt(f.split('-')[0], 10)) .filter(n => !isNaN(n)); if (existing.length > 0) { nextNum = Math.max(...existing) + 1; } } catch {} const result = { // Models planner_model: resolveModelInternal(cwd, 'gsd-planner'), executor_model: resolveModelInternal(cwd, 'gsd-executor'), // Config commit_docs: config.commit_docs, // Quick task info next_num: nextNum, slug: slug, description: description || null, // Timestamps date: now.toISOString().split('T')[0], timestamp: now.toISOString(), // Paths quick_dir: '.planning/quick', task_dir: slug ? `.planning/quick/${nextNum}-${slug}` : null, // File existence roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), planning_exists: pathExistsInternal(cwd, '.planning'), }; output(result, raw); } function cmdInitResume(cwd, raw) { const config = loadConfig(cwd); // Check for interrupted agent let interruptedAgentId = null; try { interruptedAgentId = fs.readFileSync(path.join(cwd, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); } catch {} const result = { // File existence state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), planning_exists: pathExistsInternal(cwd, '.planning'), // Agent state has_interrupted_agent: !!interruptedAgentId, interrupted_agent_id: interruptedAgentId, // Config commit_docs: config.commit_docs, }; output(result, raw); } function cmdInitVerifyWork(cwd, phase, raw) { if (!phase) { error('phase required for init verify-work'); } const config = loadConfig(cwd); const phaseInfo = findPhaseInternal(cwd, phase); const result = { // Models planner_model: resolveModelInternal(cwd, 'gsd-planner'), checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), // Config commit_docs: config.commit_docs, // Phase info phase_found: !!phaseInfo, phase_dir: phaseInfo?.directory || null, phase_number: phaseInfo?.phase_number || null, phase_name: phaseInfo?.phase_name || null, // Existing artifacts has_verification: phaseInfo?.has_verification || false, }; output(result, raw); } function cmdInitPhaseOp(cwd, phase, raw) { const config = loadConfig(cwd); const phaseInfo = findPhaseInternal(cwd, phase); const result = { // Config commit_docs: config.commit_docs, // Phase info phase_found: !!phaseInfo, phase_dir: phaseInfo?.directory || null, phase_number: phaseInfo?.phase_number || null, phase_name: phaseInfo?.phase_name || null, phase_slug: phaseInfo?.phase_slug || null, padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null, // Existing artifacts has_research: phaseInfo?.has_research || false, has_context: phaseInfo?.has_context || false, has_plans: (phaseInfo?.plans?.length || 0) > 0, has_verification: phaseInfo?.has_verification || false, plan_count: phaseInfo?.plans?.length || 0, // File existence roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), planning_exists: pathExistsInternal(cwd, '.planning'), }; output(result, raw); } function cmdInitTodos(cwd, area, raw) { const config = loadConfig(cwd); const now = new Date(); // List todos (reuse existing logic) const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); let count = 0; const todos = []; try { const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md')); for (const file of files) { try { const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8'); const createdMatch = content.match(/^created:\s*(.+)$/m); const titleMatch = content.match(/^title:\s*(.+)$/m); const areaMatch = content.match(/^area:\s*(.+)$/m); const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; if (area && todoArea !== area) continue; count++; todos.push({ file, created: createdMatch ? createdMatch[1].trim() : 'unknown', title: titleMatch ? titleMatch[1].trim() : 'Untitled', area: todoArea, path: path.join('.planning', 'todos', 'pending', file), }); } catch {} } } catch {} const result = { // Config commit_docs: config.commit_docs, // Timestamps date: now.toISOString().split('T')[0], timestamp: now.toISOString(), // Todo inventory todo_count: count, todos, area_filter: area || null, // Paths pending_dir: '.planning/todos/pending', completed_dir: '.planning/todos/completed', // File existence planning_exists: pathExistsInternal(cwd, '.planning'), todos_dir_exists: pathExistsInternal(cwd, '.planning/todos'), pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'), }; output(result, raw); } function cmdInitMilestoneOp(cwd, raw) { const config = loadConfig(cwd); const milestone = getMilestoneInfo(cwd); // Count phases let phaseCount = 0; let completedPhases = 0; const phasesDir = path.join(cwd, '.planning', 'phases'); try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); phaseCount = dirs.length; // Count phases with summaries (completed) for (const dir of dirs) { try { const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); if (hasSummary) completedPhases++; } catch {} } } catch {} // Check archive const archiveDir = path.join(cwd, '.planning', 'archive'); let archivedMilestones = []; try { archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name); } catch {} const result = { // Config commit_docs: config.commit_docs, // Current milestone milestone_version: milestone.version, milestone_name: milestone.name, milestone_slug: generateSlugInternal(milestone.name), // Phase counts phase_count: phaseCount, completed_phases: completedPhases, all_phases_complete: phaseCount > 0 && phaseCount === completedPhases, // Archive archived_milestones: archivedMilestones, archive_count: archivedMilestones.length, // File existence project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), archive_exists: pathExistsInternal(cwd, '.planning/archive'), phases_dir_exists: pathExistsInternal(cwd, '.planning/phases'), }; output(result, raw); } function cmdInitMapCodebase(cwd, raw) { const config = loadConfig(cwd); // Check for existing codebase maps const codebaseDir = path.join(cwd, '.planning', 'codebase'); let existingMaps = []; try { existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md')); } catch {} const result = { // Models mapper_model: resolveModelInternal(cwd, 'gsd-codebase-mapper'), // Config commit_docs: config.commit_docs, search_gitignored: config.search_gitignored, parallelization: config.parallelization, // Paths codebase_dir: '.planning/codebase', // Existing maps existing_maps: existingMaps, has_maps: existingMaps.length > 0, // File existence planning_exists: pathExistsInternal(cwd, '.planning'), codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'), }; output(result, raw); } function cmdInitProgress(cwd, raw) { const config = loadConfig(cwd); const milestone = getMilestoneInfo(cwd); // Analyze phases const phasesDir = path.join(cwd, '.planning', 'phases'); const phases = []; let currentPhase = null; let nextPhase = null; try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); for (const dir of dirs) { const match = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); const phaseNumber = match ? match[1] : dir; const phaseName = match && match[2] ? match[2] : null; const phasePath = path.join(phasesDir, dir); const phaseFiles = fs.readdirSync(phasePath); const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); const status = summaries.length >= plans.length && plans.length > 0 ? 'complete' : plans.length > 0 ? 'in_progress' : hasResearch ? 'researched' : 'pending'; const phaseInfo = { number: phaseNumber, name: phaseName, directory: path.join('.planning', 'phases', dir), status, plan_count: plans.length, summary_count: summaries.length, has_research: hasResearch, }; phases.push(phaseInfo); // Find current (first incomplete with plans) and next (first pending) if (!currentPhase && (status === 'in_progress' || status === 'researched')) { currentPhase = phaseInfo; } if (!nextPhase && status === 'pending') { nextPhase = phaseInfo; } } } catch {} // Check for paused work let pausedAt = null; try { const state = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8'); const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/); if (pauseMatch) pausedAt = pauseMatch[1].trim(); } catch {} const result = { // Models executor_model: resolveModelInternal(cwd, 'gsd-executor'), planner_model: resolveModelInternal(cwd, 'gsd-planner'), // Config commit_docs: config.commit_docs, // Milestone milestone_version: milestone.version, milestone_name: milestone.name, // Phase overview phases, phase_count: phases.length, completed_count: phases.filter(p => p.status === 'complete').length, in_progress_count: phases.filter(p => p.status === 'in_progress').length, // Current state current_phase: currentPhase, next_phase: nextPhase, paused_at: pausedAt, has_work_in_progress: !!currentPhase, // File existence project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), }; output(result, raw); } // ─── CLI Router ─────────────────────────────────────────────────────────────── function main() { const args = process.argv.slice(2); const rawIndex = args.indexOf('--raw'); const raw = rawIndex !== -1; if (rawIndex !== -1) args.splice(rawIndex, 1); const command = args[0]; const cwd = process.cwd(); if (!command) { error('Usage: gsd-tools [args] [--raw]\nCommands: state, resolve-model, find-phase, commit, verify-summary, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, init'); } switch (command) { case 'state': { const subcommand = args[1]; if (subcommand === 'update') { cmdStateUpdate(cwd, args[2], args[3]); } else { cmdStateLoad(cwd, raw); } break; } case 'resolve-model': { cmdResolveModel(cwd, args[1], raw); break; } case 'find-phase': { cmdFindPhase(cwd, args[1], raw); break; } case 'commit': { const message = args[1]; // Parse --files flag const filesIndex = args.indexOf('--files'); const files = filesIndex !== -1 ? args.slice(filesIndex + 1) : []; cmdCommit(cwd, message, files, raw); break; } case 'verify-summary': { const summaryPath = args[1]; const countIndex = args.indexOf('--check-count'); const checkCount = countIndex !== -1 ? parseInt(args[countIndex + 1], 10) : 2; cmdVerifySummary(cwd, summaryPath, checkCount, raw); break; } case 'generate-slug': { cmdGenerateSlug(args[1], raw); break; } case 'current-timestamp': { cmdCurrentTimestamp(args[1] || 'full', raw); break; } case 'list-todos': { cmdListTodos(cwd, args[1], raw); break; } case 'verify-path-exists': { cmdVerifyPathExists(cwd, args[1], raw); break; } case 'config-ensure-section': { cmdConfigEnsureSection(cwd, raw); break; } case 'init': { const workflow = args[1]; switch (workflow) { case 'execute-phase': cmdInitExecutePhase(cwd, args[2], raw); break; case 'plan-phase': cmdInitPlanPhase(cwd, args[2], raw); break; case 'new-project': cmdInitNewProject(cwd, raw); break; case 'new-milestone': cmdInitNewMilestone(cwd, raw); break; case 'quick': cmdInitQuick(cwd, args.slice(2).join(' '), raw); break; case 'resume': cmdInitResume(cwd, raw); break; case 'verify-work': cmdInitVerifyWork(cwd, args[2], raw); break; case 'phase-op': cmdInitPhaseOp(cwd, args[2], raw); break; case 'todos': cmdInitTodos(cwd, args[2], raw); break; case 'milestone-op': cmdInitMilestoneOp(cwd, raw); break; case 'map-codebase': cmdInitMapCodebase(cwd, raw); break; case 'progress': cmdInitProgress(cwd, raw); break; default: error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress`); } break; } default: error(`Unknown command: ${command}`); } } main();