From 01ae939c01843e3b88cc0d1f85216d533ce177dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sat, 7 Feb 2026 11:08:21 -0600 Subject: [PATCH] feat(gsd-tools): add CLI utility for command extraction Centralizes repetitive bash patterns across 50+ GSD files. Functions: state load/update, resolve-model, find-phase, commit, verify-summary, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section Enables thin orchestrator pattern. Replaces 200-400 chars per usage. Ported from: get-shit-done-v2@ce5400f --- get-shit-done/bin/gsd-tools.js | 642 +++++++++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100755 get-shit-done/bin/gsd-tools.js diff --git a/get-shit-done/bin/gsd-tools.js b/get-shit-done/bin/gsd-tools.js new file mode 100755 index 000000000..74e977f47 --- /dev/null +++ b/get-shit-done/bin/gsd-tools.js @@ -0,0 +1,642 @@ +#!/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] + * + * 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 + */ + +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'); +} + +// ─── 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'); + } + + 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; + } + + default: + error(`Unknown command: ${command}`); + } +} + +main();