Skip to content

Commit 6806678

Browse files
ashanuocclaude
andcommitted
feat: add /gsd:stats command for project statistics (#new)
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: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2eaed7a commit 6806678

File tree

5 files changed

+303
-0
lines changed

5 files changed

+303
-0
lines changed

commands/gsd/stats.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
name: gsd:stats
3+
description: Display project statistics — phases, plans, requirements, git metrics, and timeline
4+
allowed-tools:
5+
- Read
6+
- Bash
7+
---
8+
<objective>
9+
Display comprehensive project statistics including phase progress, plan execution metrics, requirements completion, git history stats, and project timeline.
10+
</objective>
11+
12+
<execution_context>
13+
@~/.claude/get-shit-done/workflows/stats.md
14+
</execution_context>
15+
16+
<process>
17+
Execute the stats workflow from @~/.claude/get-shit-done/workflows/stats.md end-to-end.
18+
</process>

get-shit-done/bin/gsd-tools.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,12 @@ async function main() {
488488
break;
489489
}
490490

491+
case 'stats': {
492+
const subcommand = args[1] || 'json';
493+
commands.cmdStats(cwd, subcommand, raw);
494+
break;
495+
}
496+
491497
case 'todo': {
492498
const subcommand = args[1];
493499
if (subcommand === 'complete') {

get-shit-done/bin/lib/commands.cjs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,123 @@ function cmdScaffold(cwd, type, options, raw) {
532532
output({ created: true, path: relPath }, raw, relPath);
533533
}
534534

535+
function cmdStats(cwd, format, raw) {
536+
const phasesDir = path.join(cwd, '.planning', 'phases');
537+
const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md');
538+
const statePath = path.join(cwd, '.planning', 'STATE.md');
539+
const milestone = getMilestoneInfo(cwd);
540+
541+
// Phase & plan stats (reuse progress pattern)
542+
const phases = [];
543+
let totalPlans = 0;
544+
let totalSummaries = 0;
545+
546+
try {
547+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
548+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
549+
550+
for (const dir of dirs) {
551+
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
552+
const phaseNum = dm ? dm[1] : dir;
553+
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
554+
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
555+
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
556+
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
557+
558+
totalPlans += plans;
559+
totalSummaries += summaries;
560+
561+
let status;
562+
if (plans === 0) status = 'Pending';
563+
else if (summaries >= plans) status = 'Complete';
564+
else if (summaries > 0) status = 'In Progress';
565+
else status = 'Planned';
566+
567+
phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
568+
}
569+
} catch {}
570+
571+
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
572+
573+
// Requirements stats
574+
let requirementsTotal = 0;
575+
let requirementsComplete = 0;
576+
try {
577+
if (fs.existsSync(reqPath)) {
578+
const reqContent = fs.readFileSync(reqPath, 'utf-8');
579+
const checked = reqContent.match(/^- \[x\] \*\*/gm);
580+
const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
581+
requirementsComplete = checked ? checked.length : 0;
582+
requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
583+
}
584+
} catch {}
585+
586+
// Last activity from STATE.md
587+
let lastActivity = null;
588+
try {
589+
if (fs.existsSync(statePath)) {
590+
const stateContent = fs.readFileSync(statePath, 'utf-8');
591+
const activityMatch = stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/);
592+
if (activityMatch) lastActivity = activityMatch[1].trim();
593+
}
594+
} catch {}
595+
596+
// Git stats
597+
let gitCommits = 0;
598+
let gitFirstCommitDate = null;
599+
try {
600+
const commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']);
601+
gitCommits = parseInt(commitCount.trim(), 10) || 0;
602+
const firstDate = execGit(cwd, ['log', '--reverse', '--format=%as', '--max-count=1']);
603+
gitFirstCommitDate = firstDate.trim() || null;
604+
} catch {}
605+
606+
const completedPhases = phases.filter(p => p.status === 'Complete').length;
607+
608+
const result = {
609+
milestone_version: milestone.version,
610+
milestone_name: milestone.name,
611+
phases,
612+
phases_completed: completedPhases,
613+
phases_total: phases.length,
614+
total_plans: totalPlans,
615+
total_summaries: totalSummaries,
616+
percent,
617+
requirements_total: requirementsTotal,
618+
requirements_complete: requirementsComplete,
619+
git_commits: gitCommits,
620+
git_first_commit_date: gitFirstCommitDate,
621+
last_activity: lastActivity,
622+
};
623+
624+
if (format === 'table') {
625+
const barWidth = 10;
626+
const filled = Math.round((percent / 100) * barWidth);
627+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
628+
let out = `# ${milestone.version} ${milestone.name} \u2014 Statistics\n\n`;
629+
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n`;
630+
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
631+
if (requirementsTotal > 0) {
632+
out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
633+
}
634+
out += '\n';
635+
out += `| Phase | Name | Plans | Completed | Status |\n`;
636+
out += `|-------|------|-------|-----------|--------|\n`;
637+
for (const p of phases) {
638+
out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
639+
}
640+
if (gitCommits > 0) {
641+
out += `\n**Git:** ${gitCommits} commits`;
642+
if (gitFirstCommitDate) out += ` (since ${gitFirstCommitDate})`;
643+
out += '\n';
644+
}
645+
if (lastActivity) out += `**Last activity:** ${lastActivity}\n`;
646+
output({ rendered: out }, raw, out);
647+
} else {
648+
output(result, raw);
649+
}
650+
}
651+
535652
module.exports = {
536653
cmdGenerateSlug,
537654
cmdCurrentTimestamp,
@@ -545,4 +662,5 @@ module.exports = {
545662
cmdProgressRender,
546663
cmdTodoComplete,
547664
cmdScaffold,
665+
cmdStats,
548666
};

get-shit-done/workflows/stats.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<purpose>
2+
Display comprehensive project statistics including phases, plans, requirements, git metrics, and timeline.
3+
</purpose>
4+
5+
<required_reading>
6+
Read all files referenced by the invoking prompt's execution_context before starting.
7+
</required_reading>
8+
9+
<process>
10+
11+
<step name="gather_stats">
12+
Gather project statistics:
13+
14+
```bash
15+
STATS=$(node "$GSD_TOOLS" stats json)
16+
if [[ "$STATS" == @file:* ]]; then STATS=$(cat "${STATS#@file:}"); fi
17+
```
18+
19+
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`.
20+
</step>
21+
22+
<step name="present_stats">
23+
Present to the user with this format:
24+
25+
```
26+
# 📊 Project Statistics — {milestone_version} {milestone_name}
27+
28+
## Progress
29+
[████████░░] X/Y plans (Z%)
30+
31+
## Phases
32+
| Phase | Name | Plans | Completed | Status |
33+
|-------|------|-------|-----------|--------|
34+
| ... | ... | ... | ... | ... |
35+
36+
## Requirements
37+
✅ X/Y requirements complete
38+
39+
## Git
40+
- **Commits:** N
41+
- **Started:** YYYY-MM-DD
42+
- **Last activity:** YYYY-MM-DD
43+
44+
## Timeline
45+
- **Project age:** N days
46+
```
47+
48+
If no `.planning/` directory exists, inform the user to run `/gsd:new-project` first.
49+
</step>
50+
51+
</process>
52+
53+
<success_criteria>
54+
- [ ] Statistics gathered from project state
55+
- [ ] Results formatted clearly
56+
- [ ] Displayed to user
57+
</success_criteria>

tests/commands.test.cjs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,3 +1186,107 @@ describe('websearch command', () => {
11861186
assert.strictEqual(output.error, 'Network timeout');
11871187
});
11881188
});
1189+
1190+
describe('stats command', () => {
1191+
let tmpDir;
1192+
1193+
beforeEach(() => {
1194+
tmpDir = createTempProject();
1195+
});
1196+
1197+
afterEach(() => {
1198+
cleanup(tmpDir);
1199+
});
1200+
1201+
test('returns valid JSON with empty project', () => {
1202+
const result = runGsdTools('stats', tmpDir);
1203+
assert.ok(result.success, `Command failed: ${result.error}`);
1204+
1205+
const stats = JSON.parse(result.output);
1206+
assert.ok(Array.isArray(stats.phases), 'phases should be an array');
1207+
assert.strictEqual(stats.total_plans, 0);
1208+
assert.strictEqual(stats.total_summaries, 0);
1209+
assert.strictEqual(stats.percent, 0);
1210+
assert.strictEqual(stats.phases_completed, 0);
1211+
assert.strictEqual(stats.phases_total, 0);
1212+
assert.strictEqual(stats.requirements_total, 0);
1213+
assert.strictEqual(stats.requirements_complete, 0);
1214+
});
1215+
1216+
test('counts phases, plans, and summaries correctly', () => {
1217+
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
1218+
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
1219+
fs.mkdirSync(p1, { recursive: true });
1220+
fs.mkdirSync(p2, { recursive: true });
1221+
1222+
// Phase 1: 2 plans, 2 summaries (complete)
1223+
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
1224+
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
1225+
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
1226+
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
1227+
1228+
// Phase 2: 1 plan, 0 summaries (planned)
1229+
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
1230+
1231+
const result = runGsdTools('stats', tmpDir);
1232+
assert.ok(result.success, `Command failed: ${result.error}`);
1233+
1234+
const stats = JSON.parse(result.output);
1235+
assert.strictEqual(stats.phases_total, 2);
1236+
assert.strictEqual(stats.phases_completed, 1);
1237+
assert.strictEqual(stats.total_plans, 3);
1238+
assert.strictEqual(stats.total_summaries, 2);
1239+
assert.strictEqual(stats.percent, 67);
1240+
});
1241+
1242+
test('counts requirements from REQUIREMENTS.md', () => {
1243+
fs.writeFileSync(
1244+
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
1245+
`# Requirements
1246+
1247+
## v1 Requirements
1248+
1249+
- [x] **AUTH-01**: User can sign up
1250+
- [x] **AUTH-02**: User can log in
1251+
- [ ] **API-01**: REST endpoints
1252+
- [ ] **API-02**: GraphQL support
1253+
`
1254+
);
1255+
1256+
const result = runGsdTools('stats', tmpDir);
1257+
assert.ok(result.success, `Command failed: ${result.error}`);
1258+
1259+
const stats = JSON.parse(result.output);
1260+
assert.strictEqual(stats.requirements_total, 4);
1261+
assert.strictEqual(stats.requirements_complete, 2);
1262+
});
1263+
1264+
test('reads last activity from STATE.md', () => {
1265+
fs.writeFileSync(
1266+
path.join(tmpDir, '.planning', 'STATE.md'),
1267+
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Last Activity:** 2025-06-15\n**Last Activity Description:** Working\n`
1268+
);
1269+
1270+
const result = runGsdTools('stats', tmpDir);
1271+
assert.ok(result.success, `Command failed: ${result.error}`);
1272+
1273+
const stats = JSON.parse(result.output);
1274+
assert.strictEqual(stats.last_activity, '2025-06-15');
1275+
});
1276+
1277+
test('table format renders readable output', () => {
1278+
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
1279+
fs.mkdirSync(p1, { recursive: true });
1280+
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
1281+
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
1282+
1283+
const result = runGsdTools('stats table', tmpDir);
1284+
assert.ok(result.success, `Command failed: ${result.error}`);
1285+
1286+
const parsed = JSON.parse(result.output);
1287+
assert.ok(parsed.rendered, 'table format should include rendered field');
1288+
assert.ok(parsed.rendered.includes('Statistics'), 'should include Statistics header');
1289+
assert.ok(parsed.rendered.includes('| Phase |'), 'should include table header');
1290+
assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row');
1291+
});
1292+
});

0 commit comments

Comments
 (0)