// lib/util/cleanup.mjs // Bug 4 — operator-invoked cleanup of completed-project state files. // // The trekplan pipeline does NOT auto-cleanup state on session-end: // stale .session-state.local.json + NEXT-SESSION-PROMPT.local.md across many // projects accumulate over time. This util removes them safely once the // project is fully done (status === 'completed' as seen by validateSessionState). // // Invariants: // - Strict equality on parsed.status === 'completed' (no soft-match). // - Idempotent: re-running on a partially-cleaned dir succeeds with deleted: []. // - Refuses dryRun: false without an explicit confirm: true (prevents accidents). // - ENOENT counts as "already absent" — never an error. // - Cleanup is operator-invoked from /trekcontinue --cleanup; no Bash binding here. import { existsSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { issue, fail, ok } from './result.mjs'; import { validateSessionState } from '../validators/session-state-validator.mjs'; const CANDIDATE_FILES = Object.freeze([ '.session-state.local.json', 'NEXT-SESSION-PROMPT.local.md', ]); /** * Clean up state files for a completed trekplan project. * * @param {string} projectDir - absolute or cwd-relative path to the project directory * @param {{dryRun?: boolean, confirm?: boolean}} [opts] * @returns {{valid: boolean, errors: object[], warnings: object[], parsed?: {wouldDelete?: string[], deleted?: string[]}}} */ export function cleanupProject(projectDir, opts = {}) { const dryRun = opts.dryRun !== false; // default true const confirm = opts.confirm === true; if (!dryRun && !confirm) { return fail(issue( 'CLEANUP_REQUIRES_CONFIRM', 'Refused: dryRun=false requires confirm=true (explicit operator confirmation)', 'Re-run with {dryRun: false, confirm: true} to actually delete files.', )); } if (typeof projectDir !== 'string' || projectDir.length === 0) { return fail(issue('CLEANUP_INVALID_PROJECT_DIR', 'projectDir must be a non-empty string')); } const stateFile = join(projectDir, '.session-state.local.json'); if (!existsSync(stateFile)) { return fail(issue( 'CLEANUP_NO_STATE_FILE', `No state file at ${stateFile}; nothing to clean up`, 'cleanup is only valid for projects that have a .session-state.local.json with status: completed', )); } const validation = validateSessionState(stateFile); if (!validation.valid) { return fail(issue( 'CLEANUP_INVALID_STATE_FILE', `State file at ${stateFile} is invalid: ${validation.errors.map(e => e.code).join(', ')}`, )); } if (validation.parsed.status !== 'completed') { return fail(issue( 'CLEANUP_NOT_COMPLETED', `Refused: status is "${validation.parsed.status}", not "completed"`, 'cleanup is reserved for fully-finished projects. Resume via /trekcontinue or wait until the run completes.', )); } const candidates = CANDIDATE_FILES.map(f => join(projectDir, f)); if (dryRun) { const wouldDelete = candidates.filter(p => existsSync(p)); return { valid: true, errors: [], warnings: [], parsed: { wouldDelete, deleted: [] } }; } const deleted = []; for (const p of candidates) { try { unlinkSync(p); deleted.push(p); } catch (e) { if (e && e.code === 'ENOENT') continue; // idempotent: already absent return fail(issue('CLEANUP_UNLINK_FAILED', `Failed to delete ${p}: ${e.message}`)); } } return ok({ wouldDelete: [], deleted }); }