diff --git a/packages/squad-cli/src/cli/core/nap.ts b/packages/squad-cli/src/cli/core/nap.ts index 6466879a9..9685ddebd 100644 --- a/packages/squad-cli/src/cli/core/nap.ts +++ b/packages/squad-cli/src/cli/core/nap.ts @@ -31,7 +31,7 @@ export interface NapMetrics { } export interface NapAction { - type: 'compress' | 'prune' | 'archive' | 'merge' | 'cleanup'; + type: 'compress' | 'prune' | 'archive' | 'merge' | 'cleanup' | 'warning'; target: string; description: string; bytesSaved: number; @@ -97,6 +97,19 @@ function humanTokens(bytes: number): string { return `${(tokens / 1000).toFixed(0)}K`; } +/** Build a warning action when decisions.md is oversized but nothing was archivable. */ +function oversizedDecisionsWarning(squadDir: string): NapAction | null { + const decisionsPath = path.join(squadDir, 'decisions.md'); + const decisionsSize = fileSize(decisionsPath); + if (decisionsSize <= DECISION_THRESHOLD) return null; + return { + type: 'warning', + target: decisionsPath, + description: `⚠️ decisions.md is ${humanBytes(decisionsSize)} but no entries could be archived (all recent or undated). Consider manual cleanup or adding dates to undated entries.`, + bytesSaved: 0, + }; +} + function daysAgoFromLine(line: string): number | null { // Match dates like 2026-02-28 or ### 2026-02-28 const m = line.match(/(\d{4}-\d{2}-\d{2})/); @@ -472,7 +485,12 @@ export async function runNap(options: NapOptions): Promise { // Decision archival const archiveAction = archiveDecisions(squadDir, dryRun); - if (archiveAction) actions.push(archiveAction); + if (archiveAction) { + actions.push(archiveAction); + } else { + const warning = oversizedDecisionsWarning(squadDir); + if (warning) actions.push(warning); + } } finally { if (!dryRun) removeJournal(squadDir); @@ -524,7 +542,12 @@ export function runNapSync(options: NapOptions): NapResult { actions.push(...cleanInbox(squadDir, dryRun)); const archiveAction = archiveDecisions(squadDir, dryRun); - if (archiveAction) actions.push(archiveAction); + if (archiveAction) { + actions.push(archiveAction); + } else { + const warning = oversizedDecisionsWarning(squadDir); + if (warning) actions.push(warning); + } } finally { if (!dryRun) removeJournal(squadDir); } diff --git a/test/nap.test.ts b/test/nap.test.ts index 437b5a3f2..3422c6b3e 100644 --- a/test/nap.test.ts +++ b/test/nap.test.ts @@ -510,6 +510,52 @@ describe('Nap — Decision archival', () => { expect(archiveActions).toHaveLength(0); expect(existsSync(join(squadDir, 'decisions-archive.md'))).toBe(false); }); + it('warns when decisions.md is over threshold but nothing is archivable', async () => { + // decisions.md >20KB with ONLY header content — no ### entries at all. + // archiveDecisions() finds no entries and returns null, but the file is oversized. + const headerOnly = '# Decisions\n\n' + 'x'.repeat(21 * 1024); + expect(Buffer.byteLength(headerOnly)).toBeGreaterThan(20 * 1024); + + const squadDir = createTestSquadDir({ 'decisions.md': headerOnly }); + const result = await runNap({ squadDir, dryRun: true }); + + const warnActions = result.actions.filter( + (a) => a.type === 'warning' && a.target.includes('decisions'), + ); + expect(warnActions).toHaveLength(1); + expect(warnActions[0]!.bytesSaved).toBe(0); + expect(warnActions[0]!.description).toContain('no entries could be archived'); + }); + + it('no warning when decisions.md is under threshold', async () => { + const smallContent = '# Decisions\n\n' + 'x'.repeat(5 * 1024); + expect(Buffer.byteLength(smallContent)).toBeLessThan(20 * 1024); + + const squadDir = createTestSquadDir({ 'decisions.md': smallContent }); + const result = await runNap({ squadDir }); + + const warnActions = result.actions.filter((a) => a.type === 'warning'); + expect(warnActions).toHaveLength(0); + }); + + it('no warning when archival succeeds', async () => { + // decisions.md >20KB with old entries that DO get archived + let bigOldDecisions = '# Decisions\n\n'; + for (let i = 0; i < 30; i++) { + bigOldDecisions += `### 2024-01-${String(i + 1).padStart(2, '0')}: Decision ${i + 1}\n`; + bigOldDecisions += 'w'.repeat(800) + '\n\n'; + } + expect(Buffer.byteLength(bigOldDecisions)).toBeGreaterThan(20 * 1024); + + const squadDir = createTestSquadDir({ 'decisions.md': bigOldDecisions }); + const result = await runNap({ squadDir }); + + const archiveActions = result.actions.filter((a) => a.type === 'archive'); + expect(archiveActions.length).toBeGreaterThan(0); + + const warnActions = result.actions.filter((a) => a.type === 'warning'); + expect(warnActions).toHaveLength(0); + }); }); // ============================================================================