feat(cli): cleanup watch capability — stale file housekeeping (#791)#794
feat(cli): cleanup watch capability — stale file housekeeping (#791)#794tamirdresher wants to merge 3 commits intodevfrom
Conversation
…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>
🛫 PR Readiness Check
|
| Status | Check | Details |
|---|---|---|
| ❌ | Single commit | 3 commits — consider squashing before review |
| ✅ | Not in draft | Ready for review |
| ✅ | Branch up to date | Up to date with dev |
| ❌ | Copilot review | No Copilot review yet — it may still be processing |
| ✅ | Changeset present | Changeset file found |
| ✅ | Scope clean | No .squad/ or docs/proposals/ files |
| ✅ | No merge conflicts | No merge conflicts |
| ❌ | Copilot threads resolved | 2 unresolved Copilot thread(s) — fix and resolve before merging |
| ❌ | CI passing | 15 check(s) still running |
This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.
There was a problem hiding this comment.
Pull request overview
Adds a new cleanup watch capability in the housekeeping phase to proactively remove stale .squad/ artifacts (scratch + old logs) and surface stale decision inbox items.
Changes:
- Introduces
CleanupCapabilityand registers it in the default watch capability registry. - Implements scratch clearing, age-based log/orchestration cleanup, and stale inbox warnings with configurable cadence/retention.
- Adds a dedicated Vitest suite covering capability behavior and configuration.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
packages/squad-cli/src/cli/commands/watch/capabilities/cleanup.ts |
New cleanup capability implementation (scratch/log cleanup + inbox staleness warning). |
packages/squad-cli/src/cli/commands/watch/capabilities/index.ts |
Registers CleanupCapability in the built-in capability registry. |
test/watch-cleanup.test.ts |
Adds unit tests for cleanup metadata, preflight, execution behavior, and config handling. |
.changeset/cleanup-ceremony.md |
Release note for the new watch capability. |
| 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; |
There was a problem hiding this comment.
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.
| // 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`); | ||
| } |
There was a problem hiding this comment.
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.
| function parseConfig(raw: Record<string, unknown>): CleanupConfig { | ||
| return { | ||
| everyNRounds: typeof raw.everyNRounds === 'number' ? raw.everyNRounds : DEFAULT_EVERY_N_ROUNDS, | ||
| maxAgeDays: typeof raw.maxAgeDays === 'number' ? raw.maxAgeDays : DEFAULT_MAX_AGE_DAYS, |
There was a problem hiding this comment.
parseConfig accepts any number values for everyNRounds / maxAgeDays (including 0, negatives, NaN, Infinity). everyNRounds <= 0 (or NaN) causes the modulo check to behave unexpectedly (often skipping forever except round 1), and negative ages invert retention. Clamp/validate to finite integers (e.g., everyNRounds >= 1, maxAgeDays >= 0) and fall back to defaults on invalid input.
| function parseConfig(raw: Record<string, unknown>): 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 parseIntegerConfig(value: unknown, minimum: number, fallback: number): number { | |
| if (typeof value !== 'number' || !Number.isFinite(value)) { | |
| return fallback; | |
| } | |
| const normalized = Math.floor(value); | |
| return normalized >= minimum ? normalized : fallback; | |
| } | |
| function parseConfig(raw: Record<string, unknown>): CleanupConfig { | |
| return { | |
| everyNRounds: parseIntegerConfig(raw.everyNRounds, 1, DEFAULT_EVERY_N_ROUNDS), | |
| maxAgeDays: parseIntegerConfig(raw.maxAgeDays, 0, DEFAULT_MAX_AGE_DAYS), |
| const fileDate = new Date(match[1]!); | ||
| if (isNaN(fileDate.getTime())) return false; | ||
| const cutoff = new Date(); | ||
| cutoff.setDate(cutoff.getDate() - days); | ||
| return fileDate < cutoff; |
There was a problem hiding this comment.
Date parsing mixes UTC (new Date('YYYY-MM-DD') is parsed as UTC midnight) with a local-time cutoff (new Date(); cutoff.setDate(...)). This can produce off-by-one behavior around day boundaries/timezones. Consider computing the cutoff in UTC (setUTCDate / Date.UTC) or comparing by date-only strings consistently.
| 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 fileDateString = match[1]!; | |
| const fileDate = new Date(`${fileDateString}T00:00:00.000Z`); | |
| if (isNaN(fileDate.getTime())) return false; | |
| if (fileDate.toISOString().slice(0, 10) !== fileDateString) return false; | |
| const cutoff = new Date(); | |
| cutoff.setUTCDate(cutoff.getUTCDate() - days); | |
| const cutoffDateString = cutoff.toISOString().slice(0, 10); | |
| return fileDateString < cutoffDateString; |
.changeset/cleanup-ceremony.md
Outdated
| - Warns about stale decision inbox files (>7 days) | ||
| - Configurable: `everyNRounds` (default: 10), `maxAgeDays` (default: 30) | ||
| - 11 new tests |
There was a problem hiding this comment.
The changeset claims “11 new tests”, but test/watch-cleanup.test.ts adds 12 it(...) cases. Update the count (or adjust the tests) so the release note matches the actual PR contents.
- 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>
|
Superseded by #830 — combined into a single watch next-gen PR for easier review. |
|
Closing — superseded by #830 (watch-next-gen combined PR) |
What
New
cleanupwatch capability in thehousekeepingphase that removes stale temp files, archives old logs, and warns about stale inbox files.Why
Squad state directories accumulate files over time — old session logs, orchestration entries, scratch files. No automated cleanup exists. Closes #791
How
.squad/.scratch/(all ephemeral temp files)everyNRounds)index.ts)Testing
npm run buildpassesnpm testpasses (12 new tests — metadata, preflight, scratch clearing, log archival, inbox warnings, round skipping, custom config)Docs
Exports
N/A (CLI internal capability)
Breaking Changes
None
Waivers
None