From 00607c715da93182d02aea73a3d7cdbd867b32a9 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 06:51:02 -0700 Subject: [PATCH] fix(nap): add silent-failure detection when archival returns null on oversized file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When decisions.md exceeds 20KB but archiveDecisions() returns null (no entries archivable — all recent or undated), emit a warning action in the nap report instead of silently reporting 'nothing to clean up'. Changes from review: - Add 'warning' type to NapAction (was using 'cleanup' with cast) - Extract oversizedDecisionsWarning() helper (DRY — was duplicated in runNap/runNapSync) - Tests assert on type='warning' instead of just description text Closes #25 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/core/nap.ts | 29 ++++++++++++++-- test/nap.test.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) 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); + }); }); // ============================================================================