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:
Tibsfox
2026-04-11 03:43:46 -07:00
parent f1b3702be8
commit 01f0b4b540
3 changed files with 214 additions and 7 deletions

View File

@@ -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);
}

View File

@@ -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
View 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);
});
});