From f1b3702be836607f345d067a9a17a3226719cca4 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Fri, 10 Apr 2026 12:00:09 -0700 Subject: [PATCH] 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 --- get-shit-done/bin/gsd-tools.cjs | 3 ++ get-shit-done/bin/lib/state.cjs | 96 +++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 540905ff0..22543761d 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -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); } diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 06da9d13c..34d3f3c54 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -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, };