diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 2dc416240..6a4ed1a7b 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -399,6 +399,14 @@ async function runCommand(command, args, cwd, raw) { state.cmdSignalWaiting(cwd, type, question, options, p, raw); } else if (subcommand === 'signal-resume') { state.cmdSignalResume(cwd, raw); + } else if (subcommand === 'planned-phase') { + const { phase: p, name, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']); + state.cmdStatePlannedPhase(cwd, p, plans !== null ? parseInt(plans, 10) : null, raw); + } else if (subcommand === 'validate') { + state.cmdStateValidate(cwd, raw); + } else if (subcommand === 'sync') { + const { verify } = parseNamedArgs(args, [], ['verify']); + state.cmdStateSync(cwd, { verify }, raw); } else { state.cmdStateLoad(cwd, raw); } @@ -727,12 +735,16 @@ async function runCommand(command, args, cwd, raw) { case 'init': { const workflow = args[1]; switch (workflow) { - case 'execute-phase': - init.cmdInitExecutePhase(cwd, args[2], raw); + case 'execute-phase': { + const { validate: epValidate } = parseNamedArgs(args, [], ['validate']); + init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate }); break; - case 'plan-phase': - init.cmdInitPlanPhase(cwd, args[2], raw); + } + case 'plan-phase': { + const { validate: ppValidate } = parseNamedArgs(args, [], ['validate']); + init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate }); break; + } case 'new-project': init.cmdInitNewProject(cwd, raw); break; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 55eafc375..e117c67a0 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -47,7 +47,7 @@ function withProjectRoot(cwd, result) { return result; } -function cmdInitExecutePhase(cwd, phase, raw) { +function cmdInitExecutePhase(cwd, phase, raw, options = {}) { if (!phase) { error('phase required for init execute-phase'); } @@ -139,10 +139,38 @@ function cmdInitExecutePhase(cwd, phase, raw) { config_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'config.json'))), }; + // Optional --validate: run state validation and include warnings (#1627) + if (options.validate) { + try { + const { cmdStateValidate } = require('./state.cjs'); + // Capture validate output by temporarily redirecting + const statePath = path.join(planningDir(cwd), 'STATE.md'); + if (fs.existsSync(statePath)) { + const stateContent = fs.readFileSync(statePath, 'utf-8'); + const { stateExtractField } = require('./state.cjs'); + const status = stateExtractField(stateContent, 'Status') || ''; + result.state_validation_ran = true; + // Simple inline validation — check for obvious drift + const warnings = []; + const phasesPath = planningPaths(cwd).phases; + if (phaseInfo && phaseInfo.directory && fs.existsSync(path.join(cwd, phaseInfo.directory))) { + const files = fs.readdirSync(path.join(cwd, phaseInfo.directory)); + const diskPlans = files.filter(f => f.match(/-PLAN\.md$/i)).length; + const totalPlansRaw = stateExtractField(stateContent, 'Total Plans in Phase'); + const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; + if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) { + warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase}, disk has ${diskPlans}`); + } + } + result.state_warnings = warnings; + } + } catch { /* intentionally empty */ } + } + output(withProjectRoot(cwd, result), raw); } -function cmdInitPlanPhase(cwd, phase, raw) { +function cmdInitPlanPhase(cwd, phase, raw, options = {}) { if (!phase) { error('phase required for init plan-phase'); } @@ -243,6 +271,25 @@ function cmdInitPlanPhase(cwd, phase, raw) { } catch { /* intentionally empty */ } } + // Optional --validate: run state validation and include warnings (#1627) + if (options.validate) { + try { + const statePath = path.join(planningDir(cwd), 'STATE.md'); + if (fs.existsSync(statePath)) { + const { stateExtractField } = require('./state.cjs'); + const stateContent = fs.readFileSync(statePath, 'utf-8'); + const warnings = []; + result.state_validation_ran = true; + const totalPlansRaw = stateExtractField(stateContent, 'Total Plans in Phase'); + const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; + if (totalPlansInPhase !== null && phaseInfo && totalPlansInPhase !== (phaseInfo.plans?.length || 0)) { + warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase}, disk has ${phaseInfo.plans?.length || 0}`); + } + result.state_warnings = warnings; + } + } catch { /* intentionally empty */ } + } + output(withProjectRoot(cwd, result), raw); } diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 605134cef..07b06d906 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -6,7 +6,7 @@ const fs = require('fs'); const path = require('path'); const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories, phaseTokenMatches } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); -const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs'); +const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs'); function cmdPhasesList(cwd, options, raw) { const phasesDir = path.join(planningDir(cwd), 'phases'); @@ -895,6 +895,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { } } + // Gate 4: Update Performance Metrics section (#1627) + stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount); + writeStateMd(statePath, stateContent, cwd); } diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 91031d1fe..be5252172 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -1054,11 +1054,283 @@ function cmdSignalResume(cwd, raw) { output({ resumed: true, removed }, raw, removed ? 'true' : 'false'); } +// ─── Gate Functions (STATE.md consistency enforcement) ──────────────────────── + +/** + * Update the ## Performance Metrics section in STATE.md content. + * Increments Velocity totals and upserts a By Phase table row. + * Returns modified content string. + */ +function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) { + // Update Velocity: Total plans completed + const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/); + const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0; + const newTotal = prevTotal + summaryCount; + content = content.replace( + /Total plans completed:\s*(\d+|\[N\])/, + `Total plans completed: ${newTotal}` + ); + + // Update By Phase table — upsert row for this phase + const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i; + const byPhaseMatch = content.match(byPhaseTablePattern); + if (byPhaseMatch) { + let tableBody = byPhaseMatch[2].trim(); + const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm'); + const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`; + + if (phaseRowPattern.test(tableBody)) { + // Update existing row + tableBody = tableBody.replace(phaseRowPattern, newRow); + } else { + // Remove placeholder row and add new row + tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim(); + tableBody = tableBody ? tableBody + '\n' + newRow : newRow; + } + + content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`); + } + + return content; +} + +/** + * Gate 3a: Record state after plan-phase completes. + * Updates Status to "Ready to execute", Total Plans, Last Activity. + */ +function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) { + const statePath = planningPaths(cwd).state; + if (!fs.existsSync(statePath)) { + output({ error: 'STATE.md not found' }, raw); + return; + } + + let content = fs.readFileSync(statePath, 'utf-8'); + const today = new Date().toISOString().split('T')[0]; + const updated = []; + + // Update Status + let result = stateReplaceField(content, 'Status', 'Ready to execute'); + if (result) { content = result; updated.push('Status'); } + + // Update Total Plans in Phase + if (planCount !== null && planCount !== undefined) { + result = stateReplaceField(content, 'Total Plans in Phase', String(planCount)); + if (result) { content = result; updated.push('Total Plans in Phase'); } + } + + // Update Last Activity + result = stateReplaceField(content, 'Last Activity', today); + if (result) { content = result; updated.push('Last Activity'); } + + // Update Last Activity Description + result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`); + if (result) { content = result; updated.push('Last Activity Description'); } + + // Update Current Position section + content = updateCurrentPositionFields(content, { + status: 'Ready to execute', + lastActivity: `${today} -- Phase ${phaseNumber} planning complete`, + }); + + if (updated.length > 0) { + writeStateMd(statePath, content, cwd); + } + + output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false'); +} + +/** + * Gate 1: Validate STATE.md against filesystem. + * Returns { valid, warnings, drift } JSON. + */ +function cmdStateValidate(cwd, raw) { + const statePath = planningPaths(cwd).state; + if (!fs.existsSync(statePath)) { + output({ error: 'STATE.md not found' }, raw); + return; + } + + const content = fs.readFileSync(statePath, 'utf-8'); + const warnings = []; + const drift = {}; + + const status = stateExtractField(content, 'Status') || ''; + const currentPhase = stateExtractField(content, 'Current Phase'); + const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase'); + const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; + + const phasesDir = planningPaths(cwd).phases; + + // Scan disk for current phase + if (currentPhase && fs.existsSync(phasesDir)) { + const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim(); + try { + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0'))); + if (phaseDir) { + const phaseDirPath = path.join(phasesDir, phaseDir.name); + const files = fs.readdirSync(phaseDirPath); + const diskPlans = files.filter(f => f.match(/-PLAN\.md$/i)).length; + const diskSummaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length; + + // Check plan count mismatch + if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) { + warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`); + drift.plan_count = { state: totalPlansInPhase, disk: diskPlans }; + } + + // Check for VERIFICATION.md + const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')); + for (const vf of verificationFiles) { + try { + const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8'); + if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) { + warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`); + drift.verification_status = { state_status: status, verification: 'passed' }; + } + } catch { /* intentionally empty */ } + } + + // Check if all plans have summaries but status still says executing + if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) { + // Only warn if no verification exists (if verification passed, the above warning covers it) + if (verificationFiles.length === 0) { + warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`); + } + } + } + } catch { /* intentionally empty */ } + } + + const valid = warnings.length === 0; + output({ valid, warnings, drift }, raw); +} + +/** + * Gate 2: Sync STATE.md from filesystem ground truth. + * Scans phase dirs, reconstructs counters, progress, metrics. + * Supports --verify for dry-run mode. + */ +function cmdStateSync(cwd, options, raw) { + const statePath = planningPaths(cwd).state; + if (!fs.existsSync(statePath)) { + output({ error: 'STATE.md not found' }, raw); + return; + } + + const verify = options && options.verify; + const content = fs.readFileSync(statePath, 'utf-8'); + const changes = []; + let modified = content; + const today = new Date().toISOString().split('T')[0]; + + const phasesDir = planningPaths(cwd).phases; + if (!fs.existsSync(phasesDir)) { + output({ synced: true, changes: [], dry_run: !!verify }, raw); + return; + } + + // Scan all phases + let entries; + try { + entries = fs.readdirSync(phasesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort(); + } catch { + output({ synced: true, changes: [], dry_run: !!verify }, raw); + return; + } + + let totalDiskPlans = 0; + let totalDiskSummaries = 0; + let highestIncompletePhase = null; + let highestIncompletePhaseNum = null; + let highestIncompletePhaseplanCount = 0; + let highestIncompletePhaseSummaryCount = 0; + + for (const dir of entries) { + const dirPath = path.join(phasesDir, dir); + const files = fs.readdirSync(dirPath); + const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length; + const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length; + totalDiskPlans += plans; + totalDiskSummaries += summaries; + + // Track the highest phase with incomplete plans (or any plans) + const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + if (phaseMatch && plans > 0) { + if (summaries < plans) { + // Incomplete phase — this is likely the current one + highestIncompletePhase = dir; + highestIncompletePhaseNum = phaseMatch[1]; + highestIncompletePhaseplanCount = plans; + highestIncompletePhaseSummaryCount = summaries; + } else if (!highestIncompletePhase) { + // All complete, track as potential current + highestIncompletePhase = dir; + highestIncompletePhaseNum = phaseMatch[1]; + highestIncompletePhaseplanCount = plans; + highestIncompletePhaseSummaryCount = summaries; + } + } + } + + // Sync Total Plans in Phase + if (highestIncompletePhase) { + const currentPlansField = stateExtractField(modified, 'Total Plans in Phase'); + if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) { + changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`); + const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount)); + if (result) modified = result; + } + } + + // Sync Progress + const percent = totalDiskPlans > 0 ? Math.min(100, Math.round(totalDiskSummaries / totalDiskPlans * 100)) : 0; + const currentProgress = stateExtractField(modified, 'Progress'); + if (currentProgress) { + const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10); + if (currentPercent !== percent) { + const barWidth = 10; + const filled = Math.round(percent / 100 * barWidth); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); + const progressStr = `[${bar}] ${percent}%`; + changes.push(`Progress: ${currentProgress} -> ${progressStr}`); + const result = stateReplaceField(modified, 'Progress', progressStr); + if (result) modified = result; + } + } + + // Sync Last Activity + const result = stateReplaceField(modified, 'Last Activity', today); + if (result) { + const oldActivity = stateExtractField(modified, 'Last Activity'); + if (oldActivity !== today) { + changes.push(`Last Activity: ${oldActivity} -> ${today}`); + } + modified = result; + } + + if (verify) { + output({ synced: false, changes, dry_run: true }, raw); + return; + } + + if (changes.length > 0 || modified !== content) { + writeStateMd(statePath, modified, cwd); + } + + output({ synced: true, changes, dry_run: false }, raw); +} + module.exports = { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, writeStateMd, + updatePerformanceMetricsSection, cmdStateLoad, cmdStateGet, cmdStatePatch, @@ -1073,6 +1345,9 @@ module.exports = { cmdStateSnapshot, cmdStateJson, cmdStateBeginPhase, + cmdStatePlannedPhase, + cmdStateValidate, + cmdStateSync, cmdSignalWaiting, cmdSignalResume, }; diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 90f449c8e..d0845ac4a 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -511,11 +511,9 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap update-plan-progress "${PHASE_NUMBER}" "${PLAN_ID}" completed done - # Update STATE.md position to reflect the last completed plan in this wave - node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state update-position --phase "${PHASE_NUMBER}" --plan "${LAST_PLAN_ID}" ``` - Where `WAVE_PLAN_IDS` is the space-separated list of plan IDs that completed in this wave, and `LAST_PLAN_ID` is the last plan ID in the wave (used to set current position). + Where `WAVE_PLAN_IDS` is the space-separated list of plan IDs that completed in this wave. **If `workflow.use_worktrees` is `false`:** Sequential agents already updated STATE.md and ROADMAP.md themselves — skip this step. diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index 335e1d7f7..666327864 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -869,6 +869,16 @@ Options: If `TEXT_MODE` is true, present as a plain-text numbered list (options already shown in the block above). Otherwise use AskUserQuestion to present the options. +## 13b. Record Planning Completion in STATE.md + +After plans pass all gates, record that planning is complete so STATE.md reflects the new phase status: + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state planned-phase --phase "${PHASE_NUMBER}" --name "${PHASE_NAME}" --plans "${PLAN_COUNT}" +``` + +This updates STATUS to "Ready to execute", sets the correct plan count, and timestamps Last Activity. + ## 14. Present Final Status Route to `` OR `auto_advance` depending on flags/config. diff --git a/tests/execute-phase-worktree-artifacts.test.cjs b/tests/execute-phase-worktree-artifacts.test.cjs index 0e986e9d6..b801c8245 100644 --- a/tests/execute-phase-worktree-artifacts.test.cjs +++ b/tests/execute-phase-worktree-artifacts.test.cjs @@ -85,18 +85,11 @@ describe('execute-phase worktree: shared artifact ownership (#1571)', () => { ); }); - test('post-wave orchestrator runs state update-position after completing a wave in worktree mode', () => { + test('ghost state update-position command removed from post-wave section (#1627)', () => { const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8'); assert.ok( - content.includes('state update-position'), - 'post-wave section should contain orchestrator-owned state update-position command' - ); - // Confirm it appears after the worktree agent block - const stateUpdateIdx = content.lastIndexOf('state update-position'); - const worktreeAgentEnd = content.indexOf('**Sequential mode**'); - assert.ok( - stateUpdateIdx > worktreeAgentEnd, - 'state update-position must appear after the worktree agent block (orchestrator-owned, post-wave)' + !content.includes('state update-position'), + 'state update-position was a ghost reference (command never existed in CLI dispatcher) — should be removed' ); }); diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 30823ec40..e8df4fb18 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -2034,8 +2034,6 @@ describe('phase resolution uses exact token matching', () => { }); test('1009 must NOT match 1009A-feature-consistency when 1009 dir is absent', () => { - // With only 1009A on disk, searching for 1009 should return not-found - // because 1009 !== 1009A (prefix match bug: '1009A-...' starts with '1009') const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency')); fs.writeFileSync(path.join(phasesDir, '1009A-feature-consistency', 'PLAN.md'), '# Plan'); @@ -2063,8 +2061,6 @@ describe('phase resolution uses exact token matching', () => { }); test('999.6 must NOT match 999.60-episode-processing when 999.6 dir is absent', () => { - // With only 999.60 on disk, searching for 999.6 should return not-found - // because '999.60-...' starts with '999.6' (prefix match bug) const phasesDir = path.join(tmpDir, '.planning', 'phases'); fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing')); fs.writeFileSync(path.join(phasesDir, '999.60-episode-processing', 'PLAN.md'), '# Plan'); @@ -2112,6 +2108,80 @@ describe('phase resolution uses exact token matching', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// phase complete — Performance Metrics gate (Step 2 — Gate 4) +// ───────────────────────────────────────────────────────────────────────────── + +describe('phase complete updates Performance Metrics', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('after cmdPhaseComplete: Performance Metrics has updated total plans count', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 2\n**Status:** Executing Phase 2\n**Total Plans in Phase:** 3\n**Current Plan:** 3\n**Completed Phases:** 0\n**Total Phases:** 3\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n'); + fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan 2\n'); + fs.writeFileSync(path.join(phaseDir, '02-03-PLAN.md'), '# Plan 3\n'); + fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(phaseDir, '02-03-SUMMARY.md'), '# Summary\n'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n` + ); + + const result = runGsdTools('phase complete 2', tmpDir); + assert.ok(result.success, `phase complete failed: ${result.error}`); + + const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3'); + }); + + test('after cmdPhaseComplete: By Phase table has row for completed phase', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Current Phase:** 1\n**Status:** Executing Phase 1\n**Total Plans in Phase:** 2\n**Current Plan:** 2\n**Completed Phases:** 0\n**Total Phases:** 2\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n` + ); + + const result = runGsdTools('phase complete 1', tmpDir); + assert.ok(result.success, `phase complete failed: ${result.error}`); + + const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(stateAfter.match(/\|\s*1\s*\|\s*2\s*\|/), 'By Phase table should have row for phase 1 with 2 plans'); + // Row must appear BEFORE the next section, not after it (regression: empty table body regex) + const rowIdx = stateAfter.indexOf('| 1 |'); + const accIdx = stateAfter.indexOf('## Accumulated Context'); + if (accIdx !== -1) { + assert.ok(rowIdx < accIdx, 'By Phase row must appear before ## Accumulated Context section'); + } + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // milestone complete command // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 2ad8e164a..4071dae1a 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -1739,6 +1739,415 @@ describe('progress counters correct after plan execution (#1589)', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// updatePerformanceMetricsSection (Step 1) +// ───────────────────────────────────────────────────────────────────────────── + +describe('updatePerformanceMetricsSection', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('empty Performance Metrics section rebuilds with zeros', () => { + const content = `# Project State + +**Status:** Executing Phase 3 + +## Performance Metrics + +**Velocity:** +- Total plans completed: [N] +- Average duration: [X] min +- Total execution time: [X.X] hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| - | - | - | - | + +## Accumulated Context +`; + + // We test via the CLI: phase complete triggers updatePerformanceMetricsSection + // But first let's test the helper directly via state planned-phase + phase complete flow + // For a unit-style test, write STATE.md and call state validate to check metrics + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, content); + + // Create a phase with 2 plans, 2 summaries + const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan 1\n'); + fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), '# Plan 2\n'); + fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), '# Summary 1\n'); + fs.writeFileSync(path.join(phaseDir, '03-02-SUMMARY.md'), '# Summary 2\n'); + + // Also need ROADMAP.md for phase complete + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Phase 3: API\n\n- [ ] Phase 3: API Layer\n` + ); + + const result = runGsdTools('phase complete 3', tmpDir); + assert.ok(result.success, `phase complete failed: ${result.error}`); + + const stateAfter = fs.readFileSync(statePath, 'utf-8'); + assert.ok(stateAfter.includes('Total plans completed:'), 'Velocity section should have total plans'); + assert.ok(stateAfter.match(/Total plans completed:\s*2/), 'Total plans should be 2'); + assert.ok(stateAfter.includes('| 3'), 'By Phase table should have row for phase 3'); + }); + + test('existing Plan Execution Times rows aggregated into Velocity/By Phase', () => { + const content = `# Project State + +**Current Phase:** 04 +**Status:** Executing Phase 4 + +## Performance Metrics + +| Plan | Duration | Tasks | Files | +|------|----------|-------|-------| +| Phase 3 P1 | 12 min | 5 tasks | 3 files | +| Phase 3 P2 | 8 min | 3 tasks | 2 files | + +**Velocity:** +- Total plans completed: 2 +- Average duration: 10 min +- Total execution time: 0.3 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 3 | 2 | 20 min | 10 min | + +## Accumulated Context +`; + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, content); + + // Create phase 4 with 1 plan, 1 summary + const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '04-01-PLAN.md'), '# Plan 1\n'); + fs.writeFileSync(path.join(phaseDir, '04-01-SUMMARY.md'), '# Summary 1\n'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Phase 4: UI\n\n- [ ] Phase 4: UI Layer\n` + ); + + const result = runGsdTools('phase complete 4', tmpDir); + assert.ok(result.success, `phase complete failed: ${result.error}`); + + const stateAfter = fs.readFileSync(statePath, 'utf-8'); + assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans should be 3 (2 previous + 1 new)'); + assert.ok(stateAfter.includes('| 4'), 'By Phase table should have row for phase 4'); + }); + + test('idempotent — running twice produces same result', () => { + const content = `# Project State + +**Current Phase:** 05 +**Status:** Executing Phase 5 + +## Performance Metrics + +**Velocity:** +- Total plans completed: 0 +- Average duration: N/A +- Total execution time: 0 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| + +## Accumulated Context +`; + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, content); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-final'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '05-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '05-01-SUMMARY.md'), '# Summary\n'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Phase 5: Final\n\n- [ ] Phase 5: Final\n` + ); + + runGsdTools('phase complete 5', tmpDir); + const afterFirst = fs.readFileSync(statePath, 'utf-8'); + + // Reset state so we can complete again + let resetContent = afterFirst.replace(/Milestone complete|Ready to plan/, 'Executing Phase 5'); + resetContent = resetContent.replace(/Not started/, '1'); + fs.writeFileSync(statePath, resetContent); + + // Re-create plan files (they still exist) + runGsdTools('phase complete 5', tmpDir); + const afterSecond = fs.readFileSync(statePath, 'utf-8'); + + // Both should have same total plans count (idempotent update for same phase) + const firstCount = afterFirst.match(/Total plans completed:\s*(\d+)/); + const secondCount = afterSecond.match(/Total plans completed:\s*(\d+)/); + assert.ok(firstCount, 'First run should have total plans'); + assert.ok(secondCount, 'Second run should have total plans'); + // Second run adds another completion for phase 5, so count increments + // The key is the By Phase row for phase 5 should be updated, not duplicated + const phase5Rows = (afterSecond.match(/\|\s*5\s*\|/g) || []).length; + assert.ok(phase5Rows <= 1, 'Phase 5 should appear at most once in By Phase table (no duplicates)'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// state planned-phase (Step 3 — Gate 3a) +// ───────────────────────────────────────────────────────────────────────────── + +describe('state planned-phase command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('after call: Status is "Ready to execute"', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Planning Phase 3\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 3\n` + ); + + const result = runGsdTools(['state', 'planned-phase', '--phase', '3', '--name', 'API', '--plans', '5'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(stateContent.includes('Ready to execute'), 'Status should be "Ready to execute"'); + }); + + test('after call: Total Plans matches argument', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Planning\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 2\n` + ); + + const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '7'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(stateContent.match(/Total Plans in Phase.*7/), 'Total Plans should be 7'); + }); + + test('after call: Last Activity is today\'s date', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Planning\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 1\n` + ); + + const result = runGsdTools(['state', 'planned-phase', '--phase', '1', '--name', 'Setup', '--plans', '3'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const today = new Date().toISOString().split('T')[0]; + const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(stateContent.includes(today), `Last Activity should contain today's date (${today})`); + }); + + test('missing STATE.md returns graceful error', () => { + // No STATE.md written + const result = runGsdTools(['state', 'planned-phase', '--phase', '1', '--name', 'Test', '--plans', '3'], tmpDir); + assert.ok(result.success, 'Should not crash'); + const output = JSON.parse(result.output); + assert.ok(output.error, 'Should return error field'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// state validate (Step 4 — Gate 1) +// ───────────────────────────────────────────────────────────────────────────── + +describe('state validate command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('STATE says executing + VERIFICATION.md shows passed emits warning', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Executing Phase 2\n**Current Phase:** 2\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); + + const result = runGsdTools('state validate', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(output.warnings.length > 0, 'Should have warnings when executing but verification passed'); + assert.ok(output.warnings.some(w => /verif/i.test(w)), 'Warning should mention verification'); + }); + + test('STATE plan count 3 but 12 SUMMARY.md on disk emits mismatch warning', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 3\n**Current Plan:** 1\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + // Write 12 plans and summaries + for (let i = 1; i <= 12; i++) { + const padded = String(i).padStart(2, '0'); + fs.writeFileSync(path.join(phaseDir, `01-${padded}-PLAN.md`), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, `01-${padded}-SUMMARY.md`), '# Summary\n'); + } + + const result = runGsdTools('state validate', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(output.warnings.length > 0, 'Should have warnings for plan count mismatch'); + assert.ok(output.warnings.some(w => /plan.*count|count.*mismatch/i.test(w)), 'Warning should mention plan count mismatch'); + }); + + test('perfect state returns valid: true, no warnings', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n'); + + const result = runGsdTools('state validate', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.valid, true, 'Should be valid'); + assert.strictEqual(output.warnings.length, 0, 'Should have no warnings'); + }); + + test('missing STATE.md returns graceful error', () => { + const result = runGsdTools('state validate', tmpDir); + assert.ok(result.success, 'Should not crash'); + const output = JSON.parse(result.output); + assert.ok(output.error, 'Should return error field'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// state sync (Step 5 — Gate 2) +// ───────────────────────────────────────────────────────────────────────────── + +describe('state sync command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('drifted STATE.md + correct filesystem: after sync, fields match disk', () => { + // STATE says phase 1 with 0 plans, but disk has phase 2 with 3 plans + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Planning\n**Current Phase:** 1\n**Total Plans in Phase:** 0\n**Current Plan:** 0\n**Progress:** 0%\n` + ); + + const phase1Dir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phase1Dir, { recursive: true }); + fs.writeFileSync(path.join(phase1Dir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phase1Dir, '01-01-SUMMARY.md'), '# Summary\n'); + + const phase2Dir = path.join(tmpDir, '.planning', 'phases', '02-core'); + fs.mkdirSync(phase2Dir, { recursive: true }); + fs.writeFileSync(path.join(phase2Dir, '02-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phase2Dir, '02-02-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phase2Dir, '02-03-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phase2Dir, '02-01-SUMMARY.md'), '# Summary\n'); + + const result = runGsdTools('state sync', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(output.synced, 'Should report synced'); + + const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // Total plans in current phase (phase 2 since it's highest with incomplete plans) should be 3 + assert.ok(stateAfter.match(/Total Plans in Phase.*3/), 'Total Plans should match disk (3)'); + }); + + test('run sync twice is idempotent', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n**Progress:** 0%\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); + + runGsdTools('state sync', tmpDir); + const afterFirst = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + + runGsdTools('state sync', tmpDir); + const afterSecond = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + + // Strip frontmatter timestamps which will differ + const stripTimestamps = (s) => s.replace(/last_updated:.*\n/g, '').replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, 'TS'); + assert.strictEqual(stripTimestamps(afterFirst), stripTimestamps(afterSecond), 'Two syncs should produce same result'); + }); + + test('--verify flag reports changes without writing', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# Project State\n\n**Status:** Planning\n**Current Phase:** 1\n**Total Plans in Phase:** 0\n**Current Plan:** 0\n**Progress:** 0%\n` + ); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n'); + + const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + + const result = runGsdTools('state sync --verify', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(output.changes && output.changes.length > 0, 'Should report changes'); + assert.strictEqual(output.dry_run, true, 'Should indicate dry run'); + + const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.strictEqual(before, after, 'File should not be modified in verify mode'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // summary-extract command // ─────────────────────────────────────────────────────────────────────────────