brain-dev 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -182,6 +182,10 @@ async function main() {
182
182
  await require('./lib/commands/review.cjs').run(args.slice(1));
183
183
  break;
184
184
 
185
+ case 'remove':
186
+ await require('./lib/commands/remove.cjs').run(args.slice(1));
187
+ break;
188
+
185
189
  case 'upgrade':
186
190
  await require('./lib/commands/upgrade.cjs').run(args.slice(1), {
187
191
  brainDir: path.join(process.cwd(), '.brain')
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { readState, writeState } = require('../state.cjs');
6
+ const { logEvent } = require('../logger.cjs');
7
+ const { output, error, prefix, success } = require('../core.cjs');
8
+
9
+ const VALID_TYPES = ['task', 'story', 'quick'];
10
+
11
+ /**
12
+ * Find an entity's directory and metadata by type and number.
13
+ * @param {string} brainDir
14
+ * @param {object} state
15
+ * @param {string} type - 'task' | 'story' | 'quick'
16
+ * @param {number} num
17
+ * @returns {{ dir: string, meta: object } | null}
18
+ */
19
+ function findEntity(brainDir, state, type, num) {
20
+ if (type === 'task') {
21
+ const tasksDir = path.join(brainDir, 'tasks');
22
+ if (!fs.existsSync(tasksDir)) return null;
23
+ const padded = String(num).padStart(2, '0');
24
+ const match = fs.readdirSync(tasksDir).find(d => d.startsWith(`${padded}-`));
25
+ if (!match) return null;
26
+ const dir = path.join(tasksDir, match);
27
+ const metaPath = path.join(dir, 'task.json');
28
+ let meta = { num, description: match };
29
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { /* use default */ }
30
+ return { dir, meta };
31
+ }
32
+
33
+ if (type === 'story') {
34
+ // Find dirName from state arrays
35
+ const allStories = [...(state.stories?.active || []), ...(state.stories?.history || [])];
36
+ const storyEntry = allStories.find(s => s.num === num);
37
+ if (storyEntry && storyEntry.dirName) {
38
+ const dir = path.join(brainDir, 'stories', storyEntry.dirName);
39
+ let meta = { num, title: storyEntry.title || storyEntry.dirName, dirName: storyEntry.dirName };
40
+ const metaPath = path.join(dir, 'story.json');
41
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { /* use default */ }
42
+ return { dir, meta };
43
+ }
44
+ // Fallback: scan story directories
45
+ const storiesDir = path.join(brainDir, 'stories');
46
+ if (!fs.existsSync(storiesDir)) return null;
47
+ for (const d of fs.readdirSync(storiesDir)) {
48
+ const metaPath = path.join(storiesDir, d, 'story.json');
49
+ try {
50
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
51
+ if (meta.num === num) return { dir: path.join(storiesDir, d), meta };
52
+ } catch { continue; }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ if (type === 'quick') {
58
+ const quickDir = path.join(brainDir, 'quick');
59
+ if (!fs.existsSync(quickDir)) return null;
60
+ const match = fs.readdirSync(quickDir).find(d => d.startsWith(`${num}-`));
61
+ if (!match) return null;
62
+ const dir = path.join(quickDir, match);
63
+ let meta = { num, description: match };
64
+ const metaPath = path.join(dir, 'task.json');
65
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { /* use default */ }
66
+ return { dir, meta };
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Run the remove command.
74
+ * @param {string[]} args - [type, number, --confirm, --force]
75
+ * @param {object} [opts]
76
+ */
77
+ async function run(args = [], opts = {}) {
78
+ const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
79
+ const state = readState(brainDir);
80
+
81
+ if (!state) {
82
+ error("No brain state found. Run 'brain-dev init' first.");
83
+ return { error: 'no-state' };
84
+ }
85
+
86
+ // Parse args
87
+ const type = args[0];
88
+ const numStr = args[1];
89
+ const confirm = args.includes('--confirm');
90
+ const force = args.includes('--force');
91
+
92
+ if (!type) {
93
+ error('Usage: brain-dev remove <task|story|quick> <number> [--confirm] [--force]');
94
+ return { error: 'missing-type' };
95
+ }
96
+
97
+ if (!VALID_TYPES.includes(type)) {
98
+ error(`Invalid type "${type}". Must be one of: ${VALID_TYPES.join(', ')}`);
99
+ return { error: 'invalid-type' };
100
+ }
101
+
102
+ const num = parseInt(numStr, 10);
103
+ if (!numStr || isNaN(num) || num < 1) {
104
+ error(`Invalid number "${numStr}". Must be a positive integer.`);
105
+ return { error: 'invalid-number' };
106
+ }
107
+
108
+ // Find entity
109
+ const entity = findEntity(brainDir, state, type, num);
110
+ const dirExists = entity && fs.existsSync(entity.dir);
111
+
112
+ // Also check state arrays for entries without directory
113
+ const stateEntry = findStateEntry(state, type, num);
114
+
115
+ if (!entity && !stateEntry) {
116
+ error(`${type} #${num} not found.`);
117
+ return { error: 'not-found' };
118
+ }
119
+
120
+ const meta = entity?.meta || stateEntry || { num };
121
+ // Merge promoted_to_phase from state if disk meta is missing it (corrupt task.json)
122
+ if (!meta.promoted_to_phase && stateEntry?.promoted_to_phase) {
123
+ meta.promoted_to_phase = stateEntry.promoted_to_phase;
124
+ }
125
+ const entityDir = entity?.dir || null;
126
+
127
+ // Determine if this is the current active item
128
+ const isCurrent = isCurrentItem(state, type, num, meta);
129
+
130
+ // Safety checks
131
+ const safety = checkSafety(state, type, num, meta, force);
132
+
133
+ // Preview mode (no --confirm) — show preview even if blocked
134
+ if (!confirm) {
135
+ const description = meta.description || meta.title || meta.slug || `#${num}`;
136
+ const warnings = [];
137
+ if (isCurrent) warnings.push(`This is the current active ${type}`);
138
+ if (safety.blocked) warnings.push(`BLOCKED: ${safety.message}`);
139
+ if (safety.warnings) warnings.push(...safety.warnings);
140
+
141
+ const humanLines = [
142
+ prefix(`Remove ${type} #${num}: ${description}`),
143
+ dirExists ? prefix(`Directory: ${entityDir}`) : prefix('Directory: not found on disk'),
144
+ isCurrent ? prefix('WARNING: This is the current active item') : '',
145
+ safety.blocked ? prefix(`BLOCKED: ${safety.message}`) : '',
146
+ ...warnings.filter(w => !w.startsWith('BLOCKED:')).map(w => prefix(`WARNING: ${w}`)),
147
+ '',
148
+ safety.blocked ? '' : prefix(`To confirm: brain-dev remove ${type} ${num} --confirm`),
149
+ type === 'story' && safety.blocked && safety.code === 'story-phases-active' ? prefix('To force: brain-dev remove story ' + num + ' --confirm --force') : ''
150
+ ].filter(Boolean).join('\n');
151
+
152
+ const result = {
153
+ action: 'preview-remove',
154
+ type,
155
+ num,
156
+ description,
157
+ directory: entityDir,
158
+ isCurrent,
159
+ warnings,
160
+ instructions: `brain-dev remove ${type} ${num} --confirm`
161
+ };
162
+
163
+ output(result, humanLines);
164
+ return result;
165
+ }
166
+
167
+ // Block execution if safety check fails
168
+ if (safety.blocked) {
169
+ error(safety.message);
170
+ return { error: safety.code, message: safety.message };
171
+ }
172
+
173
+ // Execute removal
174
+ return executeRemove(brainDir, state, type, num, meta, entityDir, isCurrent);
175
+ }
176
+
177
+ /**
178
+ * Find an entity in state arrays (without reading disk).
179
+ */
180
+ function findStateEntry(state, type, num) {
181
+ if (type === 'task') {
182
+ return (state.tasks?.active || []).find(t => t.num === num)
183
+ || (state.tasks?.history || []).find(t => t.num === num)
184
+ || null;
185
+ }
186
+ if (type === 'story') {
187
+ return (state.stories?.active || []).find(s => s.num === num)
188
+ || (state.stories?.history || []).find(s => s.num === num)
189
+ || null;
190
+ }
191
+ return null; // Quick tasks have no state arrays
192
+ }
193
+
194
+ /**
195
+ * Check if the item is the currently active one.
196
+ */
197
+ function isCurrentItem(state, type, num, meta) {
198
+ if (type === 'task') return state.tasks?.current === num;
199
+ if (type === 'story') return state.stories?.current === (meta.dirName || null);
200
+ return false; // Quick tasks have no "current" concept
201
+ }
202
+
203
+ /**
204
+ * Run safety checks per entity type.
205
+ * @returns {{ blocked: boolean, code?: string, message?: string, warnings?: string[] }}
206
+ */
207
+ function checkSafety(state, type, num, meta, force) {
208
+ const warnings = [];
209
+
210
+ if (type === 'task') {
211
+ if (meta.promoted_to_phase) {
212
+ return {
213
+ blocked: true,
214
+ code: 'promoted-task',
215
+ message: `Cannot remove task #${num}: promoted to Phase ${meta.promoted_to_phase}. Remove the phase first.`
216
+ };
217
+ }
218
+ }
219
+
220
+ if (type === 'story') {
221
+ const isCurrent = state.stories?.current === (meta.dirName || null);
222
+ if (isCurrent && Array.isArray(state.phase?.phases)) {
223
+ const hasActivePhases = state.phase.phases.some(p =>
224
+ p.status !== 'complete' && p.status !== 'completed');
225
+ if (hasActivePhases) {
226
+ if (!force) {
227
+ return {
228
+ blocked: true,
229
+ code: 'story-phases-active',
230
+ message: `Cannot remove story #${num}: it is the current story with non-complete phases. Use --force to override.`
231
+ };
232
+ }
233
+ warnings.push('Story has non-complete phases (forced removal)');
234
+ }
235
+ }
236
+ }
237
+
238
+ return { blocked: false, warnings };
239
+ }
240
+
241
+ /**
242
+ * Execute the actual removal.
243
+ */
244
+ function executeRemove(brainDir, state, type, num, meta, entityDir, isCurrent) {
245
+ const description = meta.description || meta.title || meta.slug || `#${num}`;
246
+
247
+ // Delete directory if it exists
248
+ if (entityDir && fs.existsSync(entityDir)) {
249
+ fs.rmSync(entityDir, { recursive: true, force: true });
250
+ }
251
+
252
+ // Update state per type
253
+ if (type === 'task') {
254
+ if (state.tasks) {
255
+ state.tasks.active = (state.tasks.active || []).filter(t => t.num !== num);
256
+ state.tasks.history = (state.tasks.history || []).filter(t => t.num !== num);
257
+ if (state.tasks.current === num) state.tasks.current = null;
258
+ }
259
+ } else if (type === 'story') {
260
+ if (state.stories) {
261
+ const dirName = meta.dirName || null;
262
+ state.stories.active = (state.stories.active || []).filter(s => s.num !== num);
263
+ state.stories.history = (state.stories.history || []).filter(s => s.num !== num);
264
+ if (state.stories.current === dirName) {
265
+ state.stories.current = null;
266
+ // Reset phase state owned by the removed story
267
+ if (state.phase) {
268
+ state.phase.current = 0;
269
+ state.phase.status = 'initialized';
270
+ state.phase.total = 0;
271
+ state.phase.phases = [];
272
+ state.phase.execution_started_at = null;
273
+ state.phase.stuck_count = 0;
274
+ state.phase.last_stuck_at = null;
275
+ }
276
+ }
277
+ }
278
+ }
279
+ // Quick tasks: no state arrays to update (count is never decremented)
280
+
281
+ writeState(brainDir, state);
282
+
283
+ logEvent(brainDir, 0, {
284
+ type: `${type}-removed`,
285
+ num,
286
+ description,
287
+ directory: entityDir ? path.basename(entityDir) : null
288
+ });
289
+
290
+ const humanLines = [
291
+ prefix(`Removed ${type} #${num}: ${description}`),
292
+ entityDir ? prefix(`Directory deleted: ${path.basename(entityDir)}`) : prefix('No directory to delete'),
293
+ isCurrent ? prefix('Cleared as current active item') : ''
294
+ ].filter(Boolean).join('\n');
295
+
296
+ const result = {
297
+ action: 'removed',
298
+ type,
299
+ num,
300
+ description,
301
+ directory: entityDir
302
+ };
303
+
304
+ output(result, humanLines);
305
+ return result;
306
+ }
307
+
308
+ module.exports = { run };
@@ -913,7 +913,6 @@ function stepActivate(brainDir, state, storyDir, storyMeta) {
913
913
  // Update state phases
914
914
  state.phase = state.phase || { current: 0, status: 'initialized', total: 0, phases: [] };
915
915
  state.phase.current = 1;
916
- syncPhaseStatus(state, 'ready');
917
916
  state.phase.total = roadmapData.phases.length;
918
917
  state.phase.phases = roadmapData.phases.map(p => ({
919
918
  number: p.number,
@@ -921,7 +920,8 @@ function stepActivate(brainDir, state, storyDir, storyMeta) {
921
920
  status: p.status === 'Pending' ? 'pending' : p.status.toLowerCase(),
922
921
  goal: p.goal
923
922
  }));
924
- state.phase.execution_started_at = null;
923
+ // Sync AFTER phases array is rebuilt so per-phase status is correctly updated
924
+ syncPhaseStatus(state, 'ready');
925
925
  state.phase.stuck_count = 0;
926
926
  state.phase.last_stuck_at = null;
927
927
 
@@ -119,6 +119,15 @@ const COMMANDS = [
119
119
  needsState: true,
120
120
  args: ' --phase <n> Phase to mark complete'
121
121
  },
122
+ {
123
+ name: 'remove',
124
+ description: 'Remove/cancel a task, story, or quick task',
125
+ usage: 'brain-dev remove <task|story|quick> <number> [--confirm] [--force]',
126
+ group: 'Lifecycle',
127
+ implemented: true,
128
+ needsState: true,
129
+ args: ' <task|story|quick> Entity type\n <number> Entity number\n --confirm Execute removal\n --force Override safety checks'
130
+ },
122
131
  {
123
132
  name: 'map',
124
133
  description: 'Map codebase structure and patterns',
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: brain:remove
3
+ description: Remove/cancel a task, story, or quick task by number
4
+ argument-hint: "<task|story|quick> <number>"
5
+ allowed-tools:
6
+ - Read
7
+ - Bash
8
+ - AskUserQuestion
9
+ ---
10
+ <objective>
11
+ Remove a task, story, or quick task that is no longer needed. Supports preview before deletion and safety checks.
12
+ </objective>
13
+
14
+ <context>
15
+ Usage:
16
+ - `npx brain-dev remove task 3` — preview what will be removed
17
+ - `npx brain-dev remove task 3 --confirm` — execute removal
18
+ - `npx brain-dev remove story 1 --confirm --force` — force remove story with active phases
19
+ </context>
20
+
21
+ <critical-rules>
22
+ ## PIPELINE ENFORCEMENT (NON-NEGOTIABLE):
23
+ 1. Run `npx brain-dev remove $ARGUMENTS` FIRST (preview mode)
24
+ 2. READ the full output — check warnings
25
+ 3. Ask the user for confirmation using AskUserQuestion
26
+ 4. Run `npx brain-dev remove $ARGUMENTS --confirm` to execute
27
+ 5. Removal is PERMANENT — directory and all artifacts are deleted
28
+ 6. NEVER bypass the preview step
29
+ 7. If output shows ERROR (promoted task, active phases): explain to user and do NOT retry with --confirm
30
+ </critical-rules>
31
+
32
+ <process>
33
+ 1. If user didn't specify type or number, ask using AskUserQuestion
34
+ 2. Run preview: `npx brain-dev remove <type> <number>`
35
+ 3. Show the user what will be deleted and any warnings
36
+ 4. Ask for confirmation
37
+ 5. Run: `npx brain-dev remove <type> <number> --confirm`
38
+ 6. For stories with active phases: add `--force` only if user explicitly agrees
39
+ </process>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brain-dev",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "AI-powered development workflow orchestrator",
5
5
  "author": "halilcosdu",
6
6
  "license": "MIT",