Merge pull request #2063 from Tibsfox/feat/state-prune-1970

feat(state): add state prune command for unbounded section growth
This commit is contained in:
Tom Boucher
2026-04-11 13:56:19 -04:00
committed by GitHub
3 changed files with 306 additions and 0 deletions

View File

@@ -470,6 +470,9 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
} else if (subcommand === 'sync') {
const { verify } = parseNamedArgs(args, [], ['verify']);
state.cmdStateSync(cwd, { verify }, raw);
} else if (subcommand === 'prune') {
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

@@ -1407,6 +1407,156 @@ function cmdStateSync(cwd, options, raw) {
output({ synced: true, changes, dry_run: false }, raw);
}
/**
* Prune old entries from STATE.md sections that grow unboundedly (#1970).
* 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;
if (cutoff <= 0) {
output({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
return;
}
const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md');
const archived = [];
// 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);
if (decMatch) {
const lines = decMatch[2].split('\n');
const keep = [];
const archive = [];
for (const line of lines) {
const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
sections.push({ section: 'Decisions', count: archive.length, lines: archive });
content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`);
}
}
// Prune Recently Completed section: entries mentioning phase numbers
const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const recMatch = content.match(recentPattern);
if (recMatch) {
const lines = recMatch[2].split('\n');
const keep = [];
const archive = [];
for (const line of lines) {
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
}
if (archive.length > 0) {
sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
}
}
// 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
if (archived.length > 0) {
const timestamp = new Date().toISOString().split('T')[0];
let archiveContent = '';
if (fs.existsSync(archivePath)) {
archiveContent = fs.readFileSync(archivePath, 'utf-8');
} else {
archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
}
archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
for (const section of archived) {
archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
}
atomicWriteFileSync(archivePath, archiveContent);
}
const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
output({
pruned: totalPruned > 0,
cutoff_phase: cutoff,
keep_recent: keepRecent,
sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
total_archived: totalPruned,
archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
}, raw, totalPruned > 0 ? 'true' : 'false');
}
module.exports = {
stateExtractField,
stateReplaceField,
@@ -1431,6 +1581,7 @@ module.exports = {
cmdStatePlannedPhase,
cmdStateValidate,
cmdStateSync,
cmdStatePrune,
cmdSignalWaiting,
cmdSignalResume,
};

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