Includes: CLAUDE.md, settings.json, agents, commands, rules, skills, hooks, contexts, evals, get-shit-done, plugin configs (installed list and marketplace sources). Excludes credentials, runtime caches, telemetry, session data, and plugin binary cache.
710 lines
24 KiB
JavaScript
710 lines
24 KiB
JavaScript
/**
|
|
* Commands — Standalone utility commands
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync } = require('child_process');
|
|
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
|
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
const { MODEL_PROFILES } = require('./model-profiles.cjs');
|
|
|
|
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: toPosixPath(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 cmdHistoryDigest(cwd, raw) {
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const digest = { phases: {}, decisions: [], tech_stack: new Set() };
|
|
|
|
// Collect all phase directories: archived + current
|
|
const allPhaseDirs = [];
|
|
|
|
// Add archived phases first (oldest milestones first)
|
|
const archived = getArchivedPhaseDirs(cwd);
|
|
for (const a of archived) {
|
|
allPhaseDirs.push({ name: a.name, fullPath: a.fullPath, milestone: a.milestone });
|
|
}
|
|
|
|
// Add current phases
|
|
if (fs.existsSync(phasesDir)) {
|
|
try {
|
|
const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.sort();
|
|
for (const dir of currentDirs) {
|
|
allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null });
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (allPhaseDirs.length === 0) {
|
|
digest.tech_stack = [];
|
|
output(digest, raw);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
|
|
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 cmdResolveModel(cwd, agentType, raw) {
|
|
if (!agentType) {
|
|
error('agent-type required');
|
|
}
|
|
|
|
const config = loadConfig(cwd);
|
|
const profile = config.model_profile || 'balanced';
|
|
const model = resolveModelInternal(cwd, agentType);
|
|
|
|
const agentModels = MODEL_PROFILES[agentType];
|
|
const result = agentModels
|
|
? { model, profile }
|
|
: { model, profile, unknown_agent: true };
|
|
output(result, raw, model);
|
|
}
|
|
|
|
function cmdCommit(cwd, message, files, raw, amend) {
|
|
if (!message && !amend) {
|
|
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 commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message];
|
|
const commitResult = execGit(cwd, commitArgs);
|
|
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 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']),
|
|
requirements_completed: fm['requirements-completed'] || [],
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
|
|
async function cmdWebsearch(query, options, raw) {
|
|
const apiKey = process.env.BRAVE_API_KEY;
|
|
|
|
if (!apiKey) {
|
|
// No key = silent skip, agent falls back to built-in WebSearch
|
|
output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, '');
|
|
return;
|
|
}
|
|
|
|
if (!query) {
|
|
output({ available: false, error: 'Query required' }, raw, '');
|
|
return;
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
q: query,
|
|
count: String(options.limit || 10),
|
|
country: 'us',
|
|
search_lang: 'en',
|
|
text_decorations: 'false'
|
|
});
|
|
|
|
if (options.freshness) {
|
|
params.set('freshness', options.freshness);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`https://api.search.brave.com/res/v1/web/search?${params}`,
|
|
{
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Subscription-Token': apiKey
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
output({ available: false, error: `API error: ${response.status}` }, raw, '');
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
const results = (data.web?.results || []).map(r => ({
|
|
title: r.title,
|
|
url: r.url,
|
|
description: r.description,
|
|
age: r.age || null
|
|
}));
|
|
|
|
output({
|
|
available: true,
|
|
query,
|
|
count: results.length,
|
|
results
|
|
}, raw, results.map(r => `${r.title}\n${r.url}\n${r.description}`).join('\n\n'));
|
|
} catch (err) {
|
|
output({ available: false, error: err.message }, raw, '');
|
|
}
|
|
}
|
|
|
|
function cmdProgressRender(cwd, format, raw) {
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
const milestone = getMilestoneInfo(cwd);
|
|
|
|
const phases = [];
|
|
let totalPlans = 0;
|
|
let totalSummaries = 0;
|
|
|
|
try {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
|
|
|
for (const dir of dirs) {
|
|
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
|
const phaseNum = dm ? dm[1] : dir;
|
|
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
|
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
|
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
|
|
|
totalPlans += plans;
|
|
totalSummaries += summaries;
|
|
|
|
let status;
|
|
if (plans === 0) status = 'Pending';
|
|
else if (summaries >= plans) status = 'Complete';
|
|
else if (summaries > 0) status = 'In Progress';
|
|
else status = 'Planned';
|
|
|
|
phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
|
|
}
|
|
} catch {}
|
|
|
|
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
|
|
|
|
if (format === 'table') {
|
|
// Render markdown table
|
|
const barWidth = 10;
|
|
const filled = Math.round((percent / 100) * barWidth);
|
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
let out = `# ${milestone.version} ${milestone.name}\n\n`;
|
|
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
|
|
out += `| Phase | Name | Plans | Status |\n`;
|
|
out += `|-------|------|-------|--------|\n`;
|
|
for (const p of phases) {
|
|
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
|
|
}
|
|
output({ rendered: out }, raw, out);
|
|
} else if (format === 'bar') {
|
|
const barWidth = 20;
|
|
const filled = Math.round((percent / 100) * barWidth);
|
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
|
|
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
|
|
} else {
|
|
// JSON format
|
|
output({
|
|
milestone_version: milestone.version,
|
|
milestone_name: milestone.name,
|
|
phases,
|
|
total_plans: totalPlans,
|
|
total_summaries: totalSummaries,
|
|
percent,
|
|
}, raw);
|
|
}
|
|
}
|
|
|
|
function cmdTodoComplete(cwd, filename, raw) {
|
|
if (!filename) {
|
|
error('filename required for todo complete');
|
|
}
|
|
|
|
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
|
|
const completedDir = path.join(cwd, '.planning', 'todos', 'completed');
|
|
const sourcePath = path.join(pendingDir, filename);
|
|
|
|
if (!fs.existsSync(sourcePath)) {
|
|
error(`Todo not found: ${filename}`);
|
|
}
|
|
|
|
// Ensure completed directory exists
|
|
fs.mkdirSync(completedDir, { recursive: true });
|
|
|
|
// Read, add completion timestamp, move
|
|
let content = fs.readFileSync(sourcePath, 'utf-8');
|
|
const today = new Date().toISOString().split('T')[0];
|
|
content = `completed: ${today}\n` + content;
|
|
|
|
fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
|
|
fs.unlinkSync(sourcePath);
|
|
|
|
output({ completed: true, file: filename, date: today }, raw, 'completed');
|
|
}
|
|
|
|
function cmdScaffold(cwd, type, options, raw) {
|
|
const { phase, name } = options;
|
|
const padded = phase ? normalizePhaseName(phase) : '00';
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
// Find phase directory
|
|
const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
|
|
const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null;
|
|
|
|
if (phase && !phaseDir && type !== 'phase-dir') {
|
|
error(`Phase ${phase} directory not found`);
|
|
}
|
|
|
|
let filePath, content;
|
|
|
|
switch (type) {
|
|
case 'context': {
|
|
filePath = path.join(phaseDir, `${padded}-CONTEXT.md`);
|
|
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Context\n\n## Decisions\n\n_Decisions will be captured during /gsd:discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`;
|
|
break;
|
|
}
|
|
case 'uat': {
|
|
filePath = path.join(phaseDir, `${padded}-UAT.md`);
|
|
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`;
|
|
break;
|
|
}
|
|
case 'verification': {
|
|
filePath = path.join(phaseDir, `${padded}-VERIFICATION.md`);
|
|
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`;
|
|
break;
|
|
}
|
|
case 'phase-dir': {
|
|
if (!phase || !name) {
|
|
error('phase and name required for phase-dir scaffold');
|
|
}
|
|
const slug = generateSlugInternal(name);
|
|
const dirName = `${padded}-${slug}`;
|
|
const phasesParent = path.join(cwd, '.planning', 'phases');
|
|
fs.mkdirSync(phasesParent, { recursive: true });
|
|
const dirPath = path.join(phasesParent, dirName);
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
output({ created: true, directory: `.planning/phases/${dirName}`, path: dirPath }, raw, dirPath);
|
|
return;
|
|
}
|
|
default:
|
|
error(`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`);
|
|
}
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
output({ created: false, reason: 'already_exists', path: filePath }, raw, 'exists');
|
|
return;
|
|
}
|
|
|
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|
const relPath = toPosixPath(path.relative(cwd, filePath));
|
|
output({ created: true, path: relPath }, raw, relPath);
|
|
}
|
|
|
|
function cmdStats(cwd, format, raw) {
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
|
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
const milestone = getMilestoneInfo(cwd);
|
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
|
|
// Phase & plan stats (reuse progress pattern)
|
|
const phasesByNumber = new Map();
|
|
let totalPlans = 0;
|
|
let totalSummaries = 0;
|
|
|
|
try {
|
|
const roadmapContent = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
|
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
|
let match;
|
|
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
|
phasesByNumber.set(match[1], {
|
|
number: match[1],
|
|
name: match[2].replace(/\(INSERTED\)/i, '').trim(),
|
|
plans: 0,
|
|
summaries: 0,
|
|
status: 'Not Started',
|
|
});
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.filter(isDirInMilestone)
|
|
.sort((a, b) => comparePhaseNum(a, b));
|
|
|
|
for (const dir of dirs) {
|
|
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
|
const phaseNum = dm ? dm[1] : dir;
|
|
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
|
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
|
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
|
|
|
totalPlans += plans;
|
|
totalSummaries += summaries;
|
|
|
|
let status;
|
|
if (plans === 0) status = 'Not Started';
|
|
else if (summaries >= plans) status = 'Complete';
|
|
else if (summaries > 0) status = 'In Progress';
|
|
else status = 'Planned';
|
|
|
|
const existing = phasesByNumber.get(phaseNum);
|
|
phasesByNumber.set(phaseNum, {
|
|
number: phaseNum,
|
|
name: existing?.name || phaseName,
|
|
plans,
|
|
summaries,
|
|
status,
|
|
});
|
|
}
|
|
} catch {}
|
|
|
|
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
|
|
const completedPhases = phases.filter(p => p.status === 'Complete').length;
|
|
const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
|
|
const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
|
|
|
|
// Requirements stats
|
|
let requirementsTotal = 0;
|
|
let requirementsComplete = 0;
|
|
try {
|
|
if (fs.existsSync(reqPath)) {
|
|
const reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
const checked = reqContent.match(/^- \[x\] \*\*/gm);
|
|
const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
|
|
requirementsComplete = checked ? checked.length : 0;
|
|
requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
|
|
}
|
|
} catch {}
|
|
|
|
// Last activity from STATE.md
|
|
let lastActivity = null;
|
|
try {
|
|
if (fs.existsSync(statePath)) {
|
|
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
const activityMatch = stateContent.match(/^last_activity:\s*(.+)$/im)
|
|
|| stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
|
|
|| stateContent.match(/^Last Activity:\s*(.+)$/im)
|
|
|| stateContent.match(/^Last activity:\s*(.+)$/im);
|
|
if (activityMatch) lastActivity = activityMatch[1].trim();
|
|
}
|
|
} catch {}
|
|
|
|
// Git stats
|
|
let gitCommits = 0;
|
|
let gitFirstCommitDate = null;
|
|
const commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']);
|
|
if (commitCount.exitCode === 0) {
|
|
gitCommits = parseInt(commitCount.stdout, 10) || 0;
|
|
}
|
|
const rootHash = execGit(cwd, ['rev-list', '--max-parents=0', 'HEAD']);
|
|
if (rootHash.exitCode === 0 && rootHash.stdout) {
|
|
const firstCommit = rootHash.stdout.split('\n')[0].trim();
|
|
const firstDate = execGit(cwd, ['show', '-s', '--format=%as', firstCommit]);
|
|
if (firstDate.exitCode === 0) {
|
|
gitFirstCommitDate = firstDate.stdout || null;
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
milestone_version: milestone.version,
|
|
milestone_name: milestone.name,
|
|
phases,
|
|
phases_completed: completedPhases,
|
|
phases_total: phases.length,
|
|
total_plans: totalPlans,
|
|
total_summaries: totalSummaries,
|
|
percent,
|
|
plan_percent: planPercent,
|
|
requirements_total: requirementsTotal,
|
|
requirements_complete: requirementsComplete,
|
|
git_commits: gitCommits,
|
|
git_first_commit_date: gitFirstCommitDate,
|
|
last_activity: lastActivity,
|
|
};
|
|
|
|
if (format === 'table') {
|
|
const barWidth = 10;
|
|
const filled = Math.round((percent / 100) * barWidth);
|
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
let out = `# ${milestone.version} ${milestone.name} \u2014 Statistics\n\n`;
|
|
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
|
|
if (totalPlans > 0) {
|
|
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
|
|
}
|
|
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
|
|
if (requirementsTotal > 0) {
|
|
out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
|
|
}
|
|
out += '\n';
|
|
out += `| Phase | Name | Plans | Completed | Status |\n`;
|
|
out += `|-------|------|-------|-----------|--------|\n`;
|
|
for (const p of phases) {
|
|
out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
|
|
}
|
|
if (gitCommits > 0) {
|
|
out += `\n**Git:** ${gitCommits} commits`;
|
|
if (gitFirstCommitDate) out += ` (since ${gitFirstCommitDate})`;
|
|
out += '\n';
|
|
}
|
|
if (lastActivity) out += `**Last activity:** ${lastActivity}\n`;
|
|
output({ rendered: out }, raw, out);
|
|
} else {
|
|
output(result, raw);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
cmdGenerateSlug,
|
|
cmdCurrentTimestamp,
|
|
cmdListTodos,
|
|
cmdVerifyPathExists,
|
|
cmdHistoryDigest,
|
|
cmdResolveModel,
|
|
cmdCommit,
|
|
cmdSummaryExtract,
|
|
cmdWebsearch,
|
|
cmdProgressRender,
|
|
cmdTodoComplete,
|
|
cmdScaffold,
|
|
cmdStats,
|
|
};
|