feat: add /gsd:stats command for project statistics
Add a new `/gsd:stats` command that displays comprehensive project statistics including phase progress, plan execution metrics, requirements completion, git history, and timeline information. - New command definition: commands/gsd/stats.md - New workflow: get-shit-done/workflows/stats.md - CLI handler: `gsd-tools stats [json|table]` - Stats function in commands.cjs with JSON and table output formats - 5 new tests covering empty project, phase/plan counting, requirements counting, last activity, and table format rendering Co-authored-by: ashanuoc <ashanuoc@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
commands/gsd/stats.md
Normal file
18
commands/gsd/stats.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: gsd:stats
|
||||
description: Display project statistics — phases, plans, requirements, git metrics, and timeline
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Bash
|
||||
---
|
||||
<objective>
|
||||
Display comprehensive project statistics including phase progress, plan execution metrics, requirements completion, git history stats, and project timeline.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/stats.md
|
||||
</execution_context>
|
||||
|
||||
<process>
|
||||
Execute the stats workflow from @~/.claude/get-shit-done/workflows/stats.md end-to-end.
|
||||
</process>
|
||||
@@ -488,6 +488,12 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stats': {
|
||||
const subcommand = args[1] || 'json';
|
||||
commands.cmdStats(cwd, subcommand, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'todo': {
|
||||
const subcommand = args[1];
|
||||
if (subcommand === 'complete') {
|
||||
|
||||
@@ -532,6 +532,123 @@ function cmdScaffold(cwd, type, options, raw) {
|
||||
output({ created: true, path: relPath }, raw, relPath);
|
||||
}
|
||||
|
||||
function cmdStats(cwd, format, raw) {
|
||||
const phasesDir = path.join(cwd, '.planning', 'phases');
|
||||
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
|
||||
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
|
||||
// Phase & plan stats (reuse progress pattern)
|
||||
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;
|
||||
|
||||
// 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*(.+)/);
|
||||
if (activityMatch) lastActivity = activityMatch[1].trim();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Git stats
|
||||
let gitCommits = 0;
|
||||
let gitFirstCommitDate = null;
|
||||
try {
|
||||
const commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']);
|
||||
gitCommits = parseInt(commitCount.trim(), 10) || 0;
|
||||
const firstDate = execGit(cwd, ['log', '--reverse', '--format=%as', '--max-count=1']);
|
||||
gitFirstCommitDate = firstDate.trim() || null;
|
||||
} catch {}
|
||||
|
||||
const completedPhases = phases.filter(p => p.status === 'Complete').length;
|
||||
|
||||
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,
|
||||
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}] ${totalSummaries}/${totalPlans} plans (${percent}%)\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,
|
||||
@@ -545,4 +662,5 @@ module.exports = {
|
||||
cmdProgressRender,
|
||||
cmdTodoComplete,
|
||||
cmdScaffold,
|
||||
cmdStats,
|
||||
};
|
||||
|
||||
57
get-shit-done/workflows/stats.md
Normal file
57
get-shit-done/workflows/stats.md
Normal file
@@ -0,0 +1,57 @@
|
||||
<purpose>
|
||||
Display comprehensive project statistics including phases, plans, requirements, git metrics, and timeline.
|
||||
</purpose>
|
||||
|
||||
<required_reading>
|
||||
Read all files referenced by the invoking prompt's execution_context before starting.
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
|
||||
<step name="gather_stats">
|
||||
Gather project statistics:
|
||||
|
||||
```bash
|
||||
STATS=$(node "$GSD_TOOLS" stats json)
|
||||
if [[ "$STATS" == @file:* ]]; then STATS=$(cat "${STATS#@file:}"); fi
|
||||
```
|
||||
|
||||
Extract fields from JSON: `milestone_version`, `milestone_name`, `phases`, `total_plans`, `total_summaries`, `percent`, `requirements_total`, `requirements_complete`, `git_commits`, `git_first_commit_date`, `last_activity`.
|
||||
</step>
|
||||
|
||||
<step name="present_stats">
|
||||
Present to the user with this format:
|
||||
|
||||
```
|
||||
# 📊 Project Statistics — {milestone_version} {milestone_name}
|
||||
|
||||
## Progress
|
||||
[████████░░] X/Y plans (Z%)
|
||||
|
||||
## Phases
|
||||
| Phase | Name | Plans | Completed | Status |
|
||||
|-------|------|-------|-----------|--------|
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
## Requirements
|
||||
✅ X/Y requirements complete
|
||||
|
||||
## Git
|
||||
- **Commits:** N
|
||||
- **Started:** YYYY-MM-DD
|
||||
- **Last activity:** YYYY-MM-DD
|
||||
|
||||
## Timeline
|
||||
- **Project age:** N days
|
||||
```
|
||||
|
||||
If no `.planning/` directory exists, inform the user to run `/gsd:new-project` first.
|
||||
</step>
|
||||
|
||||
</process>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Statistics gathered from project state
|
||||
- [ ] Results formatted clearly
|
||||
- [ ] Displayed to user
|
||||
</success_criteria>
|
||||
@@ -1186,3 +1186,107 @@ describe('websearch command', () => {
|
||||
assert.strictEqual(output.error, 'Network timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats command', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('returns valid JSON with empty project', () => {
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(stats.phases), 'phases should be an array');
|
||||
assert.strictEqual(stats.total_plans, 0);
|
||||
assert.strictEqual(stats.total_summaries, 0);
|
||||
assert.strictEqual(stats.percent, 0);
|
||||
assert.strictEqual(stats.phases_completed, 0);
|
||||
assert.strictEqual(stats.phases_total, 0);
|
||||
assert.strictEqual(stats.requirements_total, 0);
|
||||
assert.strictEqual(stats.requirements_complete, 0);
|
||||
});
|
||||
|
||||
test('counts phases, plans, and summaries correctly', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.mkdirSync(p2, { recursive: true });
|
||||
|
||||
// Phase 1: 2 plans, 2 summaries (complete)
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
||||
|
||||
// Phase 2: 1 plan, 0 summaries (planned)
|
||||
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
|
||||
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
assert.strictEqual(stats.phases_total, 2);
|
||||
assert.strictEqual(stats.phases_completed, 1);
|
||||
assert.strictEqual(stats.total_plans, 3);
|
||||
assert.strictEqual(stats.total_summaries, 2);
|
||||
assert.strictEqual(stats.percent, 67);
|
||||
});
|
||||
|
||||
test('counts requirements from REQUIREMENTS.md', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
||||
`# Requirements
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
- [x] **AUTH-01**: User can sign up
|
||||
- [x] **AUTH-02**: User can log in
|
||||
- [ ] **API-01**: REST endpoints
|
||||
- [ ] **API-02**: GraphQL support
|
||||
`
|
||||
);
|
||||
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
assert.strictEqual(stats.requirements_total, 4);
|
||||
assert.strictEqual(stats.requirements_complete, 2);
|
||||
});
|
||||
|
||||
test('reads last activity from STATE.md', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Last Activity:** 2025-06-15\n**Last Activity Description:** Working\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('stats', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const stats = JSON.parse(result.output);
|
||||
assert.strictEqual(stats.last_activity, '2025-06-15');
|
||||
});
|
||||
|
||||
test('table format renders readable output', () => {
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('stats table', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.ok(parsed.rendered, 'table format should include rendered field');
|
||||
assert.ok(parsed.rendered.includes('Statistics'), 'should include Statistics header');
|
||||
assert.ok(parsed.rendered.includes('| Phase |'), 'should include table header');
|
||||
assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user