From 944df19926270a8978e9c8cafbedcd1101e98cab Mon Sep 17 00:00:00 2001 From: ashanuoc <59000312+ashanuoc@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:58:09 -0400 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.6 --- commands/gsd/stats.md | 18 +++++ get-shit-done/bin/gsd-tools.cjs | 6 ++ get-shit-done/bin/lib/commands.cjs | 118 +++++++++++++++++++++++++++++ get-shit-done/workflows/stats.md | 57 ++++++++++++++ tests/commands.test.cjs | 104 +++++++++++++++++++++++++ 5 files changed, 303 insertions(+) create mode 100644 commands/gsd/stats.md create mode 100644 get-shit-done/workflows/stats.md 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'); + }); +});