@hyperdrive.bot/bmad-workflow 1.0.2
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/LICENSE +21 -0
- package/README.md +1017 -0
- package/bin/dev +5 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/dist/commands/config/show.d.ts +34 -0
- package/dist/commands/config/show.js +108 -0
- package/dist/commands/config/validate.d.ts +29 -0
- package/dist/commands/config/validate.js +131 -0
- package/dist/commands/decompose.d.ts +79 -0
- package/dist/commands/decompose.js +327 -0
- package/dist/commands/demo.d.ts +18 -0
- package/dist/commands/demo.js +107 -0
- package/dist/commands/epics/create.d.ts +123 -0
- package/dist/commands/epics/create.js +459 -0
- package/dist/commands/epics/list.d.ts +120 -0
- package/dist/commands/epics/list.js +280 -0
- package/dist/commands/hello/index.d.ts +12 -0
- package/dist/commands/hello/index.js +34 -0
- package/dist/commands/hello/world.d.ts +8 -0
- package/dist/commands/hello/world.js +24 -0
- package/dist/commands/prd/fix.d.ts +39 -0
- package/dist/commands/prd/fix.js +140 -0
- package/dist/commands/prd/validate.d.ts +112 -0
- package/dist/commands/prd/validate.js +302 -0
- package/dist/commands/stories/create.d.ts +95 -0
- package/dist/commands/stories/create.js +431 -0
- package/dist/commands/stories/develop.d.ts +91 -0
- package/dist/commands/stories/develop.js +460 -0
- package/dist/commands/stories/list.d.ts +84 -0
- package/dist/commands/stories/list.js +291 -0
- package/dist/commands/stories/move.d.ts +66 -0
- package/dist/commands/stories/move.js +273 -0
- package/dist/commands/stories/qa.d.ts +99 -0
- package/dist/commands/stories/qa.js +530 -0
- package/dist/commands/workflow.d.ts +97 -0
- package/dist/commands/workflow.js +390 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/models/agent-options.d.ts +50 -0
- package/dist/models/agent-options.js +1 -0
- package/dist/models/agent-result.d.ts +29 -0
- package/dist/models/agent-result.js +1 -0
- package/dist/models/index.d.ts +10 -0
- package/dist/models/index.js +10 -0
- package/dist/models/phase-result.d.ts +65 -0
- package/dist/models/phase-result.js +7 -0
- package/dist/models/provider.d.ts +28 -0
- package/dist/models/provider.js +18 -0
- package/dist/models/story.d.ts +154 -0
- package/dist/models/story.js +18 -0
- package/dist/models/workflow-config.d.ts +148 -0
- package/dist/models/workflow-config.js +1 -0
- package/dist/models/workflow-result.d.ts +164 -0
- package/dist/models/workflow-result.js +7 -0
- package/dist/services/agents/agent-runner-factory.d.ts +31 -0
- package/dist/services/agents/agent-runner-factory.js +44 -0
- package/dist/services/agents/agent-runner.d.ts +46 -0
- package/dist/services/agents/agent-runner.js +29 -0
- package/dist/services/agents/claude-agent-runner.d.ts +81 -0
- package/dist/services/agents/claude-agent-runner.js +332 -0
- package/dist/services/agents/gemini-agent-runner.d.ts +82 -0
- package/dist/services/agents/gemini-agent-runner.js +350 -0
- package/dist/services/agents/index.d.ts +7 -0
- package/dist/services/agents/index.js +7 -0
- package/dist/services/file-system/file-manager.d.ts +110 -0
- package/dist/services/file-system/file-manager.js +223 -0
- package/dist/services/file-system/glob-matcher.d.ts +75 -0
- package/dist/services/file-system/glob-matcher.js +126 -0
- package/dist/services/file-system/path-resolver.d.ts +183 -0
- package/dist/services/file-system/path-resolver.js +400 -0
- package/dist/services/logging/workflow-logger.d.ts +232 -0
- package/dist/services/logging/workflow-logger.js +552 -0
- package/dist/services/orchestration/batch-processor.d.ts +113 -0
- package/dist/services/orchestration/batch-processor.js +187 -0
- package/dist/services/orchestration/dependency-graph-executor.d.ts +60 -0
- package/dist/services/orchestration/dependency-graph-executor.js +447 -0
- package/dist/services/orchestration/index.d.ts +10 -0
- package/dist/services/orchestration/index.js +8 -0
- package/dist/services/orchestration/input-detector.d.ts +125 -0
- package/dist/services/orchestration/input-detector.js +381 -0
- package/dist/services/orchestration/story-queue.d.ts +94 -0
- package/dist/services/orchestration/story-queue.js +170 -0
- package/dist/services/orchestration/story-type-detector.d.ts +80 -0
- package/dist/services/orchestration/story-type-detector.js +258 -0
- package/dist/services/orchestration/task-decomposition-service.d.ts +67 -0
- package/dist/services/orchestration/task-decomposition-service.js +607 -0
- package/dist/services/orchestration/workflow-orchestrator.d.ts +659 -0
- package/dist/services/orchestration/workflow-orchestrator.js +2201 -0
- package/dist/services/parsers/epic-parser.d.ts +117 -0
- package/dist/services/parsers/epic-parser.js +264 -0
- package/dist/services/parsers/prd-fixer.d.ts +86 -0
- package/dist/services/parsers/prd-fixer.js +194 -0
- package/dist/services/parsers/prd-parser.d.ts +123 -0
- package/dist/services/parsers/prd-parser.js +286 -0
- package/dist/services/parsers/standalone-story-parser.d.ts +114 -0
- package/dist/services/parsers/standalone-story-parser.js +255 -0
- package/dist/services/parsers/story-parser-factory.d.ts +81 -0
- package/dist/services/parsers/story-parser-factory.js +108 -0
- package/dist/services/parsers/story-parser.d.ts +122 -0
- package/dist/services/parsers/story-parser.js +262 -0
- package/dist/services/scaffolding/decompose-session-scaffolder.d.ts +74 -0
- package/dist/services/scaffolding/decompose-session-scaffolder.js +315 -0
- package/dist/services/scaffolding/file-scaffolder.d.ts +94 -0
- package/dist/services/scaffolding/file-scaffolder.js +314 -0
- package/dist/services/validation/config-validator.d.ts +88 -0
- package/dist/services/validation/config-validator.js +167 -0
- package/dist/types/task-graph.d.ts +142 -0
- package/dist/types/task-graph.js +5 -0
- package/dist/utils/colors.d.ts +49 -0
- package/dist/utils/colors.js +50 -0
- package/dist/utils/error-formatter.d.ts +64 -0
- package/dist/utils/error-formatter.js +279 -0
- package/dist/utils/errors.d.ts +170 -0
- package/dist/utils/errors.js +233 -0
- package/dist/utils/formatters.d.ts +84 -0
- package/dist/utils/formatters.js +162 -0
- package/dist/utils/logger.d.ts +63 -0
- package/dist/utils/logger.js +78 -0
- package/dist/utils/progress.d.ts +104 -0
- package/dist/utils/progress.js +161 -0
- package/dist/utils/retry.d.ts +114 -0
- package/dist/utils/retry.js +160 -0
- package/dist/utils/shared-flags.d.ts +28 -0
- package/dist/utils/shared-flags.js +43 -0
- package/package.json +119 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stories List Command
|
|
3
|
+
*
|
|
4
|
+
* Lists all stories with filtering by status and epic number.
|
|
5
|
+
* Displays results in table format or JSON format with counts summary.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```bash
|
|
9
|
+
* bmad-workflow stories list
|
|
10
|
+
* bmad-workflow stories list --status=draft
|
|
11
|
+
* bmad-workflow stories list --epic=4
|
|
12
|
+
* bmad-workflow stories list --status=ready --epic=4 --json
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import { Command, Flags } from '@oclif/core';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { isEpicStory } from '../../models/story.js';
|
|
18
|
+
import { FileManager } from '../../services/file-system/file-manager.js';
|
|
19
|
+
import { PathResolver } from '../../services/file-system/path-resolver.js';
|
|
20
|
+
import { StoryParserFactory } from '../../services/parsers/story-parser-factory.js';
|
|
21
|
+
import { FileSystemError } from '../../utils/errors.js';
|
|
22
|
+
import { createLogger } from '../../utils/logger.js';
|
|
23
|
+
/**
|
|
24
|
+
* Stories List Command
|
|
25
|
+
*
|
|
26
|
+
* Lists all stories with optional filtering by status and epic number.
|
|
27
|
+
*/
|
|
28
|
+
export default class StoriesListCommand extends Command {
|
|
29
|
+
static description = 'List all stories with optional filtering by status and epic';
|
|
30
|
+
static examples = [
|
|
31
|
+
{
|
|
32
|
+
command: '<%= config.bin %> <%= command.id %>',
|
|
33
|
+
description: 'List all stories',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
command: '<%= config.bin %> <%= command.id %> --status=draft',
|
|
37
|
+
description: 'List only draft stories',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
command: '<%= config.bin %> <%= command.id %> --epic=4',
|
|
41
|
+
description: 'List stories from epic 4',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
command: '<%= config.bin %> <%= command.id %> --status=ready --epic=4 --json',
|
|
45
|
+
description: 'List ready stories from epic 4 as JSON',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
static flags = {
|
|
49
|
+
epic: Flags.integer({
|
|
50
|
+
description: 'Filter by epic number',
|
|
51
|
+
}),
|
|
52
|
+
json: Flags.boolean({
|
|
53
|
+
description: 'Output results as JSON',
|
|
54
|
+
}),
|
|
55
|
+
status: Flags.string({
|
|
56
|
+
description: 'Filter by story status (draft, ready, done)',
|
|
57
|
+
options: ['draft', 'ready', 'done', 'inprogress', 'failed'],
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
// Service instances
|
|
61
|
+
fileManager;
|
|
62
|
+
logger;
|
|
63
|
+
pathResolver;
|
|
64
|
+
storyParserFactory;
|
|
65
|
+
/**
|
|
66
|
+
* Run command
|
|
67
|
+
*/
|
|
68
|
+
async run() {
|
|
69
|
+
const { flags } = await this.parse(StoriesListCommand);
|
|
70
|
+
// Initialize services
|
|
71
|
+
this.initServices();
|
|
72
|
+
this.logger.info('Listing stories with filters: status=%s, epic=%s', flags.status, flags.epic);
|
|
73
|
+
try {
|
|
74
|
+
// Get story directory from PathResolver
|
|
75
|
+
const storyDir = this.pathResolver.getStoryDir();
|
|
76
|
+
this.logger.debug('Story directory: %s', storyDir);
|
|
77
|
+
// Read directory contents
|
|
78
|
+
const files = await this.fileManager.listFiles(storyDir, '*.md');
|
|
79
|
+
this.logger.info('Found %d markdown files in story directory', files.length);
|
|
80
|
+
// Parse story metadata from each file
|
|
81
|
+
const stories = await this.parseStories(files);
|
|
82
|
+
this.logger.info('Successfully parsed %d stories', stories.length);
|
|
83
|
+
// Apply filters
|
|
84
|
+
let filtered = this.applyFilters(stories, flags);
|
|
85
|
+
// Sort by story number
|
|
86
|
+
filtered = this.sortStories(filtered);
|
|
87
|
+
this.logger.info('After filtering and sorting: %d stories', filtered.length);
|
|
88
|
+
// Output results
|
|
89
|
+
if (flags.json) {
|
|
90
|
+
this.outputJson(filtered);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
this.outputTable(filtered);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
this.handleError(error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Apply filters to stories
|
|
102
|
+
*/
|
|
103
|
+
applyFilters(stories, flags) {
|
|
104
|
+
let filtered = stories;
|
|
105
|
+
// Filter by status
|
|
106
|
+
if (flags.status) {
|
|
107
|
+
filtered = filtered.filter((s) => s.status.toLowerCase() === flags.status.toLowerCase());
|
|
108
|
+
this.logger.debug('After status filter (%s): %d stories', flags.status, filtered.length);
|
|
109
|
+
}
|
|
110
|
+
// Filter by epic number (only applies to epic-based stories)
|
|
111
|
+
if (flags.epic !== undefined) {
|
|
112
|
+
filtered = filtered.filter((s) => isEpicStory(s) && s.epicNumber === flags.epic);
|
|
113
|
+
this.logger.debug('After epic filter (%d): %d stories', flags.epic, filtered.length);
|
|
114
|
+
}
|
|
115
|
+
return filtered;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Display count summary
|
|
119
|
+
*/
|
|
120
|
+
displaySummary(stories) {
|
|
121
|
+
// Count by status
|
|
122
|
+
const statusCounts = {};
|
|
123
|
+
for (const story of stories) {
|
|
124
|
+
const status = story.status.toLowerCase();
|
|
125
|
+
statusCounts[status] = (statusCounts[status] || 0) + 1;
|
|
126
|
+
}
|
|
127
|
+
// Build summary message
|
|
128
|
+
const summaryParts = Object.entries(statusCounts)
|
|
129
|
+
.map(([status, count]) => `${count} ${status}`)
|
|
130
|
+
.join(', ');
|
|
131
|
+
this.log('');
|
|
132
|
+
this.log(chalk.white(`Found ${stories.length} stories: ${summaryParts}`));
|
|
133
|
+
this.log('');
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Get color for status
|
|
137
|
+
*/
|
|
138
|
+
getStatusColor(status) {
|
|
139
|
+
switch (status.toLowerCase()) {
|
|
140
|
+
case 'done': {
|
|
141
|
+
return 'green';
|
|
142
|
+
}
|
|
143
|
+
case 'draft': {
|
|
144
|
+
return 'yellow';
|
|
145
|
+
}
|
|
146
|
+
case 'failed': {
|
|
147
|
+
return 'red';
|
|
148
|
+
}
|
|
149
|
+
case 'ready': {
|
|
150
|
+
return 'blue';
|
|
151
|
+
}
|
|
152
|
+
default: {
|
|
153
|
+
return 'gray';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Handle errors with user-friendly messages
|
|
159
|
+
*/
|
|
160
|
+
handleError(error) {
|
|
161
|
+
this.logger.error('Error listing stories: %O', error);
|
|
162
|
+
if (error instanceof FileSystemError) {
|
|
163
|
+
const fsError = error;
|
|
164
|
+
if (error.message.includes('does not exist')) {
|
|
165
|
+
this.error(`Story directory not found: ${fsError.context?.dirPath || 'unknown'}\n` +
|
|
166
|
+
'Check your .bmad-core/core-config.yaml configuration.', { exit: 1 });
|
|
167
|
+
}
|
|
168
|
+
if (error.message.includes('permission denied')) {
|
|
169
|
+
this.error(`Permission denied reading story directory: ${fsError.context?.dirPath || 'unknown'}\n` +
|
|
170
|
+
'Check file permissions and try again.', { exit: 1 });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this.error(`Failed to list stories: ${error.message}`, { exit: 1 });
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Initialize services
|
|
177
|
+
*/
|
|
178
|
+
initServices() {
|
|
179
|
+
this.logger = createLogger({ namespace: 'commands:stories:list' });
|
|
180
|
+
this.fileManager = new FileManager(this.logger);
|
|
181
|
+
this.pathResolver = new PathResolver(this.fileManager, this.logger);
|
|
182
|
+
this.storyParserFactory = new StoryParserFactory(this.fileManager, this.logger);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Output stories as JSON
|
|
186
|
+
*/
|
|
187
|
+
outputJson(stories) {
|
|
188
|
+
const output = stories.map((story) => {
|
|
189
|
+
const base = {
|
|
190
|
+
filePath: story.filePath,
|
|
191
|
+
id: story.id,
|
|
192
|
+
status: story.status,
|
|
193
|
+
title: story.title,
|
|
194
|
+
type: story.type,
|
|
195
|
+
};
|
|
196
|
+
if (isEpicStory(story)) {
|
|
197
|
+
return {
|
|
198
|
+
...base,
|
|
199
|
+
epic: story.epicNumber,
|
|
200
|
+
number: story.number,
|
|
201
|
+
storyNumber: story.storyNumber,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
category: story.category,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
this.log(JSON.stringify(output, null, 2));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Output stories as table
|
|
213
|
+
*/
|
|
214
|
+
outputTable(stories) {
|
|
215
|
+
if (stories.length === 0) {
|
|
216
|
+
this.log(chalk.yellow('No stories found matching criteria'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Create simple table
|
|
220
|
+
this.log('');
|
|
221
|
+
this.log(chalk.cyan.bold('Story ID'.padEnd(30)) +
|
|
222
|
+
chalk.cyan.bold('Title'.padEnd(45)) +
|
|
223
|
+
chalk.cyan.bold('Status'.padEnd(15)) +
|
|
224
|
+
chalk.cyan.bold('Type'));
|
|
225
|
+
this.log(chalk.gray('─'.repeat(100)));
|
|
226
|
+
// Add rows
|
|
227
|
+
for (const story of stories) {
|
|
228
|
+
const statusColor = this.getStatusColor(story.status);
|
|
229
|
+
const truncatedTitle = this.truncate(story.title, 43);
|
|
230
|
+
const storyId = isEpicStory(story) ? story.number : story.id;
|
|
231
|
+
const typeLabel = isEpicStory(story) ? `Epic ${story.epicNumber}` : story.category || 'Standalone';
|
|
232
|
+
this.log(storyId.padEnd(30) +
|
|
233
|
+
truncatedTitle.padEnd(45) +
|
|
234
|
+
chalk[statusColor](story.status.padEnd(15)) +
|
|
235
|
+
chalk.gray(typeLabel));
|
|
236
|
+
}
|
|
237
|
+
// Display summary
|
|
238
|
+
this.displaySummary(stories);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Parse stories from file paths
|
|
242
|
+
*/
|
|
243
|
+
async parseStories(files) {
|
|
244
|
+
const stories = [];
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
try {
|
|
247
|
+
// Sequential parsing to maintain order and reduce memory usage
|
|
248
|
+
// eslint-disable-next-line no-await-in-loop
|
|
249
|
+
const metadata = await this.storyParserFactory.parseStory(file);
|
|
250
|
+
stories.push(metadata);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
this.logger.warn({ err: error, file }, 'Failed to parse story');
|
|
254
|
+
// Continue processing other files
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return stories;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Sort stories by type and ID
|
|
261
|
+
*
|
|
262
|
+
* Epic stories are sorted by epic.story number ascending.
|
|
263
|
+
* Standalone stories are sorted alphabetically by ID.
|
|
264
|
+
* Epic stories appear before standalone stories.
|
|
265
|
+
*/
|
|
266
|
+
sortStories(stories) {
|
|
267
|
+
return stories.sort((a, b) => {
|
|
268
|
+
// Epic stories come before standalone stories
|
|
269
|
+
if (isEpicStory(a) && !isEpicStory(b))
|
|
270
|
+
return -1;
|
|
271
|
+
if (!isEpicStory(a) && isEpicStory(b))
|
|
272
|
+
return 1;
|
|
273
|
+
// Both epic stories: sort by epic.story number
|
|
274
|
+
if (isEpicStory(a) && isEpicStory(b)) {
|
|
275
|
+
// Compare epic numbers first
|
|
276
|
+
if (a.epicNumber !== b.epicNumber)
|
|
277
|
+
return a.epicNumber - b.epicNumber;
|
|
278
|
+
// Then compare story numbers
|
|
279
|
+
return a.storyNumber - b.storyNumber;
|
|
280
|
+
}
|
|
281
|
+
// Both standalone: sort alphabetically by ID
|
|
282
|
+
return a.id.localeCompare(b.id);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Truncate string to max length
|
|
287
|
+
*/
|
|
288
|
+
truncate(str, maxLength) {
|
|
289
|
+
return str.length > maxLength ? str.slice(0, maxLength - 3) + '...' : str;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stories Move Command
|
|
3
|
+
*
|
|
4
|
+
* Moves story files from active stories directory to QA directory.
|
|
5
|
+
* Supports glob patterns for selecting multiple files and requires confirmation
|
|
6
|
+
* unless --force flag is used.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```bash
|
|
10
|
+
* bmad-workflow stories move "4.7-*.md"
|
|
11
|
+
* bmad-workflow stories move "4.*-*.md" --force
|
|
12
|
+
* bmad-workflow stories move "*.md"
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import { Command } from '@oclif/core';
|
|
16
|
+
/**
|
|
17
|
+
* Stories Move Command
|
|
18
|
+
*
|
|
19
|
+
* Moves story files from active directory (docs/stories) to QA directory (docs/qa/stories).
|
|
20
|
+
* Provides confirmation before moving (unless --force flag is used) and displays progress.
|
|
21
|
+
*/
|
|
22
|
+
export default class StoriesMoveCommand extends Command {
|
|
23
|
+
static args: {
|
|
24
|
+
pattern: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
25
|
+
};
|
|
26
|
+
static description: string;
|
|
27
|
+
static examples: {
|
|
28
|
+
command: string;
|
|
29
|
+
description: string;
|
|
30
|
+
}[];
|
|
31
|
+
static flags: {
|
|
32
|
+
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
33
|
+
};
|
|
34
|
+
private fileManager;
|
|
35
|
+
private globMatcher;
|
|
36
|
+
private logger;
|
|
37
|
+
private pathResolver;
|
|
38
|
+
/**
|
|
39
|
+
* Run command
|
|
40
|
+
*/
|
|
41
|
+
run(): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Confirm move operation with user
|
|
44
|
+
*/
|
|
45
|
+
private confirmMove;
|
|
46
|
+
/**
|
|
47
|
+
* Display list of files to be moved
|
|
48
|
+
*/
|
|
49
|
+
private displayFileList;
|
|
50
|
+
/**
|
|
51
|
+
* Display summary of move operation
|
|
52
|
+
*/
|
|
53
|
+
private displaySummary;
|
|
54
|
+
/**
|
|
55
|
+
* Handle errors with user-friendly messages
|
|
56
|
+
*/
|
|
57
|
+
private handleError;
|
|
58
|
+
/**
|
|
59
|
+
* Initialize services
|
|
60
|
+
*/
|
|
61
|
+
private initServices;
|
|
62
|
+
/**
|
|
63
|
+
* Move files from source to QA directory
|
|
64
|
+
*/
|
|
65
|
+
private moveFiles;
|
|
66
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stories Move Command
|
|
3
|
+
*
|
|
4
|
+
* Moves story files from active stories directory to QA directory.
|
|
5
|
+
* Supports glob patterns for selecting multiple files and requires confirmation
|
|
6
|
+
* unless --force flag is used.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```bash
|
|
10
|
+
* bmad-workflow stories move "4.7-*.md"
|
|
11
|
+
* bmad-workflow stories move "4.*-*.md" --force
|
|
12
|
+
* bmad-workflow stories move "*.md"
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import * as readline from 'node:readline';
|
|
19
|
+
import ora from 'ora';
|
|
20
|
+
import { FileManager } from '../../services/file-system/file-manager.js';
|
|
21
|
+
import { GlobMatcher } from '../../services/file-system/glob-matcher.js';
|
|
22
|
+
import { PathResolver } from '../../services/file-system/path-resolver.js';
|
|
23
|
+
import { FileSystemError } from '../../utils/errors.js';
|
|
24
|
+
import { createLogger } from '../../utils/logger.js';
|
|
25
|
+
/**
|
|
26
|
+
* Stories Move Command
|
|
27
|
+
*
|
|
28
|
+
* Moves story files from active directory (docs/stories) to QA directory (docs/qa/stories).
|
|
29
|
+
* Provides confirmation before moving (unless --force flag is used) and displays progress.
|
|
30
|
+
*/
|
|
31
|
+
export default class StoriesMoveCommand extends Command {
|
|
32
|
+
static args = {
|
|
33
|
+
pattern: Args.string({
|
|
34
|
+
description: 'Glob pattern to match story files (e.g., "4.7-*.md", "*.md")',
|
|
35
|
+
required: true,
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
static description = 'Move story files from active to QA directory';
|
|
39
|
+
static examples = [
|
|
40
|
+
{
|
|
41
|
+
command: '<%= config.bin %> <%= command.id %> "4.7-*.md"',
|
|
42
|
+
description: 'Move all stories matching pattern 4.7-*.md',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
command: '<%= config.bin %> <%= command.id %> "4.*-*.md" --force',
|
|
46
|
+
description: 'Move all epic 4 stories without confirmation',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
command: '<%= config.bin %> <%= command.id %> "STORY-123-*.md"',
|
|
50
|
+
description: 'Move specific story file',
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
static flags = {
|
|
54
|
+
force: Flags.boolean({
|
|
55
|
+
char: 'f',
|
|
56
|
+
default: false,
|
|
57
|
+
description: 'Skip confirmation prompt',
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
// Service instances
|
|
61
|
+
fileManager;
|
|
62
|
+
globMatcher;
|
|
63
|
+
logger;
|
|
64
|
+
pathResolver;
|
|
65
|
+
/**
|
|
66
|
+
* Run command
|
|
67
|
+
*/
|
|
68
|
+
async run() {
|
|
69
|
+
const { args, flags } = await this.parse(StoriesMoveCommand);
|
|
70
|
+
// Initialize services
|
|
71
|
+
this.initServices();
|
|
72
|
+
this.logger.info('Moving stories with pattern: %s, force: %s', args.pattern, flags.force);
|
|
73
|
+
try {
|
|
74
|
+
// Get directories from PathResolver
|
|
75
|
+
const storyDir = this.pathResolver.getStoryDir();
|
|
76
|
+
const qaStoryDir = this.pathResolver.getQaStoryDir();
|
|
77
|
+
this.logger.debug('Source directory: %s', storyDir);
|
|
78
|
+
this.logger.debug('Destination directory: %s', qaStoryDir);
|
|
79
|
+
// Expand pattern to find matching files
|
|
80
|
+
const fullPattern = path.join(storyDir, args.pattern);
|
|
81
|
+
const matchedFiles = await this.globMatcher.expandPattern(fullPattern);
|
|
82
|
+
// Filter to only include files in the stories directory
|
|
83
|
+
const filteredFiles = matchedFiles.filter((file) => file.startsWith(storyDir));
|
|
84
|
+
this.logger.info('Found %d files matching pattern', filteredFiles.length);
|
|
85
|
+
if (filteredFiles.length === 0) {
|
|
86
|
+
this.log(chalk.yellow(`No stories found matching pattern: ${args.pattern}`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Display list of files to be moved
|
|
90
|
+
this.displayFileList(filteredFiles, storyDir);
|
|
91
|
+
// Get confirmation unless --force flag is set
|
|
92
|
+
if (flags.force) {
|
|
93
|
+
this.logger.info('Skipping confirmation due to --force flag');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const confirmed = await this.confirmMove(filteredFiles.length);
|
|
97
|
+
if (!confirmed) {
|
|
98
|
+
this.log(chalk.yellow('Operation cancelled'));
|
|
99
|
+
this.logger.info('User cancelled operation');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Move files
|
|
104
|
+
const results = await this.moveFiles(filteredFiles, storyDir, qaStoryDir);
|
|
105
|
+
// Display summary
|
|
106
|
+
this.displaySummary(results);
|
|
107
|
+
// Exit with appropriate code
|
|
108
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
109
|
+
if (failed > 0) {
|
|
110
|
+
this.logger.error('Move operation completed with %d failures', failed);
|
|
111
|
+
this.exit(1);
|
|
112
|
+
}
|
|
113
|
+
this.logger.info('Move operation completed successfully');
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
this.handleError(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Confirm move operation with user
|
|
121
|
+
*/
|
|
122
|
+
async confirmMove(fileCount) {
|
|
123
|
+
this.log(''); // Blank line for spacing
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const rl = readline.createInterface({
|
|
126
|
+
input: process.stdin,
|
|
127
|
+
output: process.stdout,
|
|
128
|
+
});
|
|
129
|
+
rl.question(chalk.yellow(`Move ${fileCount} file${fileCount === 1 ? '' : 's'} to QA directory? (Y/N) `), (answer) => {
|
|
130
|
+
rl.close();
|
|
131
|
+
const normalized = answer.trim().toLowerCase();
|
|
132
|
+
resolve(normalized === 'y' || normalized === 'yes');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Display list of files to be moved
|
|
138
|
+
*/
|
|
139
|
+
displayFileList(files, baseDir) {
|
|
140
|
+
this.log('');
|
|
141
|
+
this.log(chalk.cyan.bold('Files to be moved:'));
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
const relativePath = path.relative(baseDir, file);
|
|
144
|
+
this.log(chalk.white(` - ${relativePath}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Display summary of move operation
|
|
149
|
+
*/
|
|
150
|
+
displaySummary(results) {
|
|
151
|
+
const moved = results.filter((r) => r.status === 'moved').length;
|
|
152
|
+
const skipped = results.filter((r) => r.status === 'skipped').length;
|
|
153
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
154
|
+
this.log('');
|
|
155
|
+
this.log(chalk.white('─'.repeat(60)));
|
|
156
|
+
this.log(chalk.bold('Move Summary:'));
|
|
157
|
+
if (moved > 0) {
|
|
158
|
+
this.log(chalk.green(` ✓ Moved: ${moved} file${moved === 1 ? '' : 's'}`));
|
|
159
|
+
}
|
|
160
|
+
if (skipped > 0) {
|
|
161
|
+
this.log(chalk.yellow(` ⚠ Skipped: ${skipped} file${skipped === 1 ? '' : 's'}`));
|
|
162
|
+
}
|
|
163
|
+
if (failed > 0) {
|
|
164
|
+
this.log(chalk.red(` ✗ Failed: ${failed} file${failed === 1 ? '' : 's'}`));
|
|
165
|
+
}
|
|
166
|
+
this.log(chalk.white('─'.repeat(60)));
|
|
167
|
+
this.log('');
|
|
168
|
+
// Log skipped files with reasons
|
|
169
|
+
const skippedResults = results.filter((r) => r.status === 'skipped');
|
|
170
|
+
if (skippedResults.length > 0) {
|
|
171
|
+
this.log(chalk.yellow.bold('Skipped files:'));
|
|
172
|
+
for (const result of skippedResults) {
|
|
173
|
+
this.log(chalk.yellow(` ${path.basename(result.source)}: ${result.reason || 'Unknown reason'}`));
|
|
174
|
+
}
|
|
175
|
+
this.log('');
|
|
176
|
+
}
|
|
177
|
+
// Log failed files with errors
|
|
178
|
+
const failedResults = results.filter((r) => r.status === 'failed');
|
|
179
|
+
if (failedResults.length > 0) {
|
|
180
|
+
this.log(chalk.red.bold('Failed files:'));
|
|
181
|
+
for (const result of failedResults) {
|
|
182
|
+
this.log(chalk.red(` ${path.basename(result.source)}: ${result.error || 'Unknown error'}`));
|
|
183
|
+
}
|
|
184
|
+
this.log('');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Handle errors with user-friendly messages
|
|
189
|
+
*/
|
|
190
|
+
handleError(error) {
|
|
191
|
+
this.logger.error('Error moving stories: %O', error);
|
|
192
|
+
if (error instanceof FileSystemError) {
|
|
193
|
+
const fsError = error;
|
|
194
|
+
if (error.message.includes('does not exist')) {
|
|
195
|
+
this.error(`Directory not found: ${fsError.context?.dirPath || 'unknown'}\n` +
|
|
196
|
+
'Check your .bmad-core/core-config.yaml configuration.', { exit: 1 });
|
|
197
|
+
}
|
|
198
|
+
if (error.message.includes('permission denied')) {
|
|
199
|
+
this.error(`Permission denied: ${fsError.context?.dirPath || 'unknown'}\nCheck file permissions and try again.`, { exit: 1 });
|
|
200
|
+
}
|
|
201
|
+
if (error.message.includes('Glob pattern cannot be empty')) {
|
|
202
|
+
this.error('Pattern cannot be empty. Please provide a valid glob pattern.', { exit: 1 });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
this.error(`Failed to move stories: ${error.message}`, { exit: 1 });
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Initialize services
|
|
209
|
+
*/
|
|
210
|
+
initServices() {
|
|
211
|
+
this.logger = createLogger({ namespace: 'commands:stories:move' });
|
|
212
|
+
this.fileManager = new FileManager(this.logger);
|
|
213
|
+
this.pathResolver = new PathResolver(this.fileManager, this.logger);
|
|
214
|
+
this.globMatcher = new GlobMatcher(this.fileManager, this.logger);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Move files from source to QA directory
|
|
218
|
+
*/
|
|
219
|
+
async moveFiles(files, sourceDir, destDir) {
|
|
220
|
+
const results = [];
|
|
221
|
+
const spinner = ora('Moving files...').start();
|
|
222
|
+
// Process files sequentially to show progress for each file
|
|
223
|
+
/* eslint-disable no-await-in-loop */
|
|
224
|
+
for (const sourceFile of files) {
|
|
225
|
+
const fileName = path.basename(sourceFile);
|
|
226
|
+
const destFile = path.join(destDir, fileName);
|
|
227
|
+
spinner.text = `Moving ${fileName}...`;
|
|
228
|
+
this.logger.info('Moving file: %s -> %s', sourceFile, destFile);
|
|
229
|
+
try {
|
|
230
|
+
// Check if destination file exists
|
|
231
|
+
const destExists = await this.fileManager.fileExists(destFile);
|
|
232
|
+
if (destExists) {
|
|
233
|
+
spinner.warn(chalk.yellow(`Skipped ${fileName} (file already exists in destination)`));
|
|
234
|
+
this.logger.warn('Destination file already exists: %s', destFile);
|
|
235
|
+
results.push({
|
|
236
|
+
destination: destFile,
|
|
237
|
+
reason: 'File already exists in destination',
|
|
238
|
+
source: sourceFile,
|
|
239
|
+
status: 'skipped',
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// Move file
|
|
244
|
+
await this.fileManager.moveFile(sourceFile, destFile);
|
|
245
|
+
spinner.succeed(chalk.green(`✓ Moved ${fileName}`));
|
|
246
|
+
this.logger.info('Successfully moved file: %s -> %s', sourceFile, destFile);
|
|
247
|
+
results.push({
|
|
248
|
+
destination: destFile,
|
|
249
|
+
source: sourceFile,
|
|
250
|
+
status: 'moved',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
const err = error;
|
|
255
|
+
spinner.fail(chalk.red(`✗ Failed to move ${fileName}: ${err.message}`));
|
|
256
|
+
this.logger.error('Error moving file %s: %O', sourceFile, err);
|
|
257
|
+
results.push({
|
|
258
|
+
destination: destFile,
|
|
259
|
+
error: err.message,
|
|
260
|
+
source: sourceFile,
|
|
261
|
+
status: 'failed',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
// Restart spinner for next iteration
|
|
265
|
+
if (files.indexOf(sourceFile) < files.length - 1) {
|
|
266
|
+
spinner.start();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/* eslint-enable no-await-in-loop */
|
|
270
|
+
spinner.stop();
|
|
271
|
+
return results;
|
|
272
|
+
}
|
|
273
|
+
}
|