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>
2258 lines
70 KiB
JavaScript
Executable File
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();
|