Files
msd-core/get-shit-done/bin/gsd-tools.js
TÂCHES 246d542c65 feat(gsd-tools): add compound init commands and update workflows (#468)
* feat(gsd-tools): add compound init commands for workflow setup

Adds 8 compound commands that return all context a workflow needs in
one JSON blob, replacing 5-10 atomic calls per workflow:

- init execute-phase: models, config, phase info, plan inventory
- init plan-phase: models, workflow flags, existing artifacts
- init new-project: models, brownfield detection, state checks
- init new-milestone: models, milestone info
- init quick: models, next task number, timestamps
- init resume: file existence, interrupted agent
- init verify-work: models, phase info
- init phase-op: generic phase context

Updated 8 workflows to use compound commands:
- execute-phase, plan-phase, new-project, quick
- resume-project, verify-work, discuss-phase

Token savings: ~200 lines of bash setup replaced with single init calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(gsd-tools): add 4 new init commands and update files to use compound commands

Add new compound init commands:
- init todos - context for todo workflows
- init milestone-op - context for milestone operations
- init map-codebase - context for codebase mapping
- init progress - context for progress workflow

Update 24 files to use compound init commands instead of atomic calls:
- 4 phase operation workflows (add-phase, insert-phase, remove-phase, verify-phase)
- 5 todo/milestone workflows (add-todo, check-todos, audit-milestone, complete-milestone, new-milestone)
- 6 misc workflows (execute-plan, map-codebase, pause-work, progress, set-profile, settings)
- 6 agent files (gsd-executor, gsd-planner, gsd-phase-researcher, gsd-plan-checker, gsd-debugger, gsd-research-synthesizer)
- 2 command files (debug, research-phase)
- 1 reference file (planning-config)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gsd-tools): add init to help output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(verify-phase): correct expected init fields

The workflow was referencing plans/summaries from init phase-op,
but those fields come from ls command. Updated to reference
has_plans and plan_count which are actually in phase-op output.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 22:09:07 -06:00

1350 lines
42 KiB
JavaScript
Executable File

#!/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 <command> [args] [--raw]
*
* Atomic Commands:
* state load Load project config + state
* state update <field> <value> Update a STATE.md field
* resolve-model <agent-type> Get model for agent based on profile
* find-phase <phase> Find phase directory by number
* commit <message> [--files f1 f2] Commit planning docs
* verify-summary <path> Verify a SUMMARY.md file
*
* Compound Commands (workflow-specific initialization):
* init execute-phase <phase> All context for execute-phase workflow
* init plan-phase <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 <description> All context for quick workflow
* init resume All context for resume-project workflow
* init verify-work <phase> All context for verify-work workflow
* init phase-op <phase> 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 <command> [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();