diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 01d24685a..73decc8bd 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs'); +const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { MODEL_PROFILES } = require('./model-profiles.cjs'); @@ -535,21 +535,42 @@ function cmdScaffold(cwd, type, options, raw) { function cmdStats(cwd, format, raw) { const phasesDir = path.join(cwd, '.planning', 'phases'); + const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); const statePath = path.join(cwd, '.planning', 'STATE.md'); const milestone = getMilestoneInfo(cwd); + const isDirInMilestone = getMilestonePhaseFilter(cwd); // Phase & plan stats (reuse progress pattern) - const phases = []; + const phasesByNumber = new Map(); let totalPlans = 0; let totalSummaries = 0; + try { + const roadmapContent = stripShippedMilestones(fs.readFileSync(roadmapPath, 'utf-8')); + const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + let match; + while ((match = headingPattern.exec(roadmapContent)) !== null) { + phasesByNumber.set(match[1], { + number: match[1], + name: match[2].replace(/\(INSERTED\)/i, '').trim(), + plans: 0, + summaries: 0, + status: 'Not Started', + }); + } + } catch {} + 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)); + const dirs = entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(isDirInMilestone) + .sort((a, b) => comparePhaseNum(a, b)); for (const dir of dirs) { - const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/); + const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNum = dm ? dm[1] : dir; const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); @@ -560,16 +581,26 @@ function cmdStats(cwd, format, raw) { totalSummaries += summaries; let status; - if (plans === 0) status = 'Pending'; + if (plans === 0) status = 'Not Started'; 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 }); + const existing = phasesByNumber.get(phaseNum); + phasesByNumber.set(phaseNum, { + number: phaseNum, + name: existing?.name || phaseName, + plans, + summaries, + status, + }); } } catch {} - const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0; + const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number)); + const completedPhases = phases.filter(p => p.status === 'Complete').length; + const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0; + const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0; // Requirements stats let requirementsTotal = 0; @@ -589,7 +620,10 @@ function cmdStats(cwd, format, raw) { try { if (fs.existsSync(statePath)) { const stateContent = fs.readFileSync(statePath, 'utf-8'); - const activityMatch = stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/); + const activityMatch = stateContent.match(/^last_activity:\s*(.+)$/im) + || stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i) + || stateContent.match(/^Last Activity:\s*(.+)$/im) + || stateContent.match(/^Last activity:\s*(.+)$/im); if (activityMatch) lastActivity = activityMatch[1].trim(); } } catch {} @@ -597,14 +631,18 @@ function cmdStats(cwd, format, raw) { // 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 commitCount = execGit(cwd, ['rev-list', '--count', 'HEAD']); + if (commitCount.exitCode === 0) { + gitCommits = parseInt(commitCount.stdout, 10) || 0; + } + const rootHash = execGit(cwd, ['rev-list', '--max-parents=0', 'HEAD']); + if (rootHash.exitCode === 0 && rootHash.stdout) { + const firstCommit = rootHash.stdout.split('\n')[0].trim(); + const firstDate = execGit(cwd, ['show', '-s', '--format=%as', firstCommit]); + if (firstDate.exitCode === 0) { + gitFirstCommitDate = firstDate.stdout || null; + } + } const result = { milestone_version: milestone.version, @@ -615,6 +653,7 @@ function cmdStats(cwd, format, raw) { total_plans: totalPlans, total_summaries: totalSummaries, percent, + plan_percent: planPercent, requirements_total: requirementsTotal, requirements_complete: requirementsComplete, git_commits: gitCommits, @@ -627,7 +666,10 @@ function cmdStats(cwd, format, raw) { 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 += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`; + if (totalPlans > 0) { + out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`; + } out += `**Phases:** ${completedPhases}/${phases.length} complete\n`; if (requirementsTotal > 0) { out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`; diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index a8f40d21b..9ac791498 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); +const { execSync, spawnSync } = require('child_process'); const { MODEL_PROFILES } = require('./model-profiles.cjs'); // ─── Path helpers ──────────────────────────────────────────────────────────── @@ -132,24 +132,16 @@ function isGitIgnored(cwd, targetPath) { } function execGit(cwd, args) { - try { - const escaped = args.map(a => { - if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a; - return "'" + a.replace(/'/g, "'\\''") + "'"; - }); - const stdout = execSync('git ' + escaped.join(' '), { - cwd, - stdio: 'pipe', - encoding: 'utf-8', - }); - return { exitCode: 0, stdout: stdout.trim(), stderr: '' }; - } catch (err) { - return { - exitCode: err.status ?? 1, - stdout: (err.stdout ?? '').toString().trim(), - stderr: (err.stderr ?? '').toString().trim(), - }; - } + const result = spawnSync('git', args, { + cwd, + stdio: 'pipe', + encoding: 'utf-8', + }); + return { + exitCode: result.status ?? 1, + stdout: (result.stdout ?? '').toString().trim(), + stderr: (result.stderr ?? '').toString().trim(), + }; } // ─── Phase utilities ────────────────────────────────────────────────────────── diff --git a/get-shit-done/workflows/stats.md b/get-shit-done/workflows/stats.md index 7272b6b70..b3021c358 100644 --- a/get-shit-done/workflows/stats.md +++ b/get-shit-done/workflows/stats.md @@ -16,7 +16,7 @@ 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`. +Extract fields from JSON: `milestone_version`, `milestone_name`, `phases`, `phases_completed`, `phases_total`, `total_plans`, `total_summaries`, `percent`, `plan_percent`, `requirements_total`, `requirements_complete`, `git_commits`, `git_first_commit_date`, `last_activity`. @@ -26,7 +26,10 @@ Present to the user with this format: # 📊 Project Statistics — {milestone_version} {milestone_name} ## Progress -[████████░░] X/Y plans (Z%) +[████████░░] X/Y phases (Z%) + +## Plans +X/Y plans complete (Z%) ## Phases | Phase | Name | Plans | Completed | Status | diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index a512b20e0..b43d2827a 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -4,6 +4,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert'); +const { execSync } = require('node:child_process'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); @@ -1236,7 +1237,8 @@ describe('stats command', () => { assert.strictEqual(stats.phases_completed, 1); assert.strictEqual(stats.total_plans, 3); assert.strictEqual(stats.total_summaries, 2); - assert.strictEqual(stats.percent, 67); + assert.strictEqual(stats.percent, 50); + assert.strictEqual(stats.plan_percent, 67); }); test('counts requirements from REQUIREMENTS.md', () => { @@ -1274,6 +1276,105 @@ describe('stats command', () => { assert.strictEqual(stats.last_activity, '2025-06-15'); }); + test('reads last activity from plain STATE.md template format', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n## Current Position\n\nPhase: 1 of 2 (Foundation)\nPlan: 1 of 1 in current phase\nStatus: In progress\nLast activity: 2025-06-16 — Finished plan 01-01\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-16 — Finished plan 01-01'); + }); + + test('includes roadmap-only phases in totals and preserves hyphenated names', () => { + const p1 = path.join(tmpDir, '.planning', 'phases', '14-auth-hardening'); + const p2 = path.join(tmpDir, '.planning', 'phases', '15-proof-generation'); + fs.mkdirSync(p1, { recursive: true }); + fs.mkdirSync(p2, { recursive: true }); + fs.writeFileSync(path.join(p1, '14-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(p1, '14-01-SUMMARY.md'), '# Summary'); + fs.writeFileSync(path.join(p2, '15-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(p2, '15-01-SUMMARY.md'), '# Summary'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +- [x] **Phase 14: Auth Hardening** +- [x] **Phase 15: Proof Generation** +- [ ] **Phase 16: Multi-Claim Verification & UX** + +## Milestone v1.0 Growth + +### Phase 14: Auth Hardening +**Goal:** Improve auth checks + +### Phase 15: Proof Generation +**Goal:** Improve proof generation + +### Phase 16: Multi-Claim Verification & UX +**Goal:** Support multi-claim verification +` + ); + + const result = runGsdTools('stats', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const stats = JSON.parse(result.output); + assert.strictEqual(stats.phases_total, 3); + assert.strictEqual(stats.phases_completed, 2); + assert.strictEqual(stats.percent, 67); + assert.strictEqual(stats.plan_percent, 100); + assert.strictEqual( + stats.phases.find(p => p.number === '16')?.name, + 'Multi-Claim Verification & UX' + ); + assert.strictEqual( + stats.phases.find(p => p.number === '16')?.status, + 'Not Started' + ); + }); + + test('reports git commit count and first commit date from repository history', () => { + execSync('git init', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git config user.email "test@example.com"', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: 'pipe' }); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); + execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git commit -m "initial commit"', { + cwd: tmpDir, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z', + GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z', + }, + }); + + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Updated\n'); + execSync('git add README.md', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git commit -m "second commit"', { + cwd: tmpDir, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_DATE: '2026-02-01T00:00:00Z', + GIT_COMMITTER_DATE: '2026-02-01T00:00:00Z', + }, + }); + + const result = runGsdTools('stats', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const stats = JSON.parse(result.output); + assert.strictEqual(stats.git_commits, 2); + assert.strictEqual(stats.git_first_commit_date, '2026-01-01'); + }); + test('table format renders readable output', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth'); fs.mkdirSync(p1, { recursive: true }); @@ -1288,5 +1389,6 @@ describe('stats command', () => { 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'); + assert.ok(parsed.rendered.includes('1/1 phases'), 'should report phase progress'); }); });