feat(state): add state prune command for unbounded section growth (#1970)

Add `gsd-tools state prune --keep-recent N` that moves old decisions
and recently-completed entries to STATE-ARCHIVE.md. Entries from phases
older than (current - N) are archived; the N most recent are kept.

STATE.md sections grow unboundedly in long-lived projects. A 20+ phase
project accumulates hundreds of historical decisions that every agent
loads into context. Pruning removes stale entries from the hot path
while preserving them in a recoverable archive.

Usage: gsd-tools state prune --keep-recent 3
Default: keeps 3 most recent phases

Closes #1970

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tibsfox
2026-04-10 12:00:09 -07:00
parent 6c2795598a
commit f1b3702be8
2 changed files with 99 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 } = parseNamedArgs(args, ['keep-recent']);
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3' }, raw);
} else {
state.cmdStateLoad(cwd, raw);
}

View File

@@ -1386,6 +1386,101 @@ 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, performance metrics rows, and recently-completed summaries
* older than keepRecent phases to STATE-ARCHIVE.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 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 = [];
readModifyWriteStateMd(statePath, (content) => {
// 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) {
archived.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) {
archived.push({ section: 'Recently Completed', count: archive.length, lines: archive });
content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
}
}
return content;
}, 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,
@@ -1410,6 +1505,7 @@ module.exports = {
cmdStatePlannedPhase,
cmdStateValidate,
cmdStateSync,
cmdStatePrune,
cmdSignalWaiting,
cmdSignalResume,
};