ma-agents 3.0.1 → 3.2.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.
Files changed (35) hide show
  1. package/.opencode/skills/.ma-agents.json +99 -99
  2. package/README.md +48 -2
  3. package/bin/cli.js +546 -2
  4. package/lib/bmad-extension/module-help.csv +8 -4
  5. package/lib/bmad-extension/skills/add-sprint/SKILL.md +126 -40
  6. package/lib/bmad-extension/skills/add-to-sprint/SKILL.md +116 -142
  7. package/lib/bmad-extension/skills/cleanup-done/.gitkeep +0 -0
  8. package/lib/bmad-extension/skills/cleanup-done/SKILL.md +159 -0
  9. package/lib/bmad-extension/skills/cleanup-done/bmad-skill-manifest.yaml +3 -0
  10. package/lib/bmad-extension/skills/create-bug-story/SKILL.md +75 -7
  11. package/lib/bmad-extension/skills/generate-backlog/SKILL.md +183 -0
  12. package/lib/bmad-extension/skills/generate-backlog/bmad-skill-manifest.yaml +3 -0
  13. package/lib/bmad-extension/skills/modify-sprint/SKILL.md +63 -0
  14. package/lib/bmad-extension/skills/prioritize-backlog/.gitkeep +0 -0
  15. package/lib/bmad-extension/skills/prioritize-backlog/SKILL.md +195 -0
  16. package/lib/bmad-extension/skills/prioritize-backlog/bmad-skill-manifest.yaml +3 -0
  17. package/lib/bmad-extension/skills/remove-from-sprint/.gitkeep +0 -0
  18. package/lib/bmad-extension/skills/remove-from-sprint/SKILL.md +163 -0
  19. package/lib/bmad-extension/skills/remove-from-sprint/bmad-skill-manifest.yaml +3 -0
  20. package/lib/bmad-extension/skills/sprint-status-view/SKILL.md +199 -138
  21. package/lib/bmad-extension/workflows/add-sprint/workflow.md +129 -39
  22. package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +3 -205
  23. package/lib/bmad-extension/workflows/modify-sprint/workflow.md +5 -0
  24. package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +3 -192
  25. package/lib/installer.js +109 -2
  26. package/lib/templates/project-context.template.md +1 -1
  27. package/package.json +2 -2
  28. package/test/cicd-remote-mode.test.js +224 -0
  29. package/test/config-layout.test.js +230 -0
  30. package/test/config-lost-on-update.test.js +363 -0
  31. package/test/config-storage.test.js +275 -0
  32. package/test/cross-repo-validation.test.js +201 -0
  33. package/test/generate-project-context.test.js +148 -2
  34. package/test/portable-paths.test.js +268 -0
  35. package/test/repo-layout.test.js +246 -0
package/bin/cli.js CHANGED
@@ -3,7 +3,9 @@
3
3
  const prompts = require('prompts');
4
4
  const chalk = require('chalk');
5
5
  const path = require('path');
6
- const { installSkill, uninstallSkill, getStatus, listSkills, listAgents } = require('../lib/installer');
6
+ const fs = require('fs');
7
+ const { execFileSync } = require('child_process');
8
+ const { installSkill, uninstallSkill, getStatus, listSkills, listAgents, updateProjectContextRepoLayout } = require('../lib/installer');
7
9
  const bmad = require('../lib/bmad');
8
10
  const { handleCreateSkill, handleValidateSkill, handleSetMandatory, handleCustomizeAgent, handleCreateAgent } = require('../lib/skill-authoring');
9
11
 
@@ -28,6 +30,8 @@ ${chalk.bold('Usage:')}
28
30
  ${chalk.cyan(`npx ${NAME} set-mandatory`)} <name> [--off] Mark a skill as always-load (or remove)
29
31
  ${chalk.cyan(`npx ${NAME} customize-agent`)} <agent> Customize a BMAD agent persona and actions
30
32
  ${chalk.cyan(`npx ${NAME} create-agent`)} <name> Create a new specialized BMAD agent
33
+ ${chalk.cyan(`npx ${NAME} config layout`)} Reconfigure repository layout
34
+ ${chalk.cyan(`npx ${NAME} config layout --show`)} Show current layout (read-only)
31
35
  ${chalk.cyan(`npx ${NAME} help`)} Show this help
32
36
 
33
37
  ${chalk.bold('Install options:')}
@@ -158,6 +162,452 @@ function parseFlags(args) {
158
162
  };
159
163
  }
160
164
 
165
+ // --- Repository layout wizard ---
166
+
167
+ async function collectLocalPath(concern) {
168
+ while (true) {
169
+ const { localPath } = await prompts({
170
+ type: 'text',
171
+ name: 'localPath',
172
+ message: `Enter local path for ${concern}:`
173
+ });
174
+
175
+ if (!localPath || !localPath.trim()) {
176
+ console.log(chalk.red(' Path cannot be empty. Please try again.'));
177
+ continue;
178
+ }
179
+
180
+ const resolved = path.resolve(process.cwd(), localPath.trim());
181
+
182
+ if (fs.existsSync(resolved)) {
183
+ if (!fs.statSync(resolved).isDirectory()) {
184
+ console.log(chalk.red(` "${resolved}" exists but is a file, not a directory. Please try again.`));
185
+ continue;
186
+ }
187
+ } else {
188
+ console.log(chalk.yellow(` Warning: "${resolved}" does not exist.`));
189
+ const { confirmed } = await prompts({
190
+ type: 'confirm',
191
+ name: 'confirmed',
192
+ message: 'Use this path anyway? (It must exist before agents run)',
193
+ initial: false
194
+ });
195
+ if (!confirmed) continue;
196
+ console.log(chalk.yellow(` Note: Path "${resolved}" must exist before agents run.`));
197
+ }
198
+
199
+ if (resolved.startsWith(process.cwd())) {
200
+ console.log(chalk.yellow(` Warning: Path is inside the current repository. Nested repos may cause git confusion.`));
201
+ }
202
+
203
+ return { mode: 'local', path: resolved };
204
+ }
205
+ }
206
+
207
+ async function collectRemotePath(concern) {
208
+ while (true) {
209
+ // Collect git URL
210
+ let gitUrl;
211
+ while (true) {
212
+ const { url } = await prompts({
213
+ type: 'text',
214
+ name: 'url',
215
+ message: `Enter git URL for ${concern}:`
216
+ });
217
+ if (!url || !url.trim()) {
218
+ console.log(chalk.red(' Git URL cannot be empty. Please try again.'));
219
+ continue;
220
+ }
221
+ gitUrl = url.trim();
222
+ break;
223
+ }
224
+
225
+ // Collect destination path
226
+ let destPath;
227
+ while (true) {
228
+ const { dest } = await prompts({
229
+ type: 'text',
230
+ name: 'dest',
231
+ message: `Enter local destination path for clone:`
232
+ });
233
+ if (!dest || !dest.trim()) {
234
+ console.log(chalk.red(' Destination path cannot be empty. Please try again.'));
235
+ continue;
236
+ }
237
+ destPath = path.resolve(process.cwd(), dest.trim());
238
+ break;
239
+ }
240
+
241
+ // Check destination
242
+ if (fs.existsSync(destPath)) {
243
+ if (!fs.statSync(destPath).isDirectory()) {
244
+ console.log(chalk.red(` "${destPath}" exists but is a file, not a directory. Please try again.`));
245
+ continue;
246
+ }
247
+ // Existing directory — show summary and confirm
248
+ const files = fs.readdirSync(destPath);
249
+ const isGitRepo = fs.existsSync(path.join(destPath, '.git'));
250
+ console.log(chalk.blue(` Directory exists: ${files.length} items${isGitRepo ? ' (git repo)' : ''}`));
251
+ const { confirmed } = await prompts({
252
+ type: 'confirm',
253
+ name: 'confirmed',
254
+ message: 'Use this existing directory?',
255
+ initial: true
256
+ });
257
+ if (!confirmed) continue;
258
+ } else {
259
+ // Clone
260
+ const existedBefore = fs.existsSync(destPath);
261
+ try {
262
+ console.log(chalk.cyan(` Cloning ${gitUrl}...`));
263
+ execFileSync('git', ['clone', gitUrl, destPath], {
264
+ timeout: 120000,
265
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
266
+ stdio: 'inherit'
267
+ });
268
+ } catch (err) {
269
+ console.log(chalk.red(` Clone failed: ${err.message}`));
270
+ if (!existedBefore && fs.existsSync(destPath)) {
271
+ fs.rmSync(destPath, { recursive: true, force: true });
272
+ }
273
+ const { retry } = await prompts({
274
+ type: 'confirm',
275
+ name: 'retry',
276
+ message: 'Retry?',
277
+ initial: true
278
+ });
279
+ if (retry) continue;
280
+ console.log(chalk.yellow(` Falling back to current repository for ${concern}`));
281
+ return { mode: 'same', path: '.' };
282
+ }
283
+ }
284
+
285
+ if (destPath.startsWith(process.cwd())) {
286
+ console.log(chalk.yellow(` Warning: Destination is inside the current repository. Nested repos may cause git confusion.`));
287
+ }
288
+
289
+ return { mode: 'remote', path: destPath, gitUrl };
290
+ }
291
+ }
292
+
293
+ function normalizePath(p) {
294
+ return p.replace(/\\/g, '/');
295
+ }
296
+
297
+ function yamlEscapeValue(v) {
298
+ if (!v) return '""';
299
+ const s = String(v);
300
+ if (/[:"#\[\]{}&*!|>%@`]/.test(s) || s !== s.trim() || s === '') {
301
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
302
+ }
303
+ return '"' + s + '"';
304
+ }
305
+
306
+ function toPortablePath(absolutePath, projectRoot) {
307
+ if (!absolutePath || absolutePath === '.') return { portable: '.', isAbsolute: false };
308
+
309
+ const rel = normalizePath(path.relative(projectRoot, absolutePath));
310
+
311
+ // Same directory: path.relative returns '' — treat as '.'
312
+ if (rel === '') return { portable: '.', isAbsolute: false };
313
+
314
+ // Cross-drive on Windows: path.relative returns the absolute target unchanged
315
+ if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) {
316
+ return { portable: normalizePath(absolutePath), isAbsolute: true };
317
+ }
318
+
319
+ // Too many levels up — treat as non-portable
320
+ const upLevels = (rel.match(/\.\./g) || []).length;
321
+ if (upLevels > 3) {
322
+ return { portable: normalizePath(absolutePath), isAbsolute: true };
323
+ }
324
+
325
+ return { portable: rel, isAbsolute: false };
326
+ }
327
+
328
+ function writeConfigField(content, fieldName, value) {
329
+ const regex = new RegExp(`^${fieldName}:.*$`, 'm');
330
+ const newLine = `${fieldName}: ${yamlEscapeValue(value)}`;
331
+ if (regex.test(content)) {
332
+ return content.replace(regex, newLine);
333
+ }
334
+ return content.trimEnd() + '\n' + newLine + '\n';
335
+ }
336
+
337
+ function writeRepoLayoutConfig(layout) {
338
+ const configPath = path.join(process.cwd(), '_bmad', 'bmm', 'config.yaml');
339
+ try {
340
+ if (!fs.existsSync(configPath)) {
341
+ console.log(chalk.yellow(' _bmad/bmm/config.yaml not found — skipping config write'));
342
+ return;
343
+ }
344
+ let content = fs.readFileSync(configPath, 'utf-8');
345
+ const projectRoot = process.cwd();
346
+ const kbPortable = toPortablePath(layout.knowledgebase.path, projectRoot);
347
+ const spPortable = toPortablePath(layout.sprintManagement.path, projectRoot);
348
+ const kbPath = kbPortable.portable;
349
+ const spPath = spPortable.portable;
350
+ content = writeConfigField(content, 'knowledgebase_path', kbPath);
351
+ content = writeConfigField(content, 'sprint_management_path', spPath);
352
+ // Derive workflow-consumable artifact paths from layout paths (relative when base is relative)
353
+ const planningArtifacts = kbPath === '.' ? '_bmad-output/planning-artifacts' : `${kbPath}/_bmad-output/planning-artifacts`;
354
+ const implArtifacts = spPath === '.' ? '_bmad-output/implementation-artifacts' : `${spPath}/_bmad-output/implementation-artifacts`;
355
+ content = writeConfigField(content, 'planning_artifacts', planningArtifacts);
356
+ content = writeConfigField(content, 'implementation_artifacts', implArtifacts);
357
+ fs.writeFileSync(configPath, content, 'utf-8');
358
+ console.log(chalk.gray(` Config: knowledgebase_path="${kbPath}", sprint_management_path="${spPath}"`));
359
+ } catch (e) {
360
+ console.log(chalk.red(` Cannot write config.yaml: ${e.message}`));
361
+ }
362
+ }
363
+
364
+ function writeProjectLayoutYaml(layout) {
365
+ const layoutPath = path.join(process.cwd(), '_bmad-output', 'project-layout.yaml');
366
+ const bothSame = layout.knowledgebase.mode === 'same' && layout.sprintManagement.mode === 'same';
367
+
368
+ if (bothSame) {
369
+ // Single-repo mode: delete stale file if exists
370
+ if (fs.existsSync(layoutPath)) {
371
+ fs.unlinkSync(layoutPath);
372
+ console.log(chalk.blue(' Removed stale project-layout.yaml (now in single-repo mode)'));
373
+ }
374
+ return;
375
+ }
376
+
377
+ // Multi-repo: generate project-layout.yaml
378
+ fs.mkdirSync(path.join(process.cwd(), '_bmad-output'), { recursive: true });
379
+
380
+ const date = new Date().toISOString().slice(0, 10);
381
+ let content = `# Generated by ma-agents — do not edit manually\n`;
382
+ content += `# Tells agents where to find planning and sprint data\n`;
383
+ content += `generated: "${date}"\n`;
384
+
385
+ const projectRoot = process.cwd();
386
+
387
+ // Knowledgebase section
388
+ const kbPortable = toPortablePath(layout.knowledgebase.path, projectRoot);
389
+ content += `knowledgebase:\n`;
390
+ content += ` mode: ${layout.knowledgebase.mode}\n`;
391
+ content += ` path: ${yamlEscapeValue(kbPortable.portable)}\n`;
392
+ if (kbPortable.isAbsolute) {
393
+ content += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
394
+ }
395
+ if (layout.knowledgebase.mode === 'remote' && layout.knowledgebase.gitUrl) {
396
+ content += ` gitUrl: ${yamlEscapeValue(layout.knowledgebase.gitUrl)}\n`;
397
+ }
398
+
399
+ // Sprint management section
400
+ const spPortable = toPortablePath(layout.sprintManagement.path, projectRoot);
401
+ content += `sprint_management:\n`;
402
+ content += ` mode: ${layout.sprintManagement.mode}\n`;
403
+ content += ` path: ${yamlEscapeValue(spPortable.portable)}\n`;
404
+ if (spPortable.isAbsolute) {
405
+ content += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
406
+ }
407
+ if (layout.sprintManagement.mode === 'remote' && layout.sprintManagement.gitUrl) {
408
+ content += ` gitUrl: ${yamlEscapeValue(layout.sprintManagement.gitUrl)}\n`;
409
+ }
410
+
411
+ fs.writeFileSync(layoutPath, content, 'utf-8');
412
+ console.log(chalk.gray(` Created _bmad-output/project-layout.yaml`));
413
+ }
414
+
415
+ function resolveStoredPath(storedPath, projectRoot) {
416
+ if (!storedPath) return '.';
417
+ if (storedPath === '.') return storedPath;
418
+ // If already absolute, use as-is (backward compat)
419
+ if (path.isAbsolute(storedPath) || /^[A-Za-z]:/.test(storedPath)) return storedPath;
420
+ // Relative path — resolve from project root
421
+ return normalizePath(path.resolve(projectRoot, storedPath));
422
+ }
423
+
424
+ function readExistingLayout() {
425
+ const projectRoot = process.cwd();
426
+ // Try project-layout.yaml first (authoritative source written by Story 16.2)
427
+ const layoutPath = path.join(projectRoot, '_bmad-output', 'project-layout.yaml');
428
+ try {
429
+ if (fs.existsSync(layoutPath)) {
430
+ const content = fs.readFileSync(layoutPath, 'utf-8');
431
+ const layout = { knowledgebase: null, sprintManagement: null };
432
+
433
+ // Parse each concern section from the simple YAML
434
+ for (const [section, key] of [['knowledgebase', 'knowledgebase'], ['sprint_management', 'sprintManagement']]) {
435
+ const sectionMatch = content.match(new RegExp(`^${section}:\\s*$`, 'm'));
436
+ if (!sectionMatch) continue;
437
+
438
+ const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length);
439
+ const modeMatch = afterSection.match(/^\s+mode:\s*(\S+)/m);
440
+ const pathMatch = afterSection.match(/^\s+path:\s*"?([^"\n]+)"?/m);
441
+ const gitUrlMatch = afterSection.match(/^\s+gitUrl:\s*"?([^"\n]+)"?/m);
442
+
443
+ if (modeMatch && pathMatch) {
444
+ const resolvedPath = resolveStoredPath(pathMatch[1], projectRoot);
445
+ const entry = { mode: modeMatch[1], path: resolvedPath };
446
+ if (modeMatch[1] === 'remote' && gitUrlMatch) {
447
+ entry.gitUrl = gitUrlMatch[1];
448
+ }
449
+ layout[key] = entry;
450
+ }
451
+ }
452
+
453
+ // Only return if we parsed at least one valid concern
454
+ if (layout.knowledgebase || layout.sprintManagement) {
455
+ // Fill in defaults for any missing section
456
+ if (!layout.knowledgebase) layout.knowledgebase = { mode: 'same', path: '.' };
457
+ if (!layout.sprintManagement) layout.sprintManagement = { mode: 'same', path: '.' };
458
+ return layout;
459
+ }
460
+ }
461
+ } catch (e) {
462
+ console.log(chalk.yellow(` Warning: could not read project-layout.yaml: ${e.message}`));
463
+ }
464
+
465
+ // Fallback: check config.yaml for path fields
466
+ const configPath = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
467
+ try {
468
+ if (fs.existsSync(configPath)) {
469
+ const content = fs.readFileSync(configPath, 'utf-8');
470
+ const kbMatch = content.match(/^knowledgebase_path:\s*"?([^"\n]+)"?/m);
471
+ const spMatch = content.match(/^sprint_management_path:\s*"?([^"\n]+)"?/m);
472
+
473
+ const kbPath = kbMatch ? resolveStoredPath(kbMatch[1], projectRoot) : null;
474
+ const spPath = spMatch ? resolveStoredPath(spMatch[1], projectRoot) : null;
475
+
476
+ // Only return if at least one non-default path exists
477
+ if ((kbPath && kbPath !== '.') || (spPath && spPath !== '.')) {
478
+ return {
479
+ knowledgebase: kbPath && kbPath !== '.'
480
+ ? { mode: 'local', path: kbPath }
481
+ : { mode: 'same', path: '.' },
482
+ sprintManagement: spPath && spPath !== '.'
483
+ ? { mode: 'local', path: spPath }
484
+ : { mode: 'same', path: '.' },
485
+ };
486
+ }
487
+ }
488
+ } catch (e) {
489
+ console.log(chalk.yellow(` Warning: could not read config.yaml layout: ${e.message}`));
490
+ }
491
+
492
+ return null;
493
+ }
494
+
495
+ function ciCloneIfNeeded(destPath, gitUrl, clonedRepos) {
496
+ // Deduplicate: same URL + path already cloned
497
+ const key = `${gitUrl}::${destPath}`;
498
+ if (clonedRepos.has(key)) return;
499
+
500
+ if (fs.existsSync(destPath)) {
501
+ if (fs.existsSync(path.join(destPath, '.git'))) {
502
+ console.log(chalk.gray(` Using existing clone at ${destPath}`));
503
+ clonedRepos.set(key, true);
504
+ return;
505
+ }
506
+ throw new Error(`Destination exists but is not a git repo — remove it or use a different path: ${destPath}`);
507
+ }
508
+
509
+ try {
510
+ console.log(chalk.cyan(` Cloning ${gitUrl} → ${destPath}`));
511
+ execFileSync('git', ['clone', '--depth', '1', gitUrl, destPath], { stdio: 'pipe' });
512
+ clonedRepos.set(key, true);
513
+ } catch (e) {
514
+ throw new Error(`Failed to clone ${gitUrl}: ${e.message}`);
515
+ }
516
+ }
517
+
518
+ async function collectRepoLayout(flags, existingLayout = null) {
519
+ // CI/CD mode: env vars > existing layout > defaults
520
+ if (flags.yes) {
521
+ const kbPath = process.env.MA_KNOWLEDGEBASE_PATH;
522
+ const sprintPath = process.env.MA_SPRINT_PATH;
523
+ const kbGitUrl = process.env.MA_KNOWLEDGEBASE_GIT_URL;
524
+ const sprintGitUrl = process.env.MA_SPRINT_GIT_URL;
525
+
526
+ // Validate: git URL requires corresponding path
527
+ if (kbGitUrl && !kbPath) {
528
+ throw new Error('MA_KNOWLEDGEBASE_GIT_URL requires MA_KNOWLEDGEBASE_PATH to be set (clone destination)');
529
+ }
530
+ if (sprintGitUrl && !sprintPath) {
531
+ throw new Error('MA_SPRINT_GIT_URL requires MA_SPRINT_PATH to be set (clone destination)');
532
+ }
533
+
534
+ // Env vars take precedence
535
+ if (kbPath || sprintPath || kbGitUrl || sprintGitUrl) {
536
+ const result = { knowledgebase: null, sprintManagement: null };
537
+
538
+ // Track cloned repos to avoid duplicate clones (AC #8)
539
+ const clonedRepos = new Map();
540
+
541
+ for (const [concern, envPath, envGitUrl] of [
542
+ ['knowledgebase', kbPath, kbGitUrl],
543
+ ['sprintManagement', sprintPath, sprintGitUrl],
544
+ ]) {
545
+ if (envGitUrl && envPath) {
546
+ const resolvedPath = path.resolve(envPath);
547
+ // Clone if needed (CI-mode: fail loudly, no fallback)
548
+ ciCloneIfNeeded(resolvedPath, envGitUrl, clonedRepos);
549
+ result[concern] = { mode: 'remote', path: resolvedPath, gitUrl: envGitUrl };
550
+ } else if (envPath) {
551
+ result[concern] = { mode: 'local', path: path.resolve(envPath) };
552
+ } else {
553
+ result[concern] = existingLayout ? existingLayout[concern] : { mode: 'same', path: '.' };
554
+ }
555
+ }
556
+
557
+ return result;
558
+ }
559
+ // No env vars: preserve existing layout if available
560
+ if (existingLayout) {
561
+ return existingLayout;
562
+ }
563
+ // No env vars, no existing layout: default to single-repo
564
+ return {
565
+ knowledgebase: { mode: 'same', path: '.' },
566
+ sprintManagement: { mode: 'same', path: '.' },
567
+ };
568
+ }
569
+
570
+ const layout = { knowledgebase: null, sprintManagement: null };
571
+ const labels = { knowledgebase: 'knowledgebase', sprintManagement: 'sprint management' };
572
+
573
+ for (const concern of ['knowledgebase', 'sprintManagement']) {
574
+ const existingMode = existingLayout && existingLayout[concern] ? existingLayout[concern].mode : null;
575
+ const modeChoices = [
576
+ { title: 'Current repository (default)', value: 'same' },
577
+ { title: 'Local path', value: 'local' },
578
+ { title: 'Remote git repository', value: 'remote' },
579
+ ];
580
+ // Pre-select existing mode if available
581
+ const initialIndex = existingMode ? modeChoices.findIndex(c => c.value === existingMode) : 0;
582
+
583
+ const { mode } = await prompts({
584
+ type: 'select',
585
+ name: 'mode',
586
+ message: `Where is your ${labels[concern]} managed?`,
587
+ choices: modeChoices,
588
+ initial: initialIndex >= 0 ? initialIndex : 0
589
+ });
590
+
591
+ if (!mode) process.exit(0);
592
+
593
+ if (mode === 'same') {
594
+ layout[concern] = { mode: 'same', path: '.' };
595
+ } else if (mode === 'local') {
596
+ layout[concern] = await collectLocalPath(labels[concern]);
597
+ } else if (mode === 'remote') {
598
+ layout[concern] = await collectRemotePath(labels[concern]);
599
+ }
600
+ }
601
+
602
+ // Check if both concerns point to same external path
603
+ if (layout.knowledgebase.mode !== 'same' && layout.sprintManagement.mode !== 'same'
604
+ && layout.knowledgebase.path === layout.sprintManagement.path) {
605
+ console.log(chalk.cyan(' Both concerns point to same external repository'));
606
+ }
607
+
608
+ return layout;
609
+ }
610
+
161
611
  // --- Install wizard ---
162
612
 
163
613
  async function installWizard(preselectedSkill, preselectedAgents, customPath, forceFlag, yesFlag = false) {
@@ -332,6 +782,10 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
332
782
  }
333
783
  }
334
784
 
785
+ // Step 2.5: Repository layout (preserve existing config on update)
786
+ const existingLayout = isUpdate ? readExistingLayout() : null;
787
+ const repoLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
788
+
335
789
  // Step 3: Scope (Skip if update, yesFlag, or path already set)
336
790
  if (!isUpdate && !installPath && !yesFlag) {
337
791
  const { pathChoice } = await prompts({
@@ -433,6 +887,12 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
433
887
  }
434
888
  }
435
889
 
890
+ // Step 3.6: Write repo layout config (after BMAD install creates config.yaml)
891
+ if (installScope === 'project') {
892
+ writeRepoLayoutConfig(repoLayout);
893
+ writeProjectLayoutYaml(repoLayout);
894
+ }
895
+
436
896
  // Step 4: Confirm
437
897
  console.log('');
438
898
  console.log(chalk.bold(' Summary:'));
@@ -511,6 +971,21 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
511
971
  }
512
972
  }
513
973
 
974
+ // Step 6: Update project-context.md with repo layout section (after skills installed project-context.md)
975
+ if (installScope === 'project') {
976
+ const outputPath = path.join(process.cwd(), '_bmad-output', 'project-context.md');
977
+ try {
978
+ if (fs.existsSync(outputPath)) {
979
+ const updated = await updateProjectContextRepoLayout(outputPath, repoLayout);
980
+ if (updated) {
981
+ console.log(chalk.green(' project-context.md repo layout section updated'));
982
+ }
983
+ }
984
+ } catch (err) {
985
+ console.log(chalk.yellow(` project-context repo layout update skipped: ${err.message}`));
986
+ }
987
+ }
988
+
514
989
  console.log(chalk.bold.green('\n Done!\n'));
515
990
  }
516
991
 
@@ -582,6 +1057,66 @@ async function handleUninstall(args) {
582
1057
  }
583
1058
  }
584
1059
 
1060
+ // --- Config layout command ---
1061
+
1062
+ function showCurrentLayout() {
1063
+ const layout = readExistingLayout();
1064
+ if (!layout) {
1065
+ console.log(chalk.cyan('\n Single-repo layout (default)'));
1066
+ console.log(chalk.gray(' Knowledgebase: . (current repository)'));
1067
+ console.log(chalk.gray(' Sprint management: . (current repository)'));
1068
+ return;
1069
+ }
1070
+
1071
+ console.log(chalk.bold.cyan('\n Current Repository Layout:\n'));
1072
+ for (const [label, key] of [['Knowledgebase', 'knowledgebase'], ['Sprint Management', 'sprintManagement']]) {
1073
+ const concern = layout[key];
1074
+ console.log(chalk.white(` ${label}:`));
1075
+ console.log(chalk.gray(` Mode: ${concern.mode}`));
1076
+ console.log(chalk.gray(` Path: ${concern.path}`));
1077
+ if (concern.gitUrl) {
1078
+ console.log(chalk.gray(` Git URL: ${concern.gitUrl}`));
1079
+ }
1080
+ }
1081
+ console.log('');
1082
+ }
1083
+
1084
+ async function handleConfigLayout(args) {
1085
+ const { yesFlag } = parseFlags(args);
1086
+ const showOnly = args.includes('--show');
1087
+
1088
+ if (showOnly) {
1089
+ showCurrentLayout();
1090
+ return;
1091
+ }
1092
+
1093
+ // Show current before reconfiguring
1094
+ showCurrentLayout();
1095
+
1096
+ // Read existing and present pre-populated wizard
1097
+ const existingLayout = readExistingLayout();
1098
+ const newLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
1099
+
1100
+ // Write updated config
1101
+ writeRepoLayoutConfig(newLayout);
1102
+ writeProjectLayoutYaml(newLayout);
1103
+
1104
+ // Update project-context.md
1105
+ const outputPath = path.join(process.cwd(), '_bmad-output', 'project-context.md');
1106
+ try {
1107
+ if (fs.existsSync(outputPath)) {
1108
+ const updated = await updateProjectContextRepoLayout(outputPath, newLayout);
1109
+ if (updated) {
1110
+ console.log(chalk.green(' project-context.md repo layout section updated'));
1111
+ }
1112
+ }
1113
+ } catch (err) {
1114
+ console.log(chalk.yellow(` project-context repo layout update skipped: ${err.message}`));
1115
+ }
1116
+
1117
+ console.log(chalk.bold.green('\n Layout reconfigured!\n'));
1118
+ }
1119
+
585
1120
  // --- Interactive mode ---
586
1121
 
587
1122
  async function interactiveMode() {
@@ -665,6 +1200,15 @@ async function main() {
665
1200
  case 'create-agent':
666
1201
  await handleCreateAgent(args.slice(1));
667
1202
  break;
1203
+ case 'config':
1204
+ if (args[1] === 'layout') {
1205
+ await handleConfigLayout(args.slice(2));
1206
+ } else {
1207
+ console.error(chalk.red(`Unknown config subcommand: ${args[1] || '(none)'}`));
1208
+ console.error(chalk.gray(' Usage: npx ma-agents config layout [--show] [--yes]'));
1209
+ process.exit(1);
1210
+ }
1211
+ break;
668
1212
  case 'help':
669
1213
  case '--help':
670
1214
  case '-h':
@@ -688,4 +1232,4 @@ if (require.main === module) {
688
1232
  });
689
1233
  }
690
1234
 
691
- module.exports = { parseFlags };
1235
+ module.exports = { parseFlags, collectRepoLayout, readExistingLayout, writeRepoLayoutConfig, writeProjectLayoutYaml, writeConfigField, normalizePath, toPortablePath, resolveStoredPath, ciCloneIfNeeded, showCurrentLayout, handleConfigLayout, yamlEscapeValue };
@@ -29,9 +29,13 @@ ma-skills,4-implementation,Vault Secrets,cyber-vault-secrets,,skill:cyber-vault-
29
29
  ma-skills,4-implementation,Verify Docker Users,cyber-verify-docker-users,,skill:cyber-verify-docker-users,bmad-cyber-verify-docker-users,false,bmm-cyber,,"Verify Docker image user configurations and hardening compliance.",output_folder,"verification report",
30
30
  ma-skills,4-implementation,Verify Image Signature,cyber-verify-image-signature,,skill:cyber-verify-image-signature,bmad-cyber-verify-image-signature,false,bmm-cyber,,"Verify Docker image signatures for supply chain integrity.",output_folder,"verification report",
31
31
  ma-skills,4-implementation,Vulnerability Scan,cyber-vulnerability-scan,,skill:cyber-vulnerability-scan,bmad-cyber-vulnerability-scan,false,bmm-cyber,,"Orchestrate vulnerability scanning across project components.",output_folder,"scan report",
32
- ma-skills,4-implementation,Create Bug Story,create-bug-story,,skill:create-bug-story,create-bug-story,false,bmm-dev,,"Create a structured bug story from a detected defect and add to backlog.",_bmad-output/implementation-artifacts,"bug story",
33
- ma-skills,4-implementation,Add Sprint,add-sprint,,skill:add-sprint,add-sprint,false,bmm-sm,,"Create a new sprint with capacity limits and optional start/end context.",_bmad-output/implementation-artifacts,"sprint plan",
32
+ ma-skills,4-implementation,Create Bug Story,create-bug-story,,skill:create-bug-story,create-bug-story,false,bmm-dev,,"Create a structured bug story with severity and type classification, add to backlog.yaml.",_bmad-output/implementation-artifacts,"bug story",
33
+ ma-skills,4-implementation,Add Sprint,add-sprint,,skill:add-sprint,add-sprint,false,bmm-sm,,"Create a new sprint entity with capacity limits and optional ISO dates (YAML schema).",_bmad-output/implementation-artifacts/sprints,"sprint entity",
34
34
  ma-skills,4-implementation,Modify Sprint,modify-sprint,,skill:modify-sprint,modify-sprint,false,bmm-sm,,"Modify existing sprint — add/remove items, change capacity, update metadata.",_bmad-output/implementation-artifacts,"sprint plan",
35
- ma-skills,4-implementation,Add to Sprint,add-to-sprint,,skill:add-to-sprint,add-to-sprint,false,bmm-sm,,"Assign backlog items (stories + bugs) to a sprint using multi-criteria prioritization.",_bmad-output/implementation-artifacts,"sprint plan",
35
+ ma-skills,4-implementation,Add to Sprint,add-to-sprint,,skill:add-to-sprint,add-to-sprint,false,bmm-sm,,"Assign backlog items to a sprint from flat prioritized backlog.",_bmad-output/implementation-artifacts,"sprint plan",
36
36
  ma-skills,4-implementation,Project Context Expansion,project-context-expansion,,skill:project-context-expansion,project-context-expansion,false,bmm-sm,,"Post-retrospective companion to update project-context.md with new rules.",_bmad-output,"project context",
37
- ma-skills,4-implementation,Sprint Status View,sprint-status-view,,skill:sprint-status-view,sprint-status-view,false,bmm-sm,,"View sprint progress with assigned items and remaining capacity.",_bmad-output/implementation-artifacts,"status display",
37
+ ma-skills,4-implementation,Sprint Status View,sprint-status-view,,skill:sprint-status-view,sprint-status-view,false,bmm-sm,,"View sprint status with capacity, items, and backlog. Regenerates sprint-status.yaml.",_bmad-output/implementation-artifacts,"status display",
38
+ ma-skills,4-implementation,Generate Backlog,generate-backlog,,skill:generate-backlog,generate-backlog,false,bmm-sm,,"Generate or refresh flat backlog from epics and bug stories.",_bmad-output/implementation-artifacts,"backlog",
39
+ ma-skills,4-implementation,Remove from Sprint,remove-from-sprint,,skill:remove-from-sprint,remove-from-sprint,false,bmm-sm,,"Remove items from a sprint and return to unassigned backlog.",_bmad-output/implementation-artifacts,"sprint plan",
40
+ ma-skills,4-implementation,Cleanup Done,cleanup-done,,skill:cleanup-done,cleanup-done,false,bmm-sm,,"Archive done items — move files to done/ and remove from sprint/backlog.",_bmad-output/implementation-artifacts,"archived items",
41
+ ma-skills,4-implementation,Prioritize Backlog,prioritize-backlog,,skill:prioritize-backlog,prioritize-backlog,false,bmm-sm,,"Reprioritize backlog using multiple criteria — severity, value, dependencies.",_bmad-output/implementation-artifacts,"backlog",