From 3c0f0a0bab1b308de56b067d810923d219fc5c69 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 4 May 2026 17:41:06 +0200 Subject: [PATCH] feat(ultraplan-local): cleanup util (Bug 4 dry-run/confirm/idempotent) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 9 of v3.4.1 plan. lib/util/cleanup.mjs (new): - cleanupProject(projectDir, {dryRun, confirm}) reads .session-state.local.json via validateSessionState; refuses unless the parsed status is strictly equal to 'completed' (per risk-assessor Critical 2 — no soft-match on similar statuses). - Default dryRun: true; refuses dryRun: false without explicit confirm: true (CLEANUP_REQUIRES_CONFIRM). - Removes .session-state.local.json + NEXT-SESSION-PROMPT.local.md candidates; ENOENT counts as "already absent" so the function is idempotent. - No CLI shim — invoked from /ultracontinue --cleanup via inline ESM (Step 10 wires this in). tests/lib/cleanup.test.mjs (new): - 7 cases: dry-run lists candidates without deleting; confirm-mode deletes both files; idempotent re-run signals CLEANUP_NO_STATE_FILE after fully cleaned; refuses on status: in_progress (CLEANUP_NOT_COMPLETED); refuses dryRun: false without confirm (CLEANUP_REQUIRES_CONFIRM); defaults to dry-run; missing state file returns CLEANUP_NO_STATE_FILE. Internal scaffolding consumed by Step 10 (Phase 0.5 wire-up). User-facing docs land with Step 14. Tests 348 -> 355 (+7). Co-Authored-By: Claude Opus 4.7 --- plugins/ultraplan-local/lib/util/cleanup.mjs | 94 ++++++++++++ .../tests/lib/cleanup.test.mjs | 134 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 plugins/ultraplan-local/lib/util/cleanup.mjs create mode 100644 plugins/ultraplan-local/tests/lib/cleanup.test.mjs diff --git a/plugins/ultraplan-local/lib/util/cleanup.mjs b/plugins/ultraplan-local/lib/util/cleanup.mjs new file mode 100644 index 0000000..6b35ab0 --- /dev/null +++ b/plugins/ultraplan-local/lib/util/cleanup.mjs @@ -0,0 +1,94 @@ +// lib/util/cleanup.mjs +// Bug 4 — operator-invoked cleanup of completed-project state files. +// +// The ultraplan-local 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 /ultracontinue --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 ultraplan 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 /ultracontinue 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 }); +} diff --git a/plugins/ultraplan-local/tests/lib/cleanup.test.mjs b/plugins/ultraplan-local/tests/lib/cleanup.test.mjs new file mode 100644 index 0000000..b8d7f3e --- /dev/null +++ b/plugins/ultraplan-local/tests/lib/cleanup.test.mjs @@ -0,0 +1,134 @@ +// tests/lib/cleanup.test.mjs +// Unit tests for lib/util/cleanup.mjs (Bug 4). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { cleanupProject } from '../../lib/util/cleanup.mjs'; + +function buildProject(dir, status) { + mkdirSync(dir, { recursive: true }); + const stateObj = { + schema_version: 1, + project: dir, + next_session_brief_path: join(dir, 'brief.md'), + next_session_label: 'Cleanup test fixture', + status, + updated_at: '2026-05-04T16:00:00.000Z', + }; + writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify(stateObj, null, 2)); + writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), + `---\nproduced_by: ultraexecute-local\nproduced_at: 2026-05-04T16:00:00.000Z\n---\n\n# Done\n`); + return dir; +} + +test('cleanupProject — dry-run on completed project lists candidates without deleting', () => { + const root = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const dir = buildProject(join(root, 'project-a'), 'completed'); + const r = cleanupProject(dir, { dryRun: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.wouldDelete.length, 2); + assert.deepEqual(r.parsed.deleted, []); + // Files MUST still exist. + assert.equal(existsSync(join(dir, '.session-state.local.json')), true); + assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('cleanupProject — confirm-mode deletes both candidate files', () => { + const root = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const dir = buildProject(join(root, 'project-b'), 'completed'); + const r = cleanupProject(dir, { dryRun: false, confirm: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.deleted.length, 2); + assert.equal(existsSync(join(dir, '.session-state.local.json')), false); + assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('cleanupProject — idempotent re-run after partial cleanup succeeds with deleted: []', () => { + const root = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const dir = buildProject(join(root, 'project-c'), 'completed'); + // First confirm-mode deletes the prompt file BUT we still have the state file. + // Manually remove the prompt file FIRST so the state file (still completed) is + // the only candidate left after first cleanup. + unlinkSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')); + const first = cleanupProject(dir, { dryRun: false, confirm: true }); + assert.equal(first.valid, true); + assert.equal(first.parsed.deleted.length, 1, 'first cleanup deletes only the state file (prompt was pre-removed)'); + // Second invocation must fail — no state file → CLEANUP_NO_STATE_FILE. + // This is the documented "fully cleaned" terminal state and is NOT an error + // for the operator (they can ignore CLEANUP_NO_STATE_FILE), but the function + // signals it deterministically. + const second = cleanupProject(dir, { dryRun: false, confirm: true }); + assert.equal(second.valid, false); + assert.ok(second.errors.find(e => e.code === 'CLEANUP_NO_STATE_FILE')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('cleanupProject — refuses on status: in_progress (CLEANUP_NOT_COMPLETED)', () => { + const root = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const dir = buildProject(join(root, 'project-d'), 'in_progress'); + const r = cleanupProject(dir, { dryRun: false, confirm: true }); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'CLEANUP_NOT_COMPLETED')); + // Files MUST still exist (refusal must not partially clean). + assert.equal(existsSync(join(dir, '.session-state.local.json')), true); + assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('cleanupProject — refuses dryRun: false without confirm: true (CLEANUP_REQUIRES_CONFIRM)', () => { + const root = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const dir = buildProject(join(root, 'project-e'), 'completed'); + const r = cleanupProject(dir, { dryRun: false }); // no confirm + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'CLEANUP_REQUIRES_CONFIRM')); + assert.equal(existsSync(join(dir, '.session-state.local.json')), true); + assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('cleanupProject — defaults to dry-run when opts is omitted', () => { + const root = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const dir = buildProject(join(root, 'project-f'), 'completed'); + const r = cleanupProject(dir); + assert.equal(r.valid, true); + assert.deepEqual(r.parsed.deleted, []); + assert.equal(r.parsed.wouldDelete.length, 2); + assert.equal(existsSync(join(dir, '.session-state.local.json')), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('cleanupProject — missing state file returns CLEANUP_NO_STATE_FILE', () => { + const root = mkdtempSync(join(tmpdir(), 'cleanup-')); + try { + const dir = join(root, 'project-empty'); + mkdirSync(dir, { recursive: true }); + const r = cleanupProject(dir); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'CLEANUP_NO_STATE_FILE')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});