Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions commands/gsd/stats.md
Original file line number Diff line number Diff line change
@@ -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>
6 changes: 6 additions & 0 deletions get-shit-done/bin/gsd-tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
118 changes: 118 additions & 0 deletions get-shit-done/bin/lib/commands.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -545,4 +662,5 @@ module.exports = {
cmdProgressRender,
cmdTodoComplete,
cmdScaffold,
cmdStats,
};
57 changes: 57 additions & 0 deletions get-shit-done/workflows/stats.md
Original file line number Diff line number Diff line change
@@ -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>
104 changes: 104 additions & 0 deletions tests/commands.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});