From ba7f5eff50224418826f4f83d572803852308a1b Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 3 Apr 2026 21:45:53 +0300 Subject: [PATCH 1/2] feat(cli): add cleanup watch capability for stale file housekeeping (#791) - New cleanup capability in housekeeping phase - Clears .squad/.scratch/ temp files - Archives old orchestration-log and session-log entries (>30d) - Warns about stale decision inbox files (>7d) - Configurable: everyNRounds, maxAgeDays - 12 new tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/cleanup-ceremony.md | 12 ++ .../commands/watch/capabilities/cleanup.ts | 148 ++++++++++++++++++ .../cli/commands/watch/capabilities/index.ts | 2 + test/watch-cleanup.test.ts | 141 +++++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 .changeset/cleanup-ceremony.md create mode 100644 packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts create mode 100644 test/watch-cleanup.test.ts diff --git a/.changeset/cleanup-ceremony.md b/.changeset/cleanup-ceremony.md new file mode 100644 index 000000000..0cf785ab7 --- /dev/null +++ b/.changeset/cleanup-ceremony.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-cli": minor +--- + +Add cleanup watch capability for stale file housekeeping (#791) + +- New `cleanup` capability in the `housekeeping` phase +- Clears `.squad/.scratch/` (all ephemeral temp files) +- Archives orchestration-log and session-log entries older than 30 days +- Warns about stale decision inbox files (>7 days) +- Configurable: `everyNRounds` (default: 10), `maxAgeDays` (default: 30) +- 11 new tests diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts new file mode 100644 index 000000000..3de9a3a80 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts @@ -0,0 +1,148 @@ +/** + * Cleanup capability — housekeeping for stale temp and log files. + * + * Runs in the 'housekeeping' phase. Clears the scratch directory, archives + * old orchestration-log and session-log entries, and warns about stale + * decision inbox files. + */ + +import path from 'node:path'; +import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; + +const storage = new FSStorageProvider(); + +/** Default: files older than this many days are archived / deleted. */ +const DEFAULT_MAX_AGE_DAYS = 30; +/** Default: run cleanup every N rounds (not every round). */ +const DEFAULT_EVERY_N_ROUNDS = 10; +/** Stale inbox warning threshold (days). */ +const STALE_INBOX_DAYS = 7; + +interface CleanupConfig { + /** Run cleanup every N rounds (default: 10). */ + everyNRounds?: number; + /** Max age in days for log/orchestration files before archival (default: 30). */ + maxAgeDays?: number; +} + +function parseConfig(raw: Record): CleanupConfig { + return { + everyNRounds: typeof raw.everyNRounds === 'number' ? raw.everyNRounds : DEFAULT_EVERY_N_ROUNDS, + maxAgeDays: typeof raw.maxAgeDays === 'number' ? raw.maxAgeDays : DEFAULT_MAX_AGE_DAYS, + }; +} + +function isOlderThan(filename: string, days: number): boolean { + // Filenames may start with ISO timestamp: 2026-04-01T12-00-00Z-agent.md + const match = filename.match(/^(\d{4}-\d{2}-\d{2})/); + if (!match) return false; + const fileDate = new Date(match[1]!); + if (isNaN(fileDate.getTime())) return false; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + return fileDate < cutoff; +} + +function safeList(dir: string): string[] { + try { + if (!storage.existsSync(dir)) return []; + return storage.listSync?.(dir) ?? []; + } catch { + return []; + } +} + +function safeDelete(filePath: string): boolean { + try { + storage.deleteSync?.(filePath); + return true; + } catch { + return false; + } +} + +export class CleanupCapability implements WatchCapability { + readonly name = 'cleanup'; + readonly description = 'Remove stale scratch files, archive old logs, warn about stale inbox'; + readonly configShape = 'object' as const; + readonly requires: string[] = []; + readonly phase = 'housekeeping' as const; + + async preflight(context: WatchContext): Promise { + const squadDir = path.join(context.teamRoot, '.squad'); + if (!storage.existsSync(squadDir)) { + return { ok: false, reason: '.squad/ directory not found' }; + } + return { ok: true }; + } + + async execute(context: WatchContext): Promise { + const config = parseConfig(context.config); + const everyN = config.everyNRounds ?? DEFAULT_EVERY_N_ROUNDS; + + // Only run on every Nth round (or round 1 for immediate feedback) + if (context.round > 1 && context.round % everyN !== 0) { + return { success: true, summary: `cleanup: skipped (runs every ${everyN} rounds)` }; + } + + const squadDir = path.join(context.teamRoot, '.squad'); + const maxAge = config.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS; + const actions: string[] = []; + + // 1. Clear .squad/.scratch/ — everything in here is ephemeral + const scratchDir = path.join(squadDir, '.scratch'); + const scratchFiles = safeList(scratchDir); + let scratchDeleted = 0; + for (const f of scratchFiles) { + if (safeDelete(path.join(scratchDir, f))) scratchDeleted++; + } + if (scratchDeleted > 0) { + actions.push(`scratch: ${scratchDeleted} files cleared`); + } + + // 2. Archive old orchestration-log entries (older than maxAge days) + const orchDir = path.join(squadDir, 'orchestration-log'); + const orchFiles = safeList(orchDir); + let orchArchived = 0; + for (const f of orchFiles) { + if (isOlderThan(f, maxAge)) { + if (safeDelete(path.join(orchDir, f))) orchArchived++; + } + } + if (orchArchived > 0) { + actions.push(`orchestration-log: ${orchArchived} entries archived (>${maxAge}d)`); + } + + // 3. Archive old session logs + const logDir = path.join(squadDir, 'log'); + const logFiles = safeList(logDir); + let logArchived = 0; + for (const f of logFiles) { + if (isOlderThan(f, maxAge)) { + if (safeDelete(path.join(logDir, f))) logArchived++; + } + } + if (logArchived > 0) { + actions.push(`log: ${logArchived} entries archived (>${maxAge}d)`); + } + + // 4. Warn about stale decision inbox files + const inboxDir = path.join(squadDir, 'decisions', 'inbox'); + const inboxFiles = safeList(inboxDir).filter(f => f.endsWith('.md')); + const staleInbox = inboxFiles.filter(f => isOlderThan(f, STALE_INBOX_DAYS)); + if (staleInbox.length > 0) { + actions.push(`⚠ ${staleInbox.length} stale inbox files (>${STALE_INBOX_DAYS}d)`); + } + + if (actions.length === 0) { + return { success: true, summary: 'cleanup: nothing to do' }; + } + + return { + success: true, + summary: `cleanup: ${actions.join('; ')}`, + data: { scratchDeleted, orchArchived, logArchived, staleInboxCount: staleInbox.length }, + }; + } +} diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts index 5b8524f12..4a99136ef 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/index.ts @@ -12,6 +12,7 @@ import { TwoPassCapability } from './two-pass.js'; import { WaveDispatchCapability } from './wave-dispatch.js'; import { RetroCapability } from './retro.js'; import { DecisionHygieneCapability } from './decision-hygiene.js'; +import { CleanupCapability } from './cleanup.js'; /** Create a registry pre-loaded with all built-in capabilities. */ export function createDefaultRegistry(): CapabilityRegistry { @@ -25,5 +26,6 @@ export function createDefaultRegistry(): CapabilityRegistry { registry.register(new WaveDispatchCapability()); registry.register(new RetroCapability()); registry.register(new DecisionHygieneCapability()); + registry.register(new CleanupCapability()); return registry; } diff --git a/test/watch-cleanup.test.ts b/test/watch-cleanup.test.ts new file mode 100644 index 000000000..a25640f7c --- /dev/null +++ b/test/watch-cleanup.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const TEST_ROOT = path.join(os.tmpdir(), `squad-cleanup-test-${Date.now()}`); +const SQUAD_DIR = path.join(TEST_ROOT, '.squad'); + +// We test the CleanupCapability by importing it directly +// (vitest resolves workspace paths, but we import from relative source) +import { CleanupCapability } from '../packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.js'; +import type { WatchContext } from '../packages/squad-cli/src/cli/commands/watch/types.js'; + +function makeContext(overrides: Partial = {}): WatchContext { + return { + teamRoot: TEST_ROOT, + adapter: {} as WatchContext['adapter'], + round: 1, + roster: [], + config: {}, + ...overrides, + }; +} + +function writeFile(relativePath: string, content: string = ''): void { + const full = path.join(TEST_ROOT, relativePath); + mkdirSync(path.dirname(full), { recursive: true }); + writeFileSync(full, content); +} + +beforeEach(() => { + mkdirSync(SQUAD_DIR, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +describe('CleanupCapability', () => { + const cap = new CleanupCapability(); + + it('has correct metadata', () => { + expect(cap.name).toBe('cleanup'); + expect(cap.phase).toBe('housekeeping'); + expect(cap.configShape).toBe('object'); + }); + + it('preflight succeeds when .squad/ exists', async () => { + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(true); + }); + + it('preflight fails when .squad/ is missing', async () => { + rmSync(SQUAD_DIR, { recursive: true, force: true }); + const result = await cap.preflight(makeContext()); + expect(result.ok).toBe(false); + }); + + it('clears all files in .squad/.scratch/', async () => { + writeFile('.squad/.scratch/prompt-123.txt', 'hello'); + writeFile('.squad/.scratch/msg-456.tmp', 'world'); + + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('scratch: 2 files cleared'); + expect(readdirSync(path.join(SQUAD_DIR, '.scratch'))).toHaveLength(0); + }); + + it('archives old orchestration-log entries', async () => { + // Old file (date in filename older than 30 days) + writeFile('.squad/orchestration-log/2025-01-01T00-00-00Z-agent.md', 'old'); + // Recent file (today) + const today = new Date().toISOString().slice(0, 10); + writeFile(`.squad/orchestration-log/${today}T12-00-00Z-agent.md`, 'recent'); + + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('orchestration-log: 1 entries archived'); + // Recent file should still exist + const remaining = readdirSync(path.join(SQUAD_DIR, 'orchestration-log')); + expect(remaining).toHaveLength(1); + expect(remaining[0]).toContain(today); + }); + + it('archives old session log entries', async () => { + writeFile('.squad/log/2025-02-15T10-00-00Z-session.md', 'old session'); + const today = new Date().toISOString().slice(0, 10); + writeFile(`.squad/log/${today}T08-00-00Z-session.md`, 'fresh'); + + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('log: 1 entries archived'); + }); + + it('warns about stale inbox files (>7 days)', async () => { + writeFile('.squad/decisions/inbox/2025-03-01-old-decision.md', 'stale'); + const today = new Date().toISOString().slice(0, 10); + writeFile(`.squad/decisions/inbox/${today}-fresh-decision.md`, 'fresh'); + + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toContain('stale inbox files'); + }); + + it('skips cleanup on non-matching rounds', async () => { + writeFile('.squad/.scratch/should-survive.txt', 'data'); + // everyNRounds defaults to 10; round 5 should skip + const result = await cap.execute(makeContext({ round: 5, config: {} })); + expect(result.success).toBe(true); + expect(result.summary).toContain('skipped'); + expect(existsSync(path.join(SQUAD_DIR, '.scratch', 'should-survive.txt'))).toBe(true); + }); + + it('runs on round 10 (every 10th round)', async () => { + writeFile('.squad/.scratch/temp.txt', 'data'); + const result = await cap.execute(makeContext({ round: 10 })); + expect(result.success).toBe(true); + expect(result.summary).not.toContain('skipped'); + }); + + it('always runs on round 1', async () => { + writeFile('.squad/.scratch/temp.txt', 'data'); + const result = await cap.execute(makeContext({ round: 1 })); + expect(result.success).toBe(true); + expect(result.summary).toContain('scratch: 1 files cleared'); + }); + + it('respects custom everyNRounds config', async () => { + writeFile('.squad/.scratch/temp.txt', 'data'); + // Custom config: every 3 rounds + const result = await cap.execute(makeContext({ round: 3, config: { everyNRounds: 3 } })); + expect(result.success).toBe(true); + expect(result.summary).not.toContain('skipped'); + }); + + it('reports nothing to do when all clean', async () => { + const result = await cap.execute(makeContext()); + expect(result.success).toBe(true); + expect(result.summary).toBe('cleanup: nothing to do'); + }); +}); From ed6c01454b95c57a4f33dcc1809c0322a16ed635 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 19:15:41 +0300 Subject: [PATCH 2/2] fix: address Copilot review comments on cleanup ceremony - Rename 'archive' to 'prune' in all log messages and comments - Use rmSync with recursive to handle subdirectories in .scratch/ - Add Number.isFinite and >0 guards to parseConfig validation - Use Date.now() consistently for UTC cutoff calculation - Fix changeset: correct test count from 11 to 12 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/cleanup-ceremony.md | 4 +- .../commands/watch/capabilities/cleanup.ts | 46 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.changeset/cleanup-ceremony.md b/.changeset/cleanup-ceremony.md index 0cf785ab7..4a9a2631e 100644 --- a/.changeset/cleanup-ceremony.md +++ b/.changeset/cleanup-ceremony.md @@ -1,4 +1,4 @@ ---- +--- "@bradygaster/squad-cli": minor --- @@ -9,4 +9,4 @@ Add cleanup watch capability for stale file housekeeping (#791) - Archives orchestration-log and session-log entries older than 30 days - Warns about stale decision inbox files (>7 days) - Configurable: `everyNRounds` (default: 10), `maxAgeDays` (default: 30) -- 11 new tests +- 12 new tests diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts index 3de9a3a80..7dc647800 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts @@ -1,18 +1,19 @@ -/** +/** * Cleanup capability — housekeeping for stale temp and log files. * - * Runs in the 'housekeeping' phase. Clears the scratch directory, archives + * Runs in the 'housekeeping' phase. Clears the scratch directory, prunes * old orchestration-log and session-log entries, and warns about stale * decision inbox files. */ import path from 'node:path'; +import { rmSync } from 'node:fs'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js'; const storage = new FSStorageProvider(); -/** Default: files older than this many days are archived / deleted. */ +/** Default: files older than this many days are pruned. */ const DEFAULT_MAX_AGE_DAYS = 30; /** Default: run cleanup every N rounds (not every round). */ const DEFAULT_EVERY_N_ROUNDS = 10; @@ -28,8 +29,12 @@ interface CleanupConfig { function parseConfig(raw: Record): CleanupConfig { return { - everyNRounds: typeof raw.everyNRounds === 'number' ? raw.everyNRounds : DEFAULT_EVERY_N_ROUNDS, - maxAgeDays: typeof raw.maxAgeDays === 'number' ? raw.maxAgeDays : DEFAULT_MAX_AGE_DAYS, + everyNRounds: typeof raw.everyNRounds === 'number' && Number.isFinite(raw.everyNRounds) && raw.everyNRounds > 0 + ? raw.everyNRounds + : DEFAULT_EVERY_N_ROUNDS, + maxAgeDays: typeof raw.maxAgeDays === 'number' && Number.isFinite(raw.maxAgeDays) && raw.maxAgeDays > 0 + ? raw.maxAgeDays + : DEFAULT_MAX_AGE_DAYS, }; } @@ -39,9 +44,8 @@ function isOlderThan(filename: string, days: number): boolean { if (!match) return false; const fileDate = new Date(match[1]!); if (isNaN(fileDate.getTime())) return false; - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - days); - return fileDate < cutoff; + const cutoffMs = Date.now() - (days * 86400000); + return fileDate.getTime() < cutoffMs; } function safeList(dir: string): string[] { @@ -55,7 +59,7 @@ function safeList(dir: string): string[] { function safeDelete(filePath: string): boolean { try { - storage.deleteSync?.(filePath); + rmSync(filePath, { recursive: true, force: true }); return true; } catch { return false; @@ -64,7 +68,7 @@ function safeDelete(filePath: string): boolean { export class CleanupCapability implements WatchCapability { readonly name = 'cleanup'; - readonly description = 'Remove stale scratch files, archive old logs, warn about stale inbox'; + readonly description = 'Remove stale scratch files, prune old logs, warn about stale inbox'; readonly configShape = 'object' as const; readonly requires: string[] = []; readonly phase = 'housekeeping' as const; @@ -101,30 +105,30 @@ export class CleanupCapability implements WatchCapability { actions.push(`scratch: ${scratchDeleted} files cleared`); } - // 2. Archive old orchestration-log entries (older than maxAge days) + // 2. Prune old orchestration-log entries (older than maxAge days) const orchDir = path.join(squadDir, 'orchestration-log'); const orchFiles = safeList(orchDir); - let orchArchived = 0; + let orchPruned = 0; for (const f of orchFiles) { if (isOlderThan(f, maxAge)) { - if (safeDelete(path.join(orchDir, f))) orchArchived++; + if (safeDelete(path.join(orchDir, f))) orchPruned++; } } - if (orchArchived > 0) { - actions.push(`orchestration-log: ${orchArchived} entries archived (>${maxAge}d)`); + if (orchPruned > 0) { + actions.push(`orchestration-log: ${orchPruned} entries pruned (>${maxAge}d)`); } - // 3. Archive old session logs + // 3. Prune old session logs const logDir = path.join(squadDir, 'log'); const logFiles = safeList(logDir); - let logArchived = 0; + let logPruned = 0; for (const f of logFiles) { if (isOlderThan(f, maxAge)) { - if (safeDelete(path.join(logDir, f))) logArchived++; + if (safeDelete(path.join(logDir, f))) logPruned++; } } - if (logArchived > 0) { - actions.push(`log: ${logArchived} entries archived (>${maxAge}d)`); + if (logPruned > 0) { + actions.push(`log: ${logPruned} entries pruned (>${maxAge}d)`); } // 4. Warn about stale decision inbox files @@ -142,7 +146,7 @@ export class CleanupCapability implements WatchCapability { return { success: true, summary: `cleanup: ${actions.join('; ')}`, - data: { scratchDeleted, orchArchived, logArchived, staleInboxCount: staleInbox.length }, + data: { scratchDeleted, orchPruned, logPruned, staleInboxCount: staleInbox.length }, }; } }