feat(ultraplan-local): Bug 4 — wire --cleanup into /ultracontinue-local [skip-docs]

Step 10 of v3.4.1 plan.

commands/ultracontinue-local.md:
- New Phase 0.5 between Phase 0 and Phase 1 — terminal cleanup mode
  triggered by parsed flags['--cleanup'] === true. Requires explicit
  positional[0] (no "clean all"), no template placeholders in the Bash
  invocation. Passes through to cleanupProject via inline ESM. Cleanup
  never falls through to Phase 1/2/3/4.
- Phase 0 usage block updated to document --cleanup and --cleanup
  --confirm forms alongside the legacy <project-dir> form.

tests/commands/ultracontinue.test.mjs:
- Test (Bug 4 prose) — Phase 0.5 header present, references
  cleanupProject and flags['--cleanup'], appears between Phase 0 and
  Phase 1 in document order, usage mentions --cleanup --confirm.
- Test (f-1) dry-run on completed project lists candidates without
  deleting; both files still on disk.
- Test (f-2 + f-3) confirm-mode deletes both files; subsequent
  invocation on the already-cleaned dir signals CLEANUP_NO_STATE_FILE
  (deterministic terminal state, idempotent for operators).

Tests 355 -> 358 (+3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-04 17:42:56 +02:00
commit 9fa83bdf2f
2 changed files with 133 additions and 3 deletions

View file

@ -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 <project-dir> # explicit project directory
/ultracontinue --help # this message
/ultracontinue # auto-discover state file under cwd
/ultracontinue <project-dir> # explicit project directory
/ultracontinue --cleanup <project-dir> # dry-run: list stale files
/ultracontinue --cleanup --confirm <project-dir> # actually delete (requires status: completed)
/ultracontinue --help # this message
Reads .claude/projects/<project>/.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 `<project-dir>` (`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 <project-dir>.
Usage:
/ultracontinue-local --cleanup <project-dir> # dry-run: list stale files
/ultracontinue-local --cleanup --confirm <project-dir> # 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)" '<RESOLVED-PROJECT-DIR>' '<MODE>'
```
Substitute `<RESOLVED-PROJECT-DIR>` with the literal `positional[0]`
value you have in your working context, and `<MODE>` 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,

View file

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