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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user