brain-dev 2.5.2 → 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.
- package/bin/brain-tools.cjs +4 -0
- package/bin/lib/commands/remove.cjs +308 -0
- package/bin/lib/commands.cjs +9 -0
- package/commands/brain/remove.md +39 -0
- package/package.json +1 -1
package/bin/brain-tools.cjs
CHANGED
|
@@ -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 };
|
package/bin/lib/commands.cjs
CHANGED
|
@@ -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>
|