feat(state): add --dry-run mode and resolved blocker pruning (#1970)
Review feedback from @trek-e — address scope gaps: 1. **--dry-run mode** — New flag that computes what would be pruned without modifying STATE.md. Returns structured output showing per-section counts so users can verify before committing. 2. **Resolved blocker pruning** — In addition to decisions and recently-completed entries, now prunes entries in the Blockers section that are marked resolved (~~strikethrough~~ or [RESOLVED] prefix) AND reference a phase older than the cutoff. Unresolved blockers are preserved regardless of age. 3. **Tests** — Added tests/state-prune.test.cjs (4 cases): - Prunes decisions older than cutoff, keeps recent - --dry-run reports changes without modifying STATE.md - Prunes resolved blockers, keeps unresolved regardless of age - Returns pruned:false when nothing exceeds cutoff Scope items still deferred (to be filed as follow-up): - Performance Metrics "By Phase" table row pruning — needs different regex handling than prose lines - Auto-prune via workflow.auto_prune_state at phase completion — needs integration into cmdPhaseComplete Also: the pre-existing test failure (2918/2919) is tests/stale-colon-refs.test.cjs:83:3 "No stale /gsd: colon references (#1748)". Verified failing on main, not introduced by this PR.
This commit is contained in:
@@ -471,8 +471,8 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
const { verify } = parseNamedArgs(args, [], ['verify']);
|
||||
state.cmdStateSync(cwd, { verify }, raw);
|
||||
} else if (subcommand === 'prune') {
|
||||
const { 'keep-recent': keepRecent } = parseNamedArgs(args, ['keep-recent']);
|
||||
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3' }, raw);
|
||||
const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
|
||||
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw);
|
||||
} else {
|
||||
state.cmdStateLoad(cwd, raw);
|
||||
}
|
||||
|
||||
@@ -1388,14 +1388,19 @@ function cmdStateSync(cwd, options, raw) {
|
||||
|
||||
/**
|
||||
* Prune old entries from STATE.md sections that grow unboundedly (#1970).
|
||||
* Moves decisions, performance metrics rows, and recently-completed summaries
|
||||
* Moves decisions, recently-completed summaries, and resolved blockers
|
||||
* older than keepRecent phases to STATE-ARCHIVE.md.
|
||||
*
|
||||
* Options:
|
||||
* keepRecent: number of recent phases to retain (default: 3)
|
||||
* dryRun: if true, return what would be pruned without modifying STATE.md
|
||||
*/
|
||||
function cmdStatePrune(cwd, options, raw) {
|
||||
const statePath = planningPaths(cwd).state;
|
||||
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
||||
|
||||
const keepRecent = parseInt(options.keepRecent, 10) || 3;
|
||||
const dryRun = !!options.dryRun;
|
||||
const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase');
|
||||
const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
|
||||
const cutoff = currentPhase - keepRecent;
|
||||
@@ -1408,7 +1413,11 @@ function cmdStatePrune(cwd, options, raw) {
|
||||
const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md');
|
||||
const archived = [];
|
||||
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
// Shared pruning logic applied to both dry-run and real passes.
|
||||
// Returns { newContent, archivedSections }.
|
||||
function prunePass(content) {
|
||||
const sections = [];
|
||||
|
||||
// Prune Decisions section: entries like "- [Phase N]: ..."
|
||||
const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
||||
const decMatch = content.match(decisionPattern);
|
||||
@@ -1425,7 +1434,7 @@ function cmdStatePrune(cwd, options, raw) {
|
||||
}
|
||||
}
|
||||
if (archive.length > 0) {
|
||||
archived.push({ section: 'Decisions', count: archive.length, lines: archive });
|
||||
sections.push({ section: 'Decisions', count: archive.length, lines: archive });
|
||||
content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`);
|
||||
}
|
||||
}
|
||||
@@ -1446,12 +1455,58 @@ function cmdStatePrune(cwd, options, raw) {
|
||||
}
|
||||
}
|
||||
if (archive.length > 0) {
|
||||
archived.push({ section: 'Recently Completed', count: archive.length, lines: archive });
|
||||
sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
|
||||
content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
// Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
|
||||
// or "[RESOLVED]" prefix) with a phase reference older than cutoff
|
||||
const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
||||
const blockersMatch = content.match(blockersPattern);
|
||||
if (blockersMatch) {
|
||||
const lines = blockersMatch[2].split('\n');
|
||||
const keep = [];
|
||||
const archive = [];
|
||||
for (const line of lines) {
|
||||
const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
|
||||
const phaseMatch = line.match(/Phase\s+(\d+)/i);
|
||||
if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
|
||||
archive.push(line);
|
||||
} else {
|
||||
keep.push(line);
|
||||
}
|
||||
}
|
||||
if (archive.length > 0) {
|
||||
sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
|
||||
content = content.replace(blockersPattern, (_m, header) => `${header}${keep.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { newContent: content, archivedSections: sections };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
// Dry-run: compute what would be pruned without writing anything
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
const result = prunePass(content);
|
||||
const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
|
||||
output({
|
||||
pruned: false,
|
||||
dry_run: true,
|
||||
cutoff_phase: cutoff,
|
||||
keep_recent: keepRecent,
|
||||
sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
|
||||
total_would_archive: totalPruned,
|
||||
note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
|
||||
}, raw, totalPruned > 0 ? 'true' : 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
const result = prunePass(content);
|
||||
archived.push(...result.archivedSections);
|
||||
return result.newContent;
|
||||
}, cwd);
|
||||
|
||||
// Write archived entries to STATE-ARCHIVE.md
|
||||
|
||||
152
tests/state-prune.test.cjs
Normal file
152
tests/state-prune.test.cjs
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tests for `state prune` command (#1970).
|
||||
*/
|
||||
|
||||
'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 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 archiveExists(tmpDir) {
|
||||
return fs.existsSync(path.join(tmpDir, '.planning', 'STATE-ARCHIVE.md'));
|
||||
}
|
||||
|
||||
function readArchive(tmpDir) {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE-ARCHIVE.md'), 'utf-8');
|
||||
}
|
||||
|
||||
describe('state prune (#1970)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('prunes decisions older than cutoff', () => {
|
||||
writeStateMd(tmpDir, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 10',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old decision',
|
||||
'- [Phase 3]: Old decision 3',
|
||||
'- [Phase 8]: Recent decision',
|
||||
'- [Phase 10]: Current decision',
|
||||
'',
|
||||
].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);
|
||||
assert.strictEqual(output.cutoff_phase, 7);
|
||||
|
||||
const newState = readStateMd(tmpDir);
|
||||
assert.match(newState, /\[Phase 8\]: Recent decision/);
|
||||
assert.match(newState, /\[Phase 10\]: Current decision/);
|
||||
assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/);
|
||||
assert.doesNotMatch(newState, /\[Phase 3\]: Old decision 3/);
|
||||
|
||||
assert.ok(archiveExists(tmpDir), 'STATE-ARCHIVE.md should exist');
|
||||
const archive = readArchive(tmpDir);
|
||||
assert.match(archive, /\[Phase 1\]: Old decision/);
|
||||
assert.match(archive, /\[Phase 3\]: Old decision 3/);
|
||||
});
|
||||
|
||||
test('--dry-run reports what would be pruned without modifying STATE.md', () => {
|
||||
const originalContent = [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 10',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old decision',
|
||||
'- [Phase 2]: Another old decision',
|
||||
'- [Phase 9]: Recent decision',
|
||||
'',
|
||||
].join('\n');
|
||||
writeStateMd(tmpDir, originalContent);
|
||||
|
||||
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.pruned, false);
|
||||
assert.strictEqual(output.dry_run, true);
|
||||
assert.strictEqual(output.total_would_archive, 2);
|
||||
|
||||
// STATE.md should be unchanged
|
||||
const unchanged = readStateMd(tmpDir);
|
||||
assert.strictEqual(unchanged, originalContent);
|
||||
|
||||
// No archive file should be created
|
||||
assert.ok(!archiveExists(tmpDir), 'dry-run should not create archive');
|
||||
});
|
||||
|
||||
test('prunes resolved blockers older than cutoff', () => {
|
||||
writeStateMd(tmpDir, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 10',
|
||||
'',
|
||||
'## Blockers',
|
||||
'',
|
||||
'- ~~Phase 1: Old resolved issue~~',
|
||||
'- [RESOLVED] Phase 2: Another old issue',
|
||||
'- Phase 9: Current blocker (unresolved)',
|
||||
'',
|
||||
].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 blockerSection = output.sections.find(s => /Blockers/i.test(s.section));
|
||||
assert.ok(blockerSection, 'should report Blockers section');
|
||||
assert.strictEqual(blockerSection.entries_archived, 2);
|
||||
|
||||
const newState = readStateMd(tmpDir);
|
||||
assert.match(newState, /Phase 9: Current blocker/);
|
||||
assert.doesNotMatch(newState, /Phase 1: Old resolved issue/);
|
||||
});
|
||||
|
||||
test('returns pruned:false when nothing to prune', () => {
|
||||
writeStateMd(tmpDir, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 2',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Recent decision',
|
||||
'- [Phase 2]: Current decision',
|
||||
'',
|
||||
].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, false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user