- Extend cmdStatePrune to prune Performance Metrics table rows older than cutoff - Add workflow.auto_prune_state config key (default: false) - Call cmdStatePrune automatically in cmdPhaseComplete when enabled - Document workflow.auto_prune_state in planning-config.md reference - Add silent option to cmdStatePrune for programmatic use without stdout Closes #2087 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
173
tests/phase-complete-auto-prune.test.cjs
Normal file
173
tests/phase-complete-auto-prune.test.cjs
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 \|/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user