Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/cleanup-ceremony.md
Original file line number Diff line number Diff line change
@@ -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)
- 12 new tests
152 changes: 152 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Cleanup capability — housekeeping for stale temp and log files.
*
* 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 pruned. */
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<string, unknown>): CleanupConfig {
return {
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,
};
}

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 cutoffMs = Date.now() - (days * 86400000);
return fileDate.getTime() < cutoffMs;
}

function safeList(dir: string): string[] {
try {
if (!storage.existsSync(dir)) return [];
return storage.listSync?.(dir) ?? [];
} catch {
return [];
}
}

function safeDelete(filePath: string): boolean {
try {
rmSync(filePath, { recursive: true, force: true });
return true;
} catch {
return false;
}
}

export class CleanupCapability implements WatchCapability {
readonly name = 'cleanup';
readonly description = 'Remove stale scratch files, prune old logs, warn about stale inbox';
readonly configShape = 'object' as const;
Comment on lines +69 to +72

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The capability claims it “archives” old log/orchestration entries (and reports them as archived), but the implementation only unlinks the files (deleteSync). Either implement a real archive/move, or rename the user-facing strings/variable names to “deleted/removed” to avoid misleading housekeeping output.

Copilot uses AI. Check for mistakes.
readonly requires: string[] = [];
readonly phase = 'housekeeping' as const;

async preflight(context: WatchContext): Promise<PreflightResult> {
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<CapabilityResult> {
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`);
}
Comment on lines +97 to +106

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch cleanup iterates entries and calls deleteSync (unlink). If .squad/.scratch/ contains subdirectories (or nested trees), unlink will fail and the cleanup run may incorrectly report “nothing to do” or undercount deletions. Consider detecting directories and using deleteDirSync (recursive) or a recursive traversal to truly “delete everything” in scratch.

Copilot uses AI. Check for mistakes.

// 2. Prune old orchestration-log entries (older than maxAge days)
const orchDir = path.join(squadDir, 'orchestration-log');
const orchFiles = safeList(orchDir);
let orchPruned = 0;
for (const f of orchFiles) {
if (isOlderThan(f, maxAge)) {
if (safeDelete(path.join(orchDir, f))) orchPruned++;
}
}
if (orchPruned > 0) {
actions.push(`orchestration-log: ${orchPruned} entries pruned (>${maxAge}d)`);
}

// 3. Prune old session logs
const logDir = path.join(squadDir, 'log');
const logFiles = safeList(logDir);
let logPruned = 0;
for (const f of logFiles) {
if (isOlderThan(f, maxAge)) {
if (safeDelete(path.join(logDir, f))) logPruned++;
}
}
if (logPruned > 0) {
actions.push(`log: ${logPruned} entries pruned (>${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, orchPruned, logPruned, staleInboxCount: staleInbox.length },
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
141 changes: 141 additions & 0 deletions test/watch-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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');

Check failure on line 78 in test/watch-cleanup.test.ts

View workflow job for this annotation

GitHub Actions / test

test/watch-cleanup.test.ts > CleanupCapability > archives old orchestration-log entries

AssertionError: expected 'cleanup: orchestration-log: 1 entries…' to contain 'orchestration-log: 1 entries archived' Expected: "orchestration-log: 1 entries archived" Received: "cleanup: orchestration-log: 1 entries pruned (>30d)" ❯ test/watch-cleanup.test.ts:78:28
// 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');

Check failure on line 92 in test/watch-cleanup.test.ts

View workflow job for this annotation

GitHub Actions / test

test/watch-cleanup.test.ts > CleanupCapability > archives old session log entries

AssertionError: expected 'cleanup: log: 1 entries pruned (>30d)' to contain 'log: 1 entries archived' Expected: "log: 1 entries archived" Received: "cleanup: log: 1 entries pruned (>30d)" ❯ test/watch-cleanup.test.ts:92:28
});

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');
});
});
Loading