Skip to content

Commit 43044fa

Browse files
rafiki270claude
andcommitted
feat: add delete subcommand for tasks and sections
Adds `steroids tasks delete <id|title>` and `steroids sections delete <id>` commands with --force (skip in-progress guard / cascade tasks) and --dry-run support. Cascades nullify references and clean all dependent rows atomically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 260dfd7 commit 43044fa

File tree

5 files changed

+232
-2
lines changed

5 files changed

+232
-2
lines changed

src/commands/sections-delete.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { parseArgs } from 'node:util';
2+
import { withDatabase } from '../database/connection.js';
3+
import { getSection, deleteSection } from '../database/queries.js';
4+
import type { GlobalFlags } from '../cli/flags.js';
5+
import { createOutput } from '../cli/output.js';
6+
import { invalidArgumentsError, sectionNotFoundError } from '../cli/errors.js';
7+
import { ErrorCode, getExitCode } from '../cli/errors.js';
8+
9+
export async function deleteSectionCmd(args: string[], flags: GlobalFlags): Promise<void> {
10+
const out = createOutput({ command: 'sections', subcommand: 'delete', flags });
11+
12+
const { values, positionals } = parseArgs({
13+
args,
14+
options: {
15+
force: { type: 'boolean', short: 'f', default: false },
16+
},
17+
allowPositionals: true,
18+
});
19+
20+
if (flags.help) {
21+
out.log(`
22+
steroids sections delete <id> - Permanently delete a section
23+
24+
USAGE:
25+
steroids sections delete <id> [options]
26+
27+
ARGUMENTS:
28+
id Section ID or prefix (min 4 chars)
29+
30+
OPTIONS:
31+
-f, --force Also delete all tasks belonging to the section
32+
--dry-run Show what would be deleted without making changes
33+
34+
GLOBAL OPTIONS:
35+
-j, --json Output as JSON
36+
-h, --help Show help
37+
38+
DESCRIPTION:
39+
Permanently deletes a section and removes all its dependency edges.
40+
By default, the command fails if the section still has tasks.
41+
Use --force to delete the section along with all its tasks.
42+
43+
EXAMPLES:
44+
steroids sections delete abc123
45+
steroids sections delete abc123 --force
46+
`);
47+
return;
48+
}
49+
50+
if (positionals.length === 0) {
51+
throw invalidArgumentsError('Section ID required');
52+
}
53+
54+
const sectionIdInput = positionals[0];
55+
const projectPath = process.cwd();
56+
57+
withDatabase(projectPath, (db: any) => {
58+
const section = getSection(db, sectionIdInput);
59+
if (!section) {
60+
throw sectionNotFoundError(sectionIdInput);
61+
}
62+
63+
if (flags.dryRun) {
64+
const taskCount = (db.prepare('SELECT COUNT(*) as n FROM tasks WHERE section_id = ?').get(section.id) as { n: number }).n;
65+
out.log(`Would delete section: ${section.name} (${section.id})`);
66+
if (taskCount > 0) {
67+
if (values.force) {
68+
out.log(` Would also delete ${taskCount} task(s)`);
69+
} else {
70+
out.log(` Section has ${taskCount} task(s) — add --force to delete them too`);
71+
}
72+
}
73+
return;
74+
}
75+
76+
let deletedTaskCount: number;
77+
try {
78+
deletedTaskCount = deleteSection(db, section.id, { force: values.force });
79+
} catch (err: any) {
80+
out.error(ErrorCode.GENERAL_ERROR, err.message);
81+
process.exit(getExitCode(ErrorCode.GENERAL_ERROR));
82+
}
83+
84+
if (flags.json) {
85+
out.success({
86+
deleted: {
87+
id: section.id,
88+
name: section.name,
89+
deletedTaskCount,
90+
},
91+
});
92+
} else {
93+
out.log(`Deleted section: ${section.name} (${section.id})`);
94+
if (deletedTaskCount > 0) {
95+
out.log(` Also deleted ${deletedTaskCount} task(s)`);
96+
}
97+
}
98+
});
99+
}

src/commands/sections.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
updateSection,
2525
resetSectionPr,
2626
} from './sections-commands.js';
27+
import { deleteSectionCmd } from './sections-delete.js';
2728
import { generateHelp } from '../cli/help.js';
2829
import { createOutput } from '../cli/output.js';
2930
import { ErrorCode, getExitCode } from '../cli/errors.js';
@@ -43,6 +44,7 @@ Sections help structure large projects and track high-level progress.`,
4344
{ name: 'priority', args: '<id> <value>', description: 'Set section priority (0-100 or high/medium/low)' },
4445
{ name: 'depends-on', args: '<id> <depends-on-id>', description: 'Add section dependency' },
4546
{ name: 'no-depends-on', args: '<id> <dep-id>', description: 'Remove section dependency' },
47+
{ name: 'delete', args: '<id>', description: 'Permanently delete a section' },
4648
{ name: 'graph', args: '[options]', description: 'Show dependency graph (ASCII, Mermaid, image)' },
4749
],
4850
options: [
@@ -67,6 +69,8 @@ Sections help structure large projects and track high-level progress.`,
6769
{ command: 'steroids sections priority abc123 25', description: 'Set numeric priority (0-100)' },
6870
{ command: 'steroids sections depends-on abc123 def456', description: 'Add dependency' },
6971
{ command: 'steroids sections no-depends-on abc123 def456', description: 'Remove dependency' },
72+
{ command: 'steroids sections delete abc123', description: 'Delete empty section' },
73+
{ command: 'steroids sections delete abc123 --force', description: 'Delete section and all its tasks' },
7074
{ command: 'steroids sections graph', description: 'ASCII tree view (default)' },
7175
{ command: 'steroids sections graph --json', description: 'JSON output' },
7276
{ command: 'steroids sections graph --mermaid', description: 'Mermaid flowchart syntax' },
@@ -128,6 +132,9 @@ export async function sectionsCommand(args: string[], flags: GlobalFlags): Promi
128132
case 'no-depends-on':
129133
await removeDependency(subArgs, flags);
130134
break;
135+
case 'delete':
136+
await deleteSectionCmd(subArgs, flags);
137+
break;
131138
case 'graph':
132139
await showGraph(subArgs, flags);
133140
break;

src/commands/tasks-reset.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
33
import { join } from 'node:path';
44
import { spawnSync } from 'node:child_process';
55
import { openDatabase, withDatabase } from '../database/connection.js';
6-
import { getTask, getTaskByTitle, listTasks, getSectionDependencies, type Task } from '../database/queries.js';
6+
import { getTask, getTaskByTitle, listTasks, getSectionDependencies, deleteTask, type Task } from '../database/queries.js';
77
import { createOutput } from '../cli/output.js';
88
import { ErrorCode, getExitCode } from '../cli/errors.js';
99
import type { GlobalFlags } from '../cli/flags.js';
@@ -289,3 +289,76 @@ function killRunnerAndRevokeLease(taskId: string, projDb: any, out: any) {
289289
closeGlobal();
290290
}
291291
}
292+
293+
export async function deleteTaskCmd(args: string[], flags: GlobalFlags): Promise<void> {
294+
const out = createOutput({ command: 'tasks', subcommand: 'delete', flags });
295+
296+
const { values, positionals } = parseArgs({
297+
args,
298+
options: {
299+
force: { type: 'boolean', short: 'f', default: false },
300+
},
301+
allowPositionals: true,
302+
});
303+
304+
if (flags.help) {
305+
out.log(`
306+
steroids tasks delete <id|title> - Permanently delete a task
307+
308+
USAGE:
309+
steroids tasks delete <id|title> [options]
310+
311+
ARGUMENTS:
312+
id|title Task ID (or prefix) or title
313+
314+
OPTIONS:
315+
-f, --force Delete even if the task is currently in progress
316+
--dry-run Show what would be deleted without making changes
317+
318+
GLOBAL OPTIONS:
319+
-j, --json Output as JSON
320+
-h, --help Show help
321+
322+
EXAMPLES:
323+
steroids tasks delete abc123
324+
steroids tasks delete "Build login page"
325+
steroids tasks delete abc123 --force
326+
`);
327+
return;
328+
}
329+
330+
if (positionals.length === 0) {
331+
out.error(ErrorCode.INVALID_ARGUMENTS, 'Task ID or title required');
332+
process.exit(getExitCode(ErrorCode.INVALID_ARGUMENTS));
333+
}
334+
335+
const identifier = positionals.join(' ');
336+
const projectPath = process.cwd();
337+
338+
withDatabase(projectPath, (db: any) => {
339+
const task = getTask(db, identifier) || getTaskByTitle(db, identifier);
340+
if (!task) {
341+
out.error(ErrorCode.TASK_NOT_FOUND, `Task not found: ${identifier}`);
342+
process.exit(getExitCode(ErrorCode.TASK_NOT_FOUND));
343+
}
344+
345+
if (task.status === 'in_progress' && !values.force) {
346+
out.error(ErrorCode.GENERAL_ERROR, `Task is currently in progress. Use --force to delete it anyway.`);
347+
process.exit(1);
348+
}
349+
350+
if (flags.dryRun) {
351+
out.log(`Would delete task: ${task.title} (${task.id})`);
352+
out.log(` Status: ${task.status}`);
353+
return;
354+
}
355+
356+
deleteTask(db, task.id);
357+
358+
if (flags.json) {
359+
out.success({ deleted: { id: task.id, title: task.title } });
360+
} else {
361+
out.log(`Deleted task: ${task.title} (${task.id})`);
362+
}
363+
});
364+
}

src/commands/tasks.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import {
5151
triggerProjectCompleted,
5252
triggerHooksSafely,
5353
} from '../hooks/integration.js';
54-
import { resetTaskCmd } from './tasks-reset.js';
54+
import { resetTaskCmd, deleteTaskCmd } from './tasks-reset.js';
5555
import { addTaskDependencyCmd, removeTaskDependencyCmd, displayTaskDependencies } from './tasks-deps.js';
5656
import {
5757
isFileTracked,
@@ -85,6 +85,7 @@ Use this command to add, update, approve, reject, or skip tasks.`,
8585
{ name: 'audit', args: '<id|title>', description: 'View task audit trail' },
8686
{ name: 'promote', args: '<id>', description: 'Enable auto-implementation for a deferred follow-up' },
8787
{ name: 'reset', args: '[<id|title>]', description: 'Reset failed/disputed tasks to pending' },
88+
{ name: 'delete', args: '<id|title>', description: 'Permanently delete a task' },
8889
{ name: 'depends-on', args: '<id> <dep-id>', description: 'Add task dependency (id depends on dep-id)' },
8990
{ name: 'no-depends-on', args: '<id> <dep-id>', description: 'Remove task dependency' },
9091
],
@@ -209,6 +210,9 @@ export async function tasksCommand(args: string[], flags: GlobalFlags): Promise<
209210
case 'reset':
210211
await resetTaskCmd(subArgs, flags);
211212
break;
213+
case 'delete':
214+
await deleteTaskCmd(subArgs, flags);
215+
break;
212216
case 'depends-on':
213217
await addTaskDependencyCmd(subArgs, flags);
214218
break;

src/database/queries.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,3 +2063,50 @@ export function returnTaskToPending(
20632063

20642064
addAuditEntry(db, taskId, task.status, 'pending', actor, notes ?? 'Returned to pending for retry');
20652065
}
2066+
2067+
// ── Delete operations ──────────────────────────────────────────────────────
2068+
2069+
/**
2070+
* Permanently delete a task and all its dependent records.
2071+
* Callers should check task status and warn about in-progress tasks beforehand.
2072+
*/
2073+
export function deleteTask(db: Database.Database, taskId: string): void {
2074+
db.transaction(() => {
2075+
// Nullify self-referential FKs before deleting (foreign_keys = ON)
2076+
db.prepare('UPDATE tasks SET reference_task_id = NULL WHERE reference_task_id = ?').run(taskId);
2077+
db.prepare('UPDATE incidents SET task_id = NULL WHERE task_id = ?').run(taskId);
2078+
// Remove all dependent rows
2079+
db.prepare('DELETE FROM task_dependencies WHERE task_id = ? OR depends_on_task_id = ?').run(taskId, taskId);
2080+
db.prepare('DELETE FROM task_locks WHERE task_id = ?').run(taskId);
2081+
db.prepare('DELETE FROM task_invocations WHERE task_id = ?').run(taskId);
2082+
db.prepare('DELETE FROM disputes WHERE task_id = ?').run(taskId);
2083+
db.prepare('DELETE FROM audit WHERE task_id = ?').run(taskId);
2084+
db.prepare('DELETE FROM task_feedback WHERE task_id = ?').run(taskId);
2085+
db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
2086+
})();
2087+
}
2088+
2089+
/**
2090+
* Permanently delete a section and (with force=true) all its tasks.
2091+
* Throws if the section has tasks and force is false.
2092+
* Returns the number of tasks deleted.
2093+
*/
2094+
export function deleteSection(
2095+
db: Database.Database,
2096+
sectionId: string,
2097+
{ force = false }: { force?: boolean } = {},
2098+
): number {
2099+
const tasks = db.prepare('SELECT id FROM tasks WHERE section_id = ?').all(sectionId) as { id: string }[];
2100+
if (tasks.length > 0 && !force) {
2101+
throw new Error(`Section has ${tasks.length} task(s). Use --force to delete them along with the section.`);
2102+
}
2103+
db.transaction(() => {
2104+
for (const { id } of tasks) {
2105+
deleteTask(db, id);
2106+
}
2107+
db.prepare('DELETE FROM section_locks WHERE section_id = ?').run(sectionId);
2108+
db.prepare('DELETE FROM section_dependencies WHERE section_id = ? OR depends_on_section_id = ?').run(sectionId, sectionId);
2109+
db.prepare('DELETE FROM sections WHERE id = ?').run(sectionId);
2110+
})();
2111+
return tasks.length;
2112+
}

0 commit comments

Comments
 (0)