Files
msd-core/get-shit-done/bin/gsd-tools.js
Lex Christopherson fa81821df4 feat: add --include flag to init commands to eliminate redundant file reads
Workflows were calling init commands to get parsed JSON metadata, then
immediately reading the same files again with cat to pass raw content
to agents. This wastes context tokens.

Changes:
- Add --include flag to init execute-phase, plan-phase, and progress
- Support includes: state, config, roadmap, requirements, context,
  research, verification, uat, project
- Update plan-phase.md to use --include (removes 6 cat calls)
- Update execute-phase.md to use --include (removes 2 cat calls)
- Update execute-plan.md to use --include (removes 2 cat calls)
- Update progress.md to use --include (removes 4 cat calls)
- Add 7 tests for --include functionality

Token savings: ~5,000-10,000 tokens per plan-phase execution,
~1,500-3,000 per other workflow executions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 01:43:22 -06:00

2258 lines
70 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 parseIncludeFlag(args) {
const includeIndex = args.indexOf('--include');
if (includeIndex === -1) return new Set();
const includeValue = args[includeIndex + 1];
if (!includeValue) return new Set();
return new Set(includeValue.split(',').map(s => s.trim()));
}
function safeReadFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
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 extractFrontmatter(content) {
const frontmatter = {};
const match = content.match(/^---\n([\s\S]+?)\n---/);
if (!match) return frontmatter;
const yaml = match[1];
const lines = yaml.split('\n');
// Stack to track nested objects: [{obj, key, indent}]
// obj = object to write to, key = current key collecting array items, indent = indentation level
let stack = [{ obj: frontmatter, key: null, indent: -1 }];
for (const line of lines) {
// Skip empty lines
if (line.trim() === '') continue;
// Calculate indentation (number of leading spaces)
const indentMatch = line.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1].length : 0;
// Pop stack back to appropriate level
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}
const current = stack[stack.length - 1];
// Check for key: value pattern
const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
if (keyMatch) {
const key = keyMatch[2];
const value = keyMatch[3].trim();
if (value === '' || value === '[') {
// Key with no value or opening bracket — could be nested object or array
// We'll determine based on next lines, for now create placeholder
current.obj[key] = value === '[' ? [] : {};
current.key = null;
// Push new context for potential nested content
stack.push({ obj: current.obj[key], key: null, indent });
} else if (value.startsWith('[') && value.endsWith(']')) {
// Inline array: key: [a, b, c]
current.obj[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
current.key = null;
} else {
// Simple key: value
current.obj[key] = value.replace(/^["']|["']$/g, '');
current.key = null;
}
} else if (line.trim().startsWith('- ')) {
// Array item
const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
// If current context is an empty object, convert to array
if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
// Find the key in parent that points to this object and convert it
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
if (parent) {
for (const k of Object.keys(parent.obj)) {
if (parent.obj[k] === current.obj) {
parent.obj[k] = [itemValue];
current.obj = parent.obj[k];
break;
}
}
}
} else if (Array.isArray(current.obj)) {
current.obj.push(itemValue);
}
}
}
return frontmatter;
}
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 cmdHistoryDigest(cwd, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const digest = { phases: {}, decisions: [], tech_stack: new Set() };
if (!fs.existsSync(phasesDir)) {
digest.tech_stack = [];
output(digest, raw);
return;
}
try {
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
for (const dir of phaseDirs) {
const dirPath = path.join(phasesDir, dir);
const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
for (const summary of summaries) {
try {
const content = fs.readFileSync(path.join(dirPath, summary), 'utf-8');
const fm = extractFrontmatter(content);
const phaseNum = fm.phase || dir.split('-')[0];
if (!digest.phases[phaseNum]) {
digest.phases[phaseNum] = {
name: fm.name || dir.split('-').slice(1).join(' ') || 'Unknown',
provides: new Set(),
affects: new Set(),
patterns: new Set(),
};
}
// Merge provides
if (fm['dependency-graph'] && fm['dependency-graph'].provides) {
fm['dependency-graph'].provides.forEach(p => digest.phases[phaseNum].provides.add(p));
} else if (fm.provides) {
fm.provides.forEach(p => digest.phases[phaseNum].provides.add(p));
}
// Merge affects
if (fm['dependency-graph'] && fm['dependency-graph'].affects) {
fm['dependency-graph'].affects.forEach(a => digest.phases[phaseNum].affects.add(a));
}
// Merge patterns
if (fm['patterns-established']) {
fm['patterns-established'].forEach(p => digest.phases[phaseNum].patterns.add(p));
}
// Merge decisions
if (fm['key-decisions']) {
fm['key-decisions'].forEach(d => {
digest.decisions.push({ phase: phaseNum, decision: d });
});
}
// Merge tech stack
if (fm['tech-stack'] && fm['tech-stack'].added) {
fm['tech-stack'].added.forEach(t => digest.tech_stack.add(typeof t === 'string' ? t : t.name));
}
} catch (e) {
// Skip malformed summaries
}
}
}
// Convert Sets to Arrays for JSON output
Object.keys(digest.phases).forEach(p => {
digest.phases[p].provides = [...digest.phases[p].provides];
digest.phases[p].affects = [...digest.phases[p].affects];
digest.phases[p].patterns = [...digest.phases[p].patterns];
});
digest.tech_stack = [...digest.tech_stack];
output(digest, raw);
} catch (e) {
error('Failed to generate history digest: ' + e.message);
}
}
function cmdPhasesList(cwd, options, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const { type, phase } = options;
// If no phases directory, return empty
if (!fs.existsSync(phasesDir)) {
if (type) {
output({ files: [], count: 0 }, raw, '');
} else {
output({ directories: [], count: 0 }, raw, '');
}
return;
}
try {
// Get all phase directories
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Sort numerically (handles decimals: 01, 02, 02.1, 02.2, 03)
dirs.sort((a, b) => {
const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
return aNum - bNum;
});
// If filtering by phase number
if (phase) {
const normalized = normalizePhaseName(phase);
const match = dirs.find(d => d.startsWith(normalized));
if (!match) {
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
return;
}
dirs = [match];
}
// If listing files of a specific type
if (type) {
const files = [];
for (const dir of dirs) {
const dirPath = path.join(phasesDir, dir);
const dirFiles = fs.readdirSync(dirPath);
let filtered;
if (type === 'plans') {
filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
} else if (type === 'summaries') {
filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
} else {
filtered = dirFiles;
}
files.push(...filtered.sort());
}
const result = {
files,
count: files.length,
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)?-?/, '') : null,
};
output(result, raw, files.join('\n'));
return;
}
// Default: list directories
output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
} catch (e) {
error('Failed to list phases: ' + e.message);
}
}
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
if (!fs.existsSync(roadmapPath)) {
output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
return;
}
try {
const content = fs.readFileSync(roadmapPath, 'utf-8');
// Escape special regex chars in phase number, handle decimal
const escapedPhase = phaseNum.replace(/\./g, '\\.');
// Match "### Phase X:" or "### Phase X.Y:" with optional name
const phasePattern = new RegExp(
`###\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
if (!headerMatch) {
output({ found: false, phase_number: phaseNum }, raw, '');
return;
}
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
// Find the end of this section (next ### or end of file)
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n###\s+Phase\s+\d/i);
const sectionEnd = nextHeaderMatch
? headerIndex + nextHeaderMatch.index
: content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
// Extract goal if present
const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
output(
{
found: true,
phase_number: phaseNum,
phase_name: phaseName,
goal,
section,
},
raw,
section
);
} catch (e) {
error('Failed to read ROADMAP.md: ' + e.message);
}
}
function cmdPhaseNextDecimal(cwd, basePhase, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(basePhase);
// Check if phases directory exists
if (!fs.existsSync(phasesDir)) {
output(
{
found: false,
base_phase: normalized,
next: `${normalized}.1`,
existing: [],
},
raw,
`${normalized}.1`
);
return;
}
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
// Check if base phase exists
const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized);
// Find existing decimal phases for this base
const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
const existingDecimals = [];
for (const dir of dirs) {
const match = dir.match(decimalPattern);
if (match) {
existingDecimals.push(`${normalized}.${match[1]}`);
}
}
// Sort numerically
existingDecimals.sort((a, b) => {
const aNum = parseFloat(a);
const bNum = parseFloat(b);
return aNum - bNum;
});
// Calculate next decimal
let nextDecimal;
if (existingDecimals.length === 0) {
nextDecimal = `${normalized}.1`;
} else {
const lastDecimal = existingDecimals[existingDecimals.length - 1];
const lastNum = parseInt(lastDecimal.split('.')[1], 10);
nextDecimal = `${normalized}.${lastNum + 1}`;
}
output(
{
found: baseExists,
base_phase: normalized,
next: nextDecimal,
existing: existingDecimals,
},
raw,
nextDecimal
);
} catch (e) {
error('Failed to calculate next decimal phase: ' + e.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 cmdStateGet(cwd, section, raw) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
try {
const content = fs.readFileSync(statePath, 'utf-8');
if (!section) {
output({ content }, raw, content);
return;
}
// Try to find markdown section or field
const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Check for **field:** value
const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
const fieldMatch = content.match(fieldPattern);
if (fieldMatch) {
output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim());
return;
}
// Check for ## Section
const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
const sectionMatch = content.match(sectionPattern);
if (sectionMatch) {
output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
return;
}
output({ error: `Section or field "${section}" not found` }, raw, '');
} catch {
error('STATE.md not found');
}
}
function cmdStatePatch(cwd, patches, raw) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
try {
let content = fs.readFileSync(statePath, 'utf-8');
const results = { updated: [], failed: [] };
for (const [field, value] of Object.entries(patches)) {
const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
if (pattern.test(content)) {
content = content.replace(pattern, `$1${value}`);
results.updated.push(field);
} else {
results.failed.push(field);
}
}
if (results.updated.length > 0) {
fs.writeFileSync(statePath, content, 'utf-8');
}
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
} catch {
error('STATE.md not found');
}
}
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');
}
function cmdTemplateSelect(cwd, planPath, raw) {
if (!planPath) {
error('plan-path required');
}
try {
const fullPath = path.join(cwd, planPath);
const content = fs.readFileSync(fullPath, 'utf-8');
// Simple heuristics
const taskMatch = content.match(/###\s*Task\s*\d+/g) || [];
const taskCount = taskMatch.length;
const decisionMatch = content.match(/decision/gi) || [];
const hasDecisions = decisionMatch.length > 0;
// Count file mentions
const fileMentions = new Set();
const filePattern = /`([^`]+\.[a-zA-Z]+)`/g;
let m;
while ((m = filePattern.exec(content)) !== null) {
if (m[1].includes('/') && !m[1].startsWith('http')) {
fileMentions.add(m[1]);
}
}
const fileCount = fileMentions.size;
let template = 'templates/summary-standard.md';
let type = 'standard';
if (taskCount <= 2 && fileCount <= 3 && !hasDecisions) {
template = 'templates/summary-minimal.md';
type = 'minimal';
} else if (hasDecisions || fileCount > 6 || taskCount > 5) {
template = 'templates/summary-complex.md';
type = 'complex';
}
const result = { template, type, taskCount, fileCount, hasDecisions };
output(result, raw, template);
} catch (e) {
// Fallback to standard
output({ template: 'templates/summary-standard.md', type: 'standard', error: e.message }, raw, 'templates/summary-standard.md');
}
}
function cmdPhasePlanIndex(cwd, phase, raw) {
if (!phase) {
error('phase required for phase-plan-index');
}
const phasesDir = path.join(cwd, '.planning', 'phases');
const normalized = normalizePhaseName(phase);
// Find phase directory
let phaseDir = null;
let phaseDirName = null;
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) {
phaseDir = path.join(phasesDir, match);
phaseDirName = match;
}
} catch {
// phases dir doesn't exist
}
if (!phaseDir) {
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
return;
}
// Get all files in phase directory
const phaseFiles = fs.readdirSync(phaseDir);
const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
// Build set of plan IDs with summaries
const completedPlanIds = new Set(
summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
);
const plans = [];
const waves = {};
const incomplete = [];
let hasCheckpoints = false;
for (const planFile of planFiles) {
const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
const planPath = path.join(phaseDir, planFile);
const content = fs.readFileSync(planPath, 'utf-8');
const fm = extractFrontmatter(content);
// Count tasks (## Task N patterns)
const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
const taskCount = taskMatches.length;
// Parse wave as integer
const wave = parseInt(fm.wave, 10) || 1;
// Parse autonomous (default true if not specified)
let autonomous = true;
if (fm.autonomous !== undefined) {
autonomous = fm.autonomous === 'true' || fm.autonomous === true;
}
if (!autonomous) {
hasCheckpoints = true;
}
// Parse files-modified
let filesModified = [];
if (fm['files-modified']) {
filesModified = Array.isArray(fm['files-modified']) ? fm['files-modified'] : [fm['files-modified']];
}
const hasSummary = completedPlanIds.has(planId);
if (!hasSummary) {
incomplete.push(planId);
}
const plan = {
id: planId,
wave,
autonomous,
objective: fm.objective || null,
files_modified: filesModified,
task_count: taskCount,
has_summary: hasSummary,
};
plans.push(plan);
// Group by wave
const waveKey = String(wave);
if (!waves[waveKey]) {
waves[waveKey] = [];
}
waves[waveKey].push(planId);
}
const result = {
phase: normalized,
plans,
waves,
incomplete,
has_checkpoints: hasCheckpoints,
};
output(result, raw);
}
function cmdStateSnapshot(cwd, raw) {
const statePath = path.join(cwd, '.planning', 'STATE.md');
if (!fs.existsSync(statePath)) {
output({ error: 'STATE.md not found' }, raw);
return;
}
const content = fs.readFileSync(statePath, 'utf-8');
// Helper to extract **Field:** value patterns
const extractField = (fieldName) => {
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
const match = content.match(pattern);
return match ? match[1].trim() : null;
};
// Extract basic fields
const currentPhase = extractField('Current Phase');
const currentPhaseName = extractField('Current Phase Name');
const totalPhasesRaw = extractField('Total Phases');
const currentPlan = extractField('Current Plan');
const totalPlansRaw = extractField('Total Plans in Phase');
const status = extractField('Status');
const progressRaw = extractField('Progress');
const lastActivity = extractField('Last Activity');
const lastActivityDesc = extractField('Last Activity Description');
const pausedAt = extractField('Paused At');
// Parse numeric fields
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
// Extract decisions table
const decisions = [];
const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (decisionsMatch) {
const tableBody = decisionsMatch[1];
const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
for (const row of rows) {
const cells = row.split('|').map(c => c.trim()).filter(Boolean);
if (cells.length >= 3) {
decisions.push({
phase: cells[0],
summary: cells[1],
rationale: cells[2],
});
}
}
}
// Extract blockers list
const blockers = [];
const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
if (blockersMatch) {
const blockersSection = blockersMatch[1];
const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
for (const item of items) {
blockers.push(item.replace(/^-\s+/, '').trim());
}
}
// Extract session info
const session = {
last_date: null,
stopped_at: null,
resume_file: null,
};
const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
if (sessionMatch) {
const sessionSection = sessionMatch[1];
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
}
const result = {
current_phase: currentPhase,
current_phase_name: currentPhaseName,
total_phases: totalPhases,
current_plan: currentPlan,
total_plans_in_phase: totalPlansInPhase,
status,
progress_percent: progressPercent,
last_activity: lastActivity,
last_activity_desc: lastActivityDesc,
decisions,
blockers,
paused_at: pausedAt,
session,
};
output(result, raw);
}
function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
if (!summaryPath) {
error('summary-path required for summary-extract');
}
const fullPath = path.join(cwd, summaryPath);
if (!fs.existsSync(fullPath)) {
output({ error: 'File not found', path: summaryPath }, raw);
return;
}
const content = fs.readFileSync(fullPath, 'utf-8');
const fm = extractFrontmatter(content);
// Parse key-decisions into structured format
const parseDecisions = (decisionsList) => {
if (!decisionsList || !Array.isArray(decisionsList)) return [];
return decisionsList.map(d => {
const colonIdx = d.indexOf(':');
if (colonIdx > 0) {
return {
summary: d.substring(0, colonIdx).trim(),
rationale: d.substring(colonIdx + 1).trim(),
};
}
return { summary: d, rationale: null };
});
};
// Build full result
const fullResult = {
path: summaryPath,
one_liner: fm['one-liner'] || null,
key_files: fm['key-files'] || [],
tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
patterns: fm['patterns-established'] || [],
decisions: parseDecisions(fm['key-decisions']),
};
// If fields specified, filter to only those fields
if (fields && fields.length > 0) {
const filtered = { path: summaryPath };
for (const field of fields) {
if (fullResult[field] !== undefined) {
filtered[field] = fullResult[field];
}
}
output(filtered, raw);
return;
}
output(fullResult, raw);
}
// ─── 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, includes, 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'),
};
// Include file contents if requested via --include
if (includes.has('state')) {
result.state_content = safeReadFile(path.join(cwd, '.planning', 'STATE.md'));
}
if (includes.has('config')) {
result.config_content = safeReadFile(path.join(cwd, '.planning', 'config.json'));
}
if (includes.has('roadmap')) {
result.roadmap_content = safeReadFile(path.join(cwd, '.planning', 'ROADMAP.md'));
}
output(result, raw);
}
function cmdInitPlanPhase(cwd, phase, includes, 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'),
};
// Include file contents if requested via --include
if (includes.has('state')) {
result.state_content = safeReadFile(path.join(cwd, '.planning', 'STATE.md'));
}
if (includes.has('roadmap')) {
result.roadmap_content = safeReadFile(path.join(cwd, '.planning', 'ROADMAP.md'));
}
if (includes.has('requirements')) {
result.requirements_content = safeReadFile(path.join(cwd, '.planning', 'REQUIREMENTS.md'));
}
if (includes.has('context') && phaseInfo?.directory) {
// Find *-CONTEXT.md in phase directory
const phaseDirFull = path.join(cwd, phaseInfo.directory);
try {
const files = fs.readdirSync(phaseDirFull);
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
if (contextFile) {
result.context_content = safeReadFile(path.join(phaseDirFull, contextFile));
}
} catch {}
}
if (includes.has('research') && phaseInfo?.directory) {
// Find *-RESEARCH.md in phase directory
const phaseDirFull = path.join(cwd, phaseInfo.directory);
try {
const files = fs.readdirSync(phaseDirFull);
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
if (researchFile) {
result.research_content = safeReadFile(path.join(phaseDirFull, researchFile));
}
} catch {}
}
if (includes.has('verification') && phaseInfo?.directory) {
// Find *-VERIFICATION.md in phase directory
const phaseDirFull = path.join(cwd, phaseInfo.directory);
try {
const files = fs.readdirSync(phaseDirFull);
const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
if (verificationFile) {
result.verification_content = safeReadFile(path.join(phaseDirFull, verificationFile));
}
} catch {}
}
if (includes.has('uat') && phaseInfo?.directory) {
// Find *-UAT.md in phase directory
const phaseDirFull = path.join(cwd, phaseInfo.directory);
try {
const files = fs.readdirSync(phaseDirFull);
const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
if (uatFile) {
result.uat_content = safeReadFile(path.join(phaseDirFull, uatFile));
}
} catch {}
}
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, includes, 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'),
};
// Include file contents if requested via --include
if (includes.has('state')) {
result.state_content = safeReadFile(path.join(cwd, '.planning', 'STATE.md'));
}
if (includes.has('roadmap')) {
result.roadmap_content = safeReadFile(path.join(cwd, '.planning', 'ROADMAP.md'));
}
if (includes.has('project')) {
result.project_content = safeReadFile(path.join(cwd, '.planning', 'PROJECT.md'));
}
if (includes.has('config')) {
result.config_content = safeReadFile(path.join(cwd, '.planning', 'config.json'));
}
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 if (subcommand === 'get') {
cmdStateGet(cwd, args[2], raw);
} else if (subcommand === 'patch') {
const patches = {};
for (let i = 2; i < args.length; i += 2) {
const key = args[i].replace(/^--/, '');
const value = args[i + 1];
if (key && value !== undefined) {
patches[key] = value;
}
}
cmdStatePatch(cwd, patches, raw);
} 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 'template': {
const subcommand = args[1];
if (subcommand === 'select') {
cmdTemplateSelect(cwd, args[2], 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 'history-digest': {
cmdHistoryDigest(cwd, raw);
break;
}
case 'phases': {
const subcommand = args[1];
if (subcommand === 'list') {
const typeIndex = args.indexOf('--type');
const phaseIndex = args.indexOf('--phase');
const options = {
type: typeIndex !== -1 ? args[typeIndex + 1] : null,
phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
};
cmdPhasesList(cwd, options, raw);
} else {
error('Unknown phases subcommand. Available: list');
}
break;
}
case 'roadmap': {
const subcommand = args[1];
if (subcommand === 'get-phase') {
cmdRoadmapGetPhase(cwd, args[2], raw);
} else {
error('Unknown roadmap subcommand. Available: get-phase');
}
break;
}
case 'phase': {
const subcommand = args[1];
if (subcommand === 'next-decimal') {
cmdPhaseNextDecimal(cwd, args[2], raw);
} else {
error('Unknown phase subcommand. Available: next-decimal');
}
break;
}
case 'init': {
const workflow = args[1];
const includes = parseIncludeFlag(args);
switch (workflow) {
case 'execute-phase':
cmdInitExecutePhase(cwd, args[2], includes, raw);
break;
case 'plan-phase':
cmdInitPlanPhase(cwd, args[2], includes, 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, includes, 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;
}
case 'phase-plan-index': {
cmdPhasePlanIndex(cwd, args[1], raw);
break;
}
case 'state-snapshot': {
cmdStateSnapshot(cwd, raw);
break;
}
case 'summary-extract': {
const summaryPath = args[1];
const fieldsIndex = args.indexOf('--fields');
const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null;
cmdSummaryExtract(cwd, summaryPath, fields, raw);
break;
}
default:
error(`Unknown command: ${command}`);
}
}
main();