ktg-plugin-marketplace/plugins/voyage/lib/util/cleanup.mjs
Kjell Tore Guttormsen 7a90d348ad feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope.

- git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved)
- .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local
- CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list
- README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph
- plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path
- plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed)

Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
2026-05-05 15:37:52 +02:00

94 lines
3.4 KiB
JavaScript

// 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 });
}