diff --git a/plugins/ultraplan-local/commands/ultracontinue-local.md b/plugins/ultraplan-local/commands/ultracontinue-local.md index 7562c94..ee53e3d 100644 --- a/plugins/ultraplan-local/commands/ultracontinue-local.md +++ b/plugins/ultraplan-local/commands/ultracontinue-local.md @@ -46,9 +46,11 @@ Phase 1. Do NOT print the usage block on empty args. /ultracontinue — Resume the next session in a multi-session ultraplan project. Usage: - /ultracontinue # auto-discover state file under cwd - /ultracontinue # explicit project directory - /ultracontinue --help # this message + /ultracontinue # auto-discover state file under cwd + /ultracontinue # explicit project directory + /ultracontinue --cleanup # dry-run: list stale files + /ultracontinue --cleanup --confirm # actually delete (requires status: completed) + /ultracontinue --help # this message Reads .claude/projects//.session-state.local.json (per-project, gitignored). On a valid resumable state, prints a 3-line summary and begins @@ -72,6 +74,52 @@ Typical flow: /ultracontinue # reads session-state, runs next session ``` +## Phase 0.5 — Cleanup mode dispatch + +After `parseArgs` has resolved `$ARGUMENTS`, check the parsed `flags` +object directly (NOT a string contains-check on raw `$ARGUMENTS` — that +substring pattern was the root cause of Bug 1). + +If `flags['--cleanup'] === true`, switch into the terminal cleanup +flow and do NOT proceed to Phase 1 or any later phase. + +**Required positional:** an explicit `` (`positional[0]`). +There is no "clean all" mode — accidental wholesale deletion would be +irreversible. If `positional[0]` is missing, empty, or starts with `-`, +print this usage block to stderr and exit non-zero: + +``` +Error: /ultracontinue-local --cleanup requires . +Usage: + /ultracontinue-local --cleanup # dry-run: list stale files + /ultracontinue-local --cleanup --confirm # actually delete (status: completed) +``` + +**Compute mode from parsed flags:** + +``` +dryRun = (flags['--confirm'] !== true) +confirm = (flags['--confirm'] === true) +``` + +**Invoke cleanup inline.** Emit the concrete project-dir path as a literal +token in the Bash command — never a template placeholder — same +anti-substitution rule as Phase 2: + +``` +node --input-type=module -e "import {cleanupProject} from './lib/util/cleanup.mjs'; const [, dir, mode] = process.argv; const r = cleanupProject(dir, {dryRun: mode !== 'confirm', confirm: mode === 'confirm'}); console.log(JSON.stringify(r, null, 2)); process.exit(r.valid ? 0 : 1)" '' '' +``` + +Substitute `` with the literal `positional[0]` +value you have in your working context, and `` with either the +literal string `dryrun` or the literal string `confirm` based on the +booleans above. The validator emits a `{valid, errors, warnings, parsed}` +JSON record. Print it to stdout. Exit with the validator's exit code. + +**Cleanup is a terminal mode.** It must not fall through to Phase 1/2/3/4. +Operators who want to resume after cleanup must invoke `/ultracontinue` +again without `--cleanup`. + ## Phase 1 — Resolve project directory The parsed `positional[0]` from Phase 0 is the explicit project-dir argument, diff --git a/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs b/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs index 130974a..ba3c53f 100644 --- a/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs +++ b/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs @@ -267,3 +267,85 @@ test('ultracontinue Bug 2 — Phase 2 contains no {state-file-path} or any {curl 'Phase 2 must document the deterministic Read tool flow', ); }); + +// --------------------------------------------------------------- +// Step 10 — Bug 4 regression tests (Phase 0.5 wire-up + cleanup f-1/f-2/f-3) +// --------------------------------------------------------------- + +test('ultracontinue Bug 4 — Phase 0.5 documents cleanup mode dispatch', () => { + const cmd = readCommand(); + assert.match(cmd, /## Phase 0\.5 /, 'Phase 0.5 header must be present'); + // Phase 0.5 must come BETWEEN Phase 0 and Phase 1. + const idx05 = cmd.indexOf('## Phase 0.5 '); + const idx1 = cmd.indexOf('## Phase 1 '); + assert.ok(idx05 !== -1 && idx1 !== -1 && idx05 < idx1, + 'Phase 0.5 must appear before Phase 1'); + // Must reference cleanupProject and parsed flags['--cleanup']. + const phase05 = extractPhase(cmd, '## Phase 0.5 '); + assert.match(phase05, /cleanupProject/, 'Phase 0.5 must invoke cleanupProject'); + assert.match(phase05, /flags\['--cleanup'\]/, "Phase 0.5 must dispatch via flags['--cleanup']"); + // Usage block must document both forms. + assert.match(cmd, /--cleanup --confirm/, 'usage must mention --cleanup --confirm'); +}); + +test('ultracontinue Bug 4 (f-1) dry-run lists candidates without deleting', async () => { + const { cleanupProject } = await import('../../lib/util/cleanup.mjs'); + const root = mkdtempSync(join(tmpdir(), 'ultracontinue-cleanup-')); + try { + const dir = join(root, 'project-completed'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({ + schema_version: 1, + project: dir, + next_session_brief_path: join(dir, 'brief.md'), + next_session_label: 'Done', + status: 'completed', + updated_at: '2026-05-04T16:00:00.000Z', + }, 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'); + const r = cleanupProject(dir, { dryRun: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.equal(r.parsed.wouldDelete.length, 2); + assert.equal(readFileSync(join(dir, '.session-state.local.json'), 'utf8').length > 0, true); + assert.equal(readFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), 'utf8').length > 0, true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('ultracontinue Bug 4 (f-2) confirm deletes and (f-3) idempotent re-run handles already-clean dir', async () => { + const { cleanupProject } = await import('../../lib/util/cleanup.mjs'); + const { existsSync } = await import('node:fs'); + const root = mkdtempSync(join(tmpdir(), 'ultracontinue-cleanup-')); + try { + const dir = join(root, 'project-completed'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({ + schema_version: 1, + project: dir, + next_session_brief_path: join(dir, 'brief.md'), + next_session_label: 'Done', + status: 'completed', + updated_at: '2026-05-04T16:00:00.000Z', + }, 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'); + + // f-2: confirm deletes + const r2 = cleanupProject(dir, { dryRun: false, confirm: true }); + assert.equal(r2.valid, true, JSON.stringify(r2.errors)); + assert.equal(r2.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); + + // f-3: idempotent re-run on a fully-cleaned dir reports CLEANUP_NO_STATE_FILE + // (no state file → nothing to clean) — a deterministic terminal signal, + // not a crash. Operators can ignore it. + const r3 = cleanupProject(dir, { dryRun: false, confirm: true }); + assert.equal(r3.valid, false); + assert.ok(r3.errors.find(e => e.code === 'CLEANUP_NO_STATE_FILE')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});