diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 339702cc..646ed137 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -22,6 +22,7 @@ const VALID_CONFIG_KEYS = new Set([ 'workflow.research_before_questions', 'workflow.discuss_mode', 'workflow.skip_discuss', + 'workflow.auto_prune_state', 'workflow._auto_chain_active', 'workflow.use_worktrees', 'workflow.code_review', @@ -170,6 +171,7 @@ function buildNewProjectConfig(userChoices) { plan_bounce: false, plan_bounce_script: null, plan_bounce_passes: 2, + auto_prune_state: false, }, hooks: { context_warnings: true, diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index bad0e9cb..3e8450bf 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -937,6 +937,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { }, cwd); } + // Auto-prune STATE.md on phase boundary when configured (#2087) + let autoPruned = false; + try { + const configPath = path.join(planningDir(cwd), 'config.json'); + if (fs.existsSync(configPath)) { + const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const autoPruneEnabled = rawConfig.workflow && rawConfig.workflow.auto_prune_state === true; + if (autoPruneEnabled && fs.existsSync(statePath)) { + const { cmdStatePrune } = require('./state.cjs'); + cmdStatePrune(cwd, { keepRecent: '3', dryRun: false, silent: true }, true); + autoPruned = true; + } + } + } catch { /* intentionally empty — auto-prune is best-effort */ } + const result = { completed_phase: phaseNum, phase_name: phaseInfo.phase_name, @@ -948,6 +963,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { roadmap_updated: fs.existsSync(roadmapPath), state_updated: fs.existsSync(statePath), requirements_updated: requirementsUpdated, + auto_pruned: autoPruned, warnings, has_warnings: warnings.length > 0, }; diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index a8a56113..d0d08dc7 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -1417,8 +1417,10 @@ function cmdStateSync(cwd, options, raw) { * dryRun: if true, return what would be pruned without modifying STATE.md */ function cmdStatePrune(cwd, options, raw) { + const silent = !!options.silent; + const emit = silent ? () => {} : (result, r, v) => output(result, r, v); const statePath = planningPaths(cwd).state; - if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } + if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; } const keepRecent = parseInt(options.keepRecent, 10) || 3; const dryRun = !!options.dryRun; @@ -1427,7 +1429,7 @@ function cmdStatePrune(cwd, options, raw) { const cutoff = currentPhase - keepRecent; if (cutoff <= 0) { - output({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false'); + emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false'); return; } @@ -1504,6 +1506,35 @@ function cmdStatePrune(cwd, options, raw) { } } + // Prune Performance Metrics table rows: keep only rows for phases > cutoff. + // Preserves header rows (| Phase | ... and |---|...) and any prose around the table. + const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const metricsMatch = content.match(metricsPattern); + if (metricsMatch) { + const sectionLines = metricsMatch[2].split('\n'); + const keep = []; + const archive = []; + for (const line of sectionLines) { + // Table data row: starts with | followed by a number (phase) + const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/); + if (tableRowMatch) { + const rowPhase = parseInt(tableRowMatch[1], 10); + if (rowPhase <= cutoff) { + archive.push(line); + } else { + keep.push(line); + } + } else { + // Header row, separator row, or prose — always keep + keep.push(line); + } + } + if (archive.length > 0) { + sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive }); + content = content.replace(metricsPattern, (_m, header) => `${header}${keep.join('\n')}`); + } + } + return { newContent: content, archivedSections: sections }; } @@ -1512,7 +1543,7 @@ function cmdStatePrune(cwd, options, raw) { const content = fs.readFileSync(statePath, 'utf-8'); const result = prunePass(content); const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0); - output({ + emit({ pruned: false, dry_run: true, cutoff_phase: cutoff, @@ -1547,7 +1578,7 @@ function cmdStatePrune(cwd, options, raw) { } const totalPruned = archived.reduce((sum, s) => sum + s.count, 0); - output({ + emit({ pruned: totalPruned > 0, cutoff_phase: cutoff, keep_recent: keepRecent, diff --git a/get-shit-done/references/planning-config.md b/get-shit-done/references/planning-config.md index 99f45bac..c063f043 100644 --- a/get-shit-done/references/planning-config.md +++ b/get-shit-done/references/planning-config.md @@ -248,6 +248,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research": | `workflow.plan_check` | boolean | `true` | `true`, `false` | Run plan-checker agent to validate plans. _Alias:_ `plan_checker` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.plan_check` is the canonical namespaced form. | | `workflow.verifier` | boolean | `true` | `true`, `false` | Run verifier agent after execution | | `workflow.nyquist_validation` | boolean | `true` | `true`, `false` | Enable Nyquist-inspired validation gates | +| `workflow.auto_prune_state` | boolean | `false` | `true`, `false` | Automatically prune old STATE.md entries on phase completion (keeps 3 most recent phases) | | `workflow.auto_advance` | boolean | `false` | `true`, `false` | Auto-advance to next phase after completion | | `workflow.node_repair` | boolean | `true` | `true`, `false` | Attempt automatic repair of failed plan nodes | | `workflow.node_repair_budget` | number | `2` | Any positive integer | Max repair retries per failed node | diff --git a/tests/phase-complete-auto-prune.test.cjs b/tests/phase-complete-auto-prune.test.cjs new file mode 100644 index 00000000..82576e3f --- /dev/null +++ b/tests/phase-complete-auto-prune.test.cjs @@ -0,0 +1,173 @@ +/** + * Integration tests for auto-prune on phase completion (#2087). + * + * When config `workflow.auto_prune_state` is true, `phase complete` + * should automatically prune STATE.md as part of the phase transition. + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +function writeConfig(tmpDir, config) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2)); +} + +function writeStateMd(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content); +} + +function readStateMd(tmpDir) { + return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); +} + +function writeRoadmap(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); +} + +function setupPhase(tmpDir, phaseNum, planCount) { + const phasesDir = path.join(tmpDir, '.planning', 'phases'); + const phaseDir = path.join(phasesDir, `${String(phaseNum).padStart(2, '0')}-test-phase`); + fs.mkdirSync(phaseDir, { recursive: true }); + + for (let i = 1; i <= planCount; i++) { + const planId = `${String(phaseNum).padStart(2, '0')}-${String(i).padStart(2, '0')}`; + fs.writeFileSync(path.join(phaseDir, `${planId}-PLAN.md`), `# Plan ${planId}\n`); + fs.writeFileSync(path.join(phaseDir, `${planId}-SUMMARY.md`), `# Summary ${planId}\n`); + } +} + +describe('phase complete auto-prune (#2087)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('prunes STATE.md automatically when auto_prune_state is true', () => { + writeConfig(tmpDir, { + workflow: { auto_prune_state: true }, + }); + + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 6', + '**Status:** Executing', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision from phase 1', + '- [Phase 2]: Old decision from phase 2', + '- [Phase 5]: Recent decision', + '- [Phase 6]: Current decision', + '', + ].join('\n')); + + writeRoadmap(tmpDir, [ + '# Roadmap', + '', + '## Phase 6: Test Phase', + '', + '**Plans:** 0/2', + '', + ].join('\n')); + + setupPhase(tmpDir, 6, 2); + + const result = runGsdTools('phase complete 6', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMd(tmpDir); + // With keep-recent=3 (default), cutoff = 6-3 = 3 + // Phase 1 and 2 decisions should be pruned + assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/); + assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/); + // Phase 5 and 6 should remain + assert.match(newState, /\[Phase 5\]: Recent decision/); + assert.match(newState, /\[Phase 6\]: Current decision/); + }); + + test('does NOT prune when auto_prune_state is false (default)', () => { + writeConfig(tmpDir, { + workflow: { auto_prune_state: false }, + }); + + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 6', + '**Status:** Executing', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision from phase 1', + '- [Phase 5]: Recent decision', + '- [Phase 6]: Current decision', + '', + ].join('\n')); + + writeRoadmap(tmpDir, [ + '# Roadmap', + '', + '## Phase 6: Test Phase', + '', + '**Plans:** 0/2', + '', + ].join('\n')); + + setupPhase(tmpDir, 6, 2); + + const result = runGsdTools('phase complete 6', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMd(tmpDir); + // Phase 1 decision should still be present (no pruning) + assert.match(newState, /\[Phase 1\]: Old decision/); + }); + + test('does NOT prune when auto_prune_state is absent from config', () => { + writeConfig(tmpDir, { + workflow: {}, + }); + + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 6', + '**Status:** Executing', + '', + '## Decisions', + '', + '- [Phase 1]: Old decision from phase 1', + '- [Phase 6]: Current decision', + '', + ].join('\n')); + + writeRoadmap(tmpDir, [ + '# Roadmap', + '', + '## Phase 6: Test Phase', + '', + '**Plans:** 0/2', + '', + ].join('\n')); + + setupPhase(tmpDir, 6, 2); + + const result = runGsdTools('phase complete 6', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMd(tmpDir); + // Should not prune — absent means disabled (default: false) + assert.match(newState, /\[Phase 1\]: Old decision/); + }); +}); diff --git a/tests/state-prune.test.cjs b/tests/state-prune.test.cjs index 1a9be43d..a8e80d87 100644 --- a/tests/state-prune.test.cjs +++ b/tests/state-prune.test.cjs @@ -149,4 +149,129 @@ describe('state prune (#1970)', () => { const output = JSON.parse(result.output); assert.strictEqual(output.pruned, false); }); + + describe('Performance Metrics table pruning (#2087)', () => { + test('prunes old metric table rows by phase number', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 10', + '', + '## Performance Metrics', + '', + '| Phase | Plans | Duration | Status |', + '|-------|-------|----------|--------|', + '| 1 | 3/3 | 2h | Complete |', + '| 2 | 2/2 | 1h | Complete |', + '| 3 | 4/4 | 3h | Complete |', + '| 8 | 5/5 | 4h | Complete |', + '| 9 | 2/2 | 1h | Complete |', + '| 10 | 1/3 | - | In Progress |', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.pruned, true); + + const newState = readStateMd(tmpDir); + // Should keep phases 8, 9, 10 (within keep-recent of phase 10, cutoff=7) + assert.match(newState, /\| 8 \|/); + assert.match(newState, /\| 9 \|/); + assert.match(newState, /\| 10 \|/); + // Should prune phases 1, 2, 3 + assert.doesNotMatch(newState, /\| 1 \|.*Complete/); + assert.doesNotMatch(newState, /\| 2 \|.*Complete/); + assert.doesNotMatch(newState, /\| 3 \|.*Complete/); + // Header row should be preserved + assert.match(newState, /\| Phase \| Plans \| Duration \| Status \|/); + assert.match(newState, /\|-------|-------|----------|--------\|/); + }); + + test('--dry-run reports metrics rows that would be pruned', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 8', + '', + '## Performance Metrics', + '', + '| Phase | Plans | Status |', + '|-------|-------|--------|', + '| 1 | 3/3 | Complete |', + '| 2 | 2/2 | Complete |', + '| 6 | 4/4 | Complete |', + '| 7 | 2/2 | Complete |', + '| 8 | 1/3 | In Progress |', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.dry_run, true); + assert.ok(output.total_would_archive > 0, 'should report rows to archive'); + const metricsSection = output.sections.find(s => /Metrics/i.test(s.section)); + assert.ok(metricsSection, 'should include Performance Metrics section'); + assert.strictEqual(metricsSection.entries_would_archive, 2); + }); + + test('does not touch prose lines outside the metrics table', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 10', + '', + '## Performance Metrics', + '', + 'Overall project velocity is improving.', + '', + '| Phase | Plans | Status |', + '|-------|-------|--------|', + '| 1 | 3/3 | Complete |', + '| 9 | 2/2 | Complete |', + '| 10 | 1/3 | In Progress |', + '', + 'Average duration: 2.5 hours per phase.', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMd(tmpDir); + assert.match(newState, /Overall project velocity is improving\./); + assert.match(newState, /Average duration: 2\.5 hours per phase\./); + assert.doesNotMatch(newState, /\| 1 \|/); + assert.match(newState, /\| 9 \|/); + }); + + test('preserves table when no rows are old enough to prune', () => { + writeStateMd(tmpDir, [ + '# Session State', + '', + '**Current Phase:** 5', + '', + '## Performance Metrics', + '', + '| Phase | Plans | Status |', + '|-------|-------|--------|', + '| 3 | 3/3 | Complete |', + '| 4 | 2/2 | Complete |', + '| 5 | 1/3 | In Progress |', + '', + ].join('\n')); + + const result = runGsdTools('state prune --keep-recent 3', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const newState = readStateMd(tmpDir); + assert.match(newState, /\| 3 \|/); + assert.match(newState, /\| 4 \|/); + assert.match(newState, /\| 5 \|/); + }); + }); });