diff --git a/commands/gsd/stats.md b/commands/gsd/stats.md
new file mode 100644
index 000000000..1194fb6be
--- /dev/null
+++ b/commands/gsd/stats.md
@@ -0,0 +1,18 @@
+---
+name: gsd:stats
+description: Display project statistics — phases, plans, requirements, git metrics, and timeline
+allowed-tools:
+ - Read
+ - Bash
+---
+
+Display comprehensive project statistics including phase progress, plan execution metrics, requirements completion, git history stats, and project timeline.
+
+
+
+@~/.claude/get-shit-done/workflows/stats.md
+
+
+
+Execute the stats workflow from @~/.claude/get-shit-done/workflows/stats.md end-to-end.
+
diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs
index 48cb9cf56..3312d9d8e 100755
--- a/get-shit-done/bin/gsd-tools.cjs
+++ b/get-shit-done/bin/gsd-tools.cjs
@@ -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') {
diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs
index e42034946..51cf59c9f 100644
--- a/get-shit-done/bin/lib/commands.cjs
+++ b/get-shit-done/bin/lib/commands.cjs
@@ -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,
};
diff --git a/get-shit-done/workflows/stats.md b/get-shit-done/workflows/stats.md
new file mode 100644
index 000000000..7272b6b70
--- /dev/null
+++ b/get-shit-done/workflows/stats.md
@@ -0,0 +1,57 @@
+
+Display comprehensive project statistics including phases, plans, requirements, git metrics, and timeline.
+
+
+
+Read all files referenced by the invoking prompt's execution_context before starting.
+
+
+
+
+
+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`.
+
+
+
+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.
+
+
+
+
+
+- [ ] Statistics gathered from project state
+- [ ] Results formatted clearly
+- [ ] Displayed to user
+
diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs
index e2497a800..a512b20e0 100644
--- a/tests/commands.test.cjs
+++ b/tests/commands.test.cjs
@@ -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');
+ });
+});