bmad-method 6.3.1-next.9 → 6.4.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 (140) hide show
  1. package/package.json +3 -2
  2. package/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md +51 -36
  3. package/src/bmm-skills/1-analysis/bmad-agent-analyst/customize.toml +90 -0
  4. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md +50 -33
  5. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/customize.toml +81 -0
  6. package/src/bmm-skills/1-analysis/bmad-document-project/SKILL.md +57 -1
  7. package/src/bmm-skills/1-analysis/bmad-document-project/customize.toml +41 -0
  8. package/src/bmm-skills/1-analysis/bmad-document-project/workflows/deep-dive-instructions.md +1 -0
  9. package/src/bmm-skills/1-analysis/bmad-document-project/workflows/full-scan-instructions.md +1 -0
  10. package/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md +48 -9
  11. package/src/bmm-skills/1-analysis/bmad-prfaq/customize.toml +41 -0
  12. package/src/bmm-skills/1-analysis/bmad-prfaq/references/verdict.md +4 -0
  13. package/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +44 -9
  14. package/src/bmm-skills/1-analysis/bmad-product-brief/customize.toml +47 -0
  15. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/contextual-discovery.md +8 -7
  16. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/draft-and-review.md +6 -5
  17. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/finalize.md +4 -1
  18. package/src/bmm-skills/1-analysis/bmad-product-brief/prompts/guided-elicitation.md +3 -2
  19. package/src/bmm-skills/1-analysis/research/bmad-domain-research/SKILL.md +91 -1
  20. package/src/bmm-skills/1-analysis/research/bmad-domain-research/customize.toml +41 -0
  21. package/src/bmm-skills/1-analysis/research/bmad-domain-research/domain-steps/step-06-research-synthesis.md +6 -0
  22. package/src/bmm-skills/1-analysis/research/bmad-market-research/SKILL.md +91 -1
  23. package/src/bmm-skills/1-analysis/research/bmad-market-research/customize.toml +41 -0
  24. package/src/bmm-skills/1-analysis/research/bmad-market-research/steps/step-06-research-completion.md +6 -0
  25. package/src/bmm-skills/1-analysis/research/bmad-technical-research/SKILL.md +91 -1
  26. package/src/bmm-skills/1-analysis/research/bmad-technical-research/customize.toml +41 -0
  27. package/src/bmm-skills/1-analysis/research/bmad-technical-research/technical-steps/step-06-research-synthesis.md +6 -0
  28. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md +50 -35
  29. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/customize.toml +85 -0
  30. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md +50 -31
  31. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/customize.toml +60 -0
  32. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/SKILL.md +99 -1
  33. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/customize.toml +41 -0
  34. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/steps-c/step-12-complete.md +6 -0
  35. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/SKILL.md +70 -1
  36. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/customize.toml +41 -0
  37. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-14-complete.md +6 -0
  38. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/SKILL.md +97 -1
  39. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/customize.toml +42 -0
  40. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +2 -0
  41. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/SKILL.md +99 -1
  42. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/customize.toml +42 -0
  43. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/steps-v/step-v-13-report-complete.md +1 -0
  44. package/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md +50 -30
  45. package/src/bmm-skills/3-solutioning/bmad-agent-architect/customize.toml +65 -0
  46. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/SKILL.md +86 -1
  47. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/customize.toml +41 -0
  48. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-06-final-assessment.md +6 -0
  49. package/src/bmm-skills/3-solutioning/bmad-create-architecture/SKILL.md +69 -1
  50. package/src/bmm-skills/3-solutioning/bmad-create-architecture/customize.toml +41 -0
  51. package/src/bmm-skills/3-solutioning/bmad-create-architecture/steps/step-08-complete.md +6 -0
  52. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md +88 -1
  53. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml +41 -0
  54. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/steps/step-04-final-validation.md +6 -0
  55. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/SKILL.md +76 -1
  56. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/customize.toml +41 -0
  57. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/steps/step-03-complete.md +6 -0
  58. package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +48 -43
  59. package/src/bmm-skills/4-implementation/bmad-agent-dev/customize.toml +90 -0
  60. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +46 -7
  61. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/customize.toml +41 -0
  62. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md +6 -0
  63. package/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md +85 -1
  64. package/src/bmm-skills/4-implementation/bmad-code-review/customize.toml +41 -0
  65. package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-04-present.md +6 -0
  66. package/src/bmm-skills/4-implementation/bmad-correct-course/SKILL.md +296 -1
  67. package/src/bmm-skills/4-implementation/bmad-correct-course/customize.toml +41 -0
  68. package/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md +424 -1
  69. package/src/bmm-skills/4-implementation/bmad-create-story/customize.toml +41 -0
  70. package/src/bmm-skills/4-implementation/bmad-dev-story/SKILL.md +480 -1
  71. package/src/bmm-skills/4-implementation/bmad-dev-story/customize.toml +41 -0
  72. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md +171 -1
  73. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/customize.toml +41 -0
  74. package/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +106 -1
  75. package/src/bmm-skills/4-implementation/bmad-quick-dev/customize.toml +41 -0
  76. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md +6 -0
  77. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +6 -0
  78. package/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md +1507 -1
  79. package/src/bmm-skills/4-implementation/bmad-retrospective/customize.toml +41 -0
  80. package/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md +294 -1
  81. package/src/bmm-skills/4-implementation/bmad-sprint-planning/customize.toml +41 -0
  82. package/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md +292 -1
  83. package/src/bmm-skills/4-implementation/bmad-sprint-status/customize.toml +41 -0
  84. package/src/bmm-skills/module.yaml +49 -0
  85. package/src/core-skills/bmad-advanced-elicitation/SKILL.md +7 -1
  86. package/src/core-skills/bmad-customize/SKILL.md +111 -0
  87. package/src/core-skills/bmad-customize/scripts/list_customizable_skills.py +231 -0
  88. package/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py +249 -0
  89. package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +1 -1
  90. package/src/core-skills/bmad-party-mode/SKILL.md +13 -10
  91. package/src/core-skills/module-help.csv +1 -0
  92. package/src/core-skills/module.yaml +2 -0
  93. package/src/scripts/resolve_config.py +176 -0
  94. package/src/scripts/resolve_customization.py +230 -0
  95. package/tools/installer/commands/install.js +13 -0
  96. package/tools/installer/core/config.js +4 -1
  97. package/tools/installer/core/install-paths.js +11 -5
  98. package/tools/installer/core/installer.js +181 -94
  99. package/tools/installer/core/manifest-generator.js +339 -184
  100. package/tools/installer/core/manifest.js +86 -86
  101. package/tools/installer/ide/platform-codes.yaml +6 -0
  102. package/tools/installer/modules/channel-plan.js +203 -0
  103. package/tools/installer/modules/channel-resolver.js +241 -0
  104. package/tools/installer/modules/community-manager.js +130 -23
  105. package/tools/installer/modules/custom-module-manager.js +160 -19
  106. package/tools/installer/modules/external-manager.js +235 -32
  107. package/tools/installer/modules/official-modules.js +58 -12
  108. package/tools/installer/modules/registry-client.js +139 -7
  109. package/tools/installer/modules/registry-fallback.yaml +8 -0
  110. package/tools/installer/modules/version-resolver.js +336 -0
  111. package/tools/installer/project-root.js +54 -0
  112. package/tools/installer/ui.js +561 -50
  113. package/tools/platform-codes.yaml +6 -0
  114. package/src/bmm-skills/1-analysis/bmad-agent-analyst/bmad-skill-manifest.yaml +0 -11
  115. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/bmad-skill-manifest.yaml +0 -11
  116. package/src/bmm-skills/1-analysis/bmad-document-project/workflow.md +0 -25
  117. package/src/bmm-skills/1-analysis/research/bmad-domain-research/workflow.md +0 -51
  118. package/src/bmm-skills/1-analysis/research/bmad-market-research/workflow.md +0 -51
  119. package/src/bmm-skills/1-analysis/research/bmad-technical-research/workflow.md +0 -52
  120. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/bmad-skill-manifest.yaml +0 -11
  121. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/bmad-skill-manifest.yaml +0 -11
  122. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/workflow.md +0 -61
  123. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/workflow.md +0 -35
  124. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/workflow.md +0 -62
  125. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/workflow.md +0 -61
  126. package/src/bmm-skills/3-solutioning/bmad-agent-architect/bmad-skill-manifest.yaml +0 -11
  127. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +0 -47
  128. package/src/bmm-skills/3-solutioning/bmad-create-architecture/workflow.md +0 -32
  129. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +0 -51
  130. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/workflow.md +0 -39
  131. package/src/bmm-skills/4-implementation/bmad-agent-dev/bmad-skill-manifest.yaml +0 -11
  132. package/src/bmm-skills/4-implementation/bmad-code-review/workflow.md +0 -55
  133. package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +0 -267
  134. package/src/bmm-skills/4-implementation/bmad-create-story/workflow.md +0 -380
  135. package/src/bmm-skills/4-implementation/bmad-dev-story/workflow.md +0 -450
  136. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/workflow.md +0 -136
  137. package/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +0 -76
  138. package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +0 -1479
  139. package/src/bmm-skills/4-implementation/bmad-sprint-planning/workflow.md +0 -263
  140. package/src/bmm-skills/4-implementation/bmad-sprint-status/workflow.md +0 -261
@@ -1,50 +1,107 @@
1
1
  const path = require('node:path');
2
2
  const os = require('node:os');
3
+ const semver = require('semver');
3
4
  const fs = require('./fs-native');
4
5
  const { CLIUtils } = require('./cli-utils');
5
6
  const { ExternalModuleManager } = require('./modules/external-manager');
6
- const { getProjectRoot } = require('./project-root');
7
+ const { resolveModuleVersion } = require('./modules/version-resolver');
8
+ const { Manifest } = require('./core/manifest');
9
+ const {
10
+ parseChannelOptions,
11
+ buildPlan,
12
+ decideChannelForModule,
13
+ orphanPinWarnings,
14
+ bundledTargetWarnings,
15
+ } = require('./modules/channel-plan');
16
+ const channelResolver = require('./modules/channel-resolver');
7
17
  const prompts = require('./prompts');
8
18
 
19
+ const manifest = new Manifest();
20
+
9
21
  /**
10
- * Read module version from .claude-plugin/marketplace.json
11
- * @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
12
- * @returns {string} Version string or empty string
22
+ * Format a resolved version for display in installer labels.
23
+ * Semver-like values are normalized to a single leading "v".
24
+ * @param {string|null|undefined} version
25
+ * @returns {string}
13
26
  */
14
- async function getMarketplaceVersion(moduleCode) {
15
- let marketplacePath;
16
- if (moduleCode === 'core' || moduleCode === 'bmm') {
17
- marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json');
18
- } else {
19
- const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleCode);
20
- marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json');
27
+ function formatDisplayVersion(version) {
28
+ const trimmed = typeof version === 'string' ? version.trim() : '';
29
+ if (!trimmed) return '';
30
+
31
+ const normalized = semver.valid(semver.coerce(trimmed));
32
+ if (normalized) {
33
+ return `v${normalized}`;
21
34
  }
22
- try {
23
- if (await fs.pathExists(marketplacePath)) {
24
- const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
25
- return _extractMarketplaceVersion(data);
26
- }
27
- } catch {
28
- // ignore
35
+
36
+ return trimmed;
37
+ }
38
+
39
+ /**
40
+ * Build the display label for a module, showing an upgrade arrow when an
41
+ * installed semver differs from the latest resolvable semver.
42
+ * @param {string} name
43
+ * @param {string} latestVersion
44
+ * @param {string} installedVersion
45
+ * @returns {string}
46
+ */
47
+ function buildModuleLabel(name, latestVersion, installedVersion = '') {
48
+ const latestDisplay = formatDisplayVersion(latestVersion);
49
+ if (!latestDisplay) return name;
50
+
51
+ const installedDisplay = formatDisplayVersion(installedVersion);
52
+ const latestSemver = semver.valid(semver.coerce(latestVersion || ''));
53
+ const installedSemver = semver.valid(semver.coerce(installedVersion || ''));
54
+
55
+ if (installedDisplay && latestSemver && installedSemver && semver.neq(installedSemver, latestSemver)) {
56
+ return `${name} (${installedDisplay} → ${latestDisplay})`;
29
57
  }
30
- return '';
58
+
59
+ return `${name} (${latestDisplay})`;
31
60
  }
32
61
 
33
62
  /**
34
- * Extract the highest version from marketplace.json plugins array.
35
- * Handles multiple plugins per file safely.
36
- * @param {Object} data - Parsed marketplace.json
37
- * @returns {string} Version string or empty string
63
+ * Resolve the version to show for a module picker entry. External modules use
64
+ * the same channel/tag resolver as installs; bundled modules fall back to local
65
+ * source metadata.
66
+ * @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
67
+ * @param {Object} options
68
+ * @param {string|null} [options.repoUrl] - Module repository URL for tag resolution
69
+ * @param {string|null} [options.registryDefault] - Registry default channel
70
+ * @param {Object|null} [options.channelOptions] - Parsed installer channel options
71
+ * @returns {Promise<{version: string, lookupAttempted: boolean, lookupSucceeded: boolean}>}
38
72
  */
39
- function _extractMarketplaceVersion(data) {
40
- const plugins = data?.plugins;
41
- if (!Array.isArray(plugins) || plugins.length === 0) return '';
42
- // Use the highest version across all plugins in the file
43
- let best = '';
44
- for (const p of plugins) {
45
- if (p.version && (!best || p.version > best)) best = p.version;
73
+ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault = null, channelOptions = null } = {}) {
74
+ if (repoUrl) {
75
+ const plan = decideChannelForModule({
76
+ code: moduleCode,
77
+ channelOptions,
78
+ registryDefault,
79
+ });
80
+
81
+ try {
82
+ const resolved = await channelResolver.resolveChannel({
83
+ channel: plan.channel,
84
+ pin: plan.pin,
85
+ repoUrl,
86
+ });
87
+ if (resolved?.version) {
88
+ return {
89
+ version: resolved.version,
90
+ lookupAttempted: plan.channel === 'stable',
91
+ lookupSucceeded: true,
92
+ };
93
+ }
94
+ } catch {
95
+ // Fall back to local metadata when tag resolution is unavailable.
96
+ }
46
97
  }
47
- return best;
98
+
99
+ const versionInfo = await resolveModuleVersion(moduleCode);
100
+ return {
101
+ version: versionInfo.version || '',
102
+ lookupAttempted: !!repoUrl,
103
+ lookupSucceeded: false,
104
+ };
48
105
  }
49
106
 
50
107
  /**
@@ -64,6 +121,13 @@ class UI {
64
121
  const messageLoader = new MessageLoader();
65
122
  await messageLoader.displayStartMessage();
66
123
 
124
+ // Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
125
+ // are surfaced immediately so the user sees them before any git ops run.
126
+ const channelOptions = parseChannelOptions(options);
127
+ for (const warning of channelOptions.warnings) {
128
+ await prompts.log.warn(warning);
129
+ }
130
+
67
131
  // Get directory from options or prompt
68
132
  let confirmedDirectory;
69
133
  if (options.directory) {
@@ -145,7 +209,7 @@ class UI {
145
209
  // Return early with modify configuration
146
210
  if (actionType === 'update') {
147
211
  // Get existing installation info
148
- const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
212
+ const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
149
213
 
150
214
  await prompts.log.message(`Found existing modules: ${[...installedModuleIds].join(', ')}`);
151
215
 
@@ -167,7 +231,7 @@ class UI {
167
231
  `Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`,
168
232
  );
169
233
  } else {
170
- selectedModules = await this.selectAllModules(installedModuleIds);
234
+ selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
171
235
  }
172
236
 
173
237
  // Resolve custom sources from --custom-source flag
@@ -183,10 +247,38 @@ class UI {
183
247
  selectedModules.unshift('core');
184
248
  }
185
249
 
250
+ // For existing installs, resolve per-module update decisions BEFORE
251
+ // we clone anything. Reads the existing manifest's recorded channel
252
+ // per module and prompts the user on available upgrades (patch/minor
253
+ // default Y, major default N). Legacy entries with no channel are
254
+ // migrated here too. Mutates channelOptions.pins to lock rejections.
255
+ await this._resolveUpdateChannels({
256
+ bmadDir,
257
+ selectedModules,
258
+ channelOptions,
259
+ yes: options.yes || false,
260
+ });
261
+
186
262
  // Get tool selection
187
263
  const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
188
264
 
189
- const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
265
+ const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
266
+ ...options,
267
+ channelOptions,
268
+ });
269
+
270
+ // Warn about --pin/--next flags that refer to modules the user didn't
271
+ // select, or that target bundled modules (core/bmm) where channel
272
+ // flags don't apply.
273
+ {
274
+ const bundledCodes = await this._bundledModuleCodes();
275
+ for (const warning of [
276
+ ...orphanPinWarnings(channelOptions, selectedModules),
277
+ ...bundledTargetWarnings(channelOptions, bundledCodes),
278
+ ]) {
279
+ await prompts.log.warn(warning);
280
+ }
281
+ }
190
282
 
191
283
  return {
192
284
  actionType: 'update',
@@ -197,12 +289,13 @@ class UI {
197
289
  coreConfig: moduleConfigs.core || {},
198
290
  moduleConfigs: moduleConfigs,
199
291
  skipPrompts: options.yes || false,
292
+ channelOptions,
200
293
  };
201
294
  }
202
295
  }
203
296
 
204
297
  // This section is only for new installations (update returns early above)
205
- const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
298
+ const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
206
299
 
207
300
  // Unified module selection - all modules in one grouped multiselect
208
301
  let selectedModules;
@@ -221,7 +314,7 @@ class UI {
221
314
  selectedModules = await this.getDefaultModules(installedModuleIds);
222
315
  await prompts.log.info(`Using default modules (--yes flag): ${selectedModules.join(', ')}`);
223
316
  } else {
224
- selectedModules = await this.selectAllModules(installedModuleIds);
317
+ selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
225
318
  }
226
319
 
227
320
  // Resolve custom sources from --custom-source flag
@@ -236,8 +329,31 @@ class UI {
236
329
  if (!selectedModules.includes('core')) {
237
330
  selectedModules.unshift('core');
238
331
  }
332
+
333
+ // Interactive channel gate: "Ready to install (all stable)? [Y/n]"
334
+ // Only shown for fresh installs with no channel flags and an external module
335
+ // selected. Non-interactive installs skip this and fall through to the
336
+ // registry default (stable) or whatever flags were supplied.
337
+ await this._interactiveChannelGate({ options, channelOptions, selectedModules });
338
+
239
339
  let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
240
- const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
340
+ const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
341
+ ...options,
342
+ channelOptions,
343
+ });
344
+
345
+ // Warn about --pin/--next flags that refer to modules the user didn't
346
+ // select, or that target bundled modules (core/bmm) where channel
347
+ // flags don't apply.
348
+ {
349
+ const bundledCodes = await this._bundledModuleCodes();
350
+ for (const warning of [
351
+ ...orphanPinWarnings(channelOptions, selectedModules),
352
+ ...bundledTargetWarnings(channelOptions, bundledCodes),
353
+ ]) {
354
+ await prompts.log.warn(warning);
355
+ }
356
+ }
241
357
 
242
358
  return {
243
359
  actionType: 'install',
@@ -248,6 +364,7 @@ class UI {
248
364
  coreConfig: moduleConfigs.core || {},
249
365
  moduleConfigs: moduleConfigs,
250
366
  skipPrompts: options.yes || false,
367
+ channelOptions,
251
368
  };
252
369
  }
253
370
 
@@ -496,7 +613,7 @@ class UI {
496
613
  /**
497
614
  * Get existing installation info and installed modules
498
615
  * @param {string} directory - Installation directory
499
- * @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
616
+ * @returns {Object} Object with existingInstall, installedModuleIds, installedModuleVersions, and bmadDir
500
617
  */
501
618
  async getExistingInstallation(directory) {
502
619
  const { ExistingInstall } = require('./core/existing-install');
@@ -505,8 +622,26 @@ class UI {
505
622
  const { bmadDir } = await installer.findBmadDir(directory);
506
623
  const existingInstall = await ExistingInstall.detect(bmadDir);
507
624
  const installedModuleIds = new Set(existingInstall.moduleIds);
625
+ const installedModuleVersions = new Map();
626
+ const manifestModules = await manifest.getAllModuleVersions(bmadDir);
508
627
 
509
- return { existingInstall, installedModuleIds, bmadDir };
628
+ for (const module of manifestModules) {
629
+ if (module?.name && module.version) {
630
+ installedModuleVersions.set(module.name, module.version);
631
+ }
632
+ }
633
+
634
+ for (const module of existingInstall.modules) {
635
+ if (module?.id && module.version && module.version !== 'unknown' && !installedModuleVersions.has(module.id)) {
636
+ installedModuleVersions.set(module.id, module.version);
637
+ }
638
+ }
639
+
640
+ if (existingInstall.hasCore && existingInstall.version && !installedModuleVersions.has('core')) {
641
+ installedModuleVersions.set('core', existingInstall.version);
642
+ }
643
+
644
+ return { existingInstall, installedModuleIds, installedModuleVersions, bmadDir };
510
645
  }
511
646
 
512
647
  /**
@@ -519,7 +654,7 @@ class UI {
519
654
  */
520
655
  async collectModuleConfigs(directory, modules, options = {}) {
521
656
  const { OfficialModules } = require('./modules/official-modules');
522
- const configCollector = new OfficialModules();
657
+ const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
523
658
 
524
659
  // Seed core config from CLI options if provided
525
660
  if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
@@ -587,11 +722,13 @@ class UI {
587
722
  /**
588
723
  * Select all modules across three tiers: official, community, and custom URL.
589
724
  * @param {Set} installedModuleIds - Currently installed module IDs
725
+ * @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
726
+ * @param {Object|null} channelOptions - Parsed installer channel options
590
727
  * @returns {Array} Selected module codes (excluding core)
591
728
  */
592
- async selectAllModules(installedModuleIds = new Set()) {
729
+ async selectAllModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
593
730
  // Phase 1: Official modules
594
- const officialSelected = await this._selectOfficialModules(installedModuleIds);
731
+ const officialSelected = await this._selectOfficialModules(installedModuleIds, installedModuleVersions, channelOptions);
595
732
 
596
733
  // Determine which installed modules are NOT official (community or custom).
597
734
  // These must be preserved even if the user declines to browse community/custom.
@@ -627,9 +764,11 @@ class UI {
627
764
  * Select official modules using autocompleteMultiselect.
628
765
  * Extracted from the original selectAllModules - unchanged behavior.
629
766
  * @param {Set} installedModuleIds - Currently installed module IDs
767
+ * @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
768
+ * @param {Object|null} channelOptions - Parsed installer channel options
630
769
  * @returns {Array} Selected official module codes
631
770
  */
632
- async _selectOfficialModules(installedModuleIds = new Set()) {
771
+ async _selectOfficialModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
633
772
  // Built-in modules (core, bmm) come from local source, not the registry
634
773
  const { OfficialModules } = require('./modules/official-modules');
635
774
  const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
@@ -642,15 +781,18 @@ class UI {
642
781
  const initialValues = [];
643
782
  const lockedValues = ['core'];
644
783
 
645
- const buildModuleEntry = async (code, name, description, isDefault) => {
784
+ const buildModuleEntry = async (code, name, description, isDefault, repoUrl = null, registryDefault = null) => {
646
785
  const isInstalled = installedModuleIds.has(code);
647
- const version = await getMarketplaceVersion(code);
648
- const label = version ? `${name} (v${version})` : name;
786
+ const installedVersion = installedModuleVersions.get(code) || '';
787
+ const versionState = await getModuleVersion(code, { repoUrl, registryDefault, channelOptions });
788
+ const label = buildModuleLabel(name, versionState.version, installedVersion);
649
789
  return {
650
790
  label,
651
791
  value: code,
652
792
  hint: description,
653
793
  selected: isInstalled || isDefault,
794
+ lookupAttempted: versionState.lookupAttempted,
795
+ lookupSucceeded: versionState.lookupSucceeded,
654
796
  };
655
797
  };
656
798
 
@@ -667,12 +809,38 @@ class UI {
667
809
  }
668
810
 
669
811
  // Add external registry modules (skip built-in duplicates)
670
- for (const mod of registryModules) {
671
- if (mod.builtIn || builtInCodes.has(mod.code)) continue;
672
- const entry = await buildModuleEntry(mod.code, mod.name, mod.description, mod.defaultSelected);
812
+ const externalRegistryModules = registryModules.filter((mod) => !mod.builtIn && !builtInCodes.has(mod.code));
813
+ let externalRegistryEntries = [];
814
+ if (externalRegistryModules.length > 0) {
815
+ const spinner = await prompts.spinner();
816
+ spinner.start('Checking latest module versions...');
817
+
818
+ externalRegistryEntries = await Promise.all(
819
+ externalRegistryModules.map(async (mod) => ({
820
+ code: mod.code,
821
+ entry: await buildModuleEntry(
822
+ mod.code,
823
+ mod.name,
824
+ mod.description,
825
+ mod.defaultSelected,
826
+ mod.url || null,
827
+ mod.defaultChannel || null,
828
+ ),
829
+ })),
830
+ );
831
+
832
+ spinner.stop('Checked latest module versions.');
833
+
834
+ const attemptedLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupAttempted).length;
835
+ const successfulLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupSucceeded).length;
836
+ if (attemptedLookups > 0 && successfulLookups === 0) {
837
+ await prompts.log.warn('Could not check latest module versions; showing cached/local versions.');
838
+ }
839
+ }
840
+ for (const { code, entry } of externalRegistryEntries) {
673
841
  allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
674
842
  if (entry.selected) {
675
- initialValues.push(mod.code);
843
+ initialValues.push(code);
676
844
  }
677
845
  }
678
846
 
@@ -1594,6 +1762,349 @@ class UI {
1594
1762
  });
1595
1763
  await prompts.log.message('Selected tools:\n' + toolLines.join('\n'));
1596
1764
  }
1765
+
1766
+ /**
1767
+ * Return the set of module codes the registry marks as built-in (core, bmm).
1768
+ * These ship with the installer binary and have no per-module channel.
1769
+ */
1770
+ async _bundledModuleCodes() {
1771
+ const externalManager = new ExternalModuleManager();
1772
+ try {
1773
+ const modules = await externalManager.listAvailable();
1774
+ return modules.filter((m) => m.builtIn).map((m) => m.code);
1775
+ } catch {
1776
+ // Registry unreachable — fall back to the known bundled codes.
1777
+ return ['core', 'bmm'];
1778
+ }
1779
+ }
1780
+
1781
+ /**
1782
+ * Fast-path channel gate: confirm "all stable" or open the per-module picker.
1783
+ *
1784
+ * Skipped when:
1785
+ * - running non-interactively (--yes)
1786
+ * - the user already passed channel flags (--channel / --pin / --next)
1787
+ * - no externals/community modules are selected
1788
+ *
1789
+ * Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.
1790
+ */
1791
+ async _interactiveChannelGate({ options, channelOptions, selectedModules }) {
1792
+ if (options.yes) return;
1793
+ // If the user already declared their channel intent via flags, trust them
1794
+ // and skip the gate.
1795
+ const haveFlagIntent = channelOptions.global || channelOptions.nextSet.size > 0 || channelOptions.pins.size > 0;
1796
+ if (haveFlagIntent) return;
1797
+
1798
+ // Figure out which selected modules actually get a channel (externals +
1799
+ // community modules). Bundled core/bmm and custom modules skip the picker.
1800
+ const externalManager = new ExternalModuleManager();
1801
+ const externals = await externalManager.listAvailable();
1802
+ const externalByCode = new Map(externals.map((m) => [m.code, m]));
1803
+
1804
+ const { CommunityModuleManager } = require('./modules/community-manager');
1805
+ const communityMgr = new CommunityModuleManager();
1806
+ const community = await communityMgr.listAll();
1807
+ const communityByCode = new Map(community.map((m) => [m.code, m]));
1808
+
1809
+ const channelSelectable = selectedModules.filter((code) => {
1810
+ const info = externalByCode.get(code) || communityByCode.get(code);
1811
+ return info && !info.builtIn;
1812
+ });
1813
+ if (channelSelectable.length === 0) return;
1814
+
1815
+ const fastPath = await prompts.confirm({
1816
+ message: `Ready to install (all stable)? Pick "n" to customize channels or pin versions.`,
1817
+ default: true,
1818
+ });
1819
+ if (fastPath) return; // stable for all, registry default applies
1820
+
1821
+ // Customize path: per-module picker.
1822
+ const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver');
1823
+
1824
+ for (const code of channelSelectable) {
1825
+ const info = externalByCode.get(code) || communityByCode.get(code);
1826
+ const repoUrl = info.url;
1827
+
1828
+ // Try to pre-resolve the top stable tag so we can surface it in the picker.
1829
+ let stableLabel = 'stable (released version)';
1830
+ try {
1831
+ const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
1832
+ if (parsed) {
1833
+ const tags = await fetchStableTags(parsed.owner, parsed.repo);
1834
+ if (tags.length > 0) {
1835
+ stableLabel = `stable ${tags[0].tag} (released version)`;
1836
+ }
1837
+ }
1838
+ } catch {
1839
+ // fall through with the generic label
1840
+ }
1841
+
1842
+ const choice = await prompts.select({
1843
+ message: `${code}: choose a channel`,
1844
+ choices: [
1845
+ { name: stableLabel, value: 'stable' },
1846
+ { name: 'next (main HEAD \u2014 current development)', value: 'next' },
1847
+ { name: 'pin (specific version)', value: 'pin' },
1848
+ ],
1849
+ default: 'stable',
1850
+ });
1851
+
1852
+ if (choice === 'next') {
1853
+ channelOptions.nextSet.add(code);
1854
+ } else if (choice === 'pin') {
1855
+ const pinValue = await prompts.text({
1856
+ message: `Enter a version tag for '${code}' (e.g. v1.6.0):`,
1857
+ validate: (value) => {
1858
+ if (!value || !/^[\w.\-+/]+$/.test(String(value).trim())) {
1859
+ return 'Must be a non-empty tag name (letters, digits, dots, hyphens).';
1860
+ }
1861
+ },
1862
+ });
1863
+ channelOptions.pins.set(code, String(pinValue).trim());
1864
+ }
1865
+ // 'stable' is the default; nothing to record.
1866
+ }
1867
+ }
1868
+
1869
+ /**
1870
+ * Resolve channel decisions for an update over an existing install.
1871
+ *
1872
+ * For each selected external/community module:
1873
+ * - Read the recorded channel from the existing manifest.
1874
+ * - On `stable`: query tags; if a newer stable exists, classify the diff
1875
+ * and prompt. Patch/minor default Y; major defaults N. `--yes` accepts
1876
+ * defaults (patches/minors) but NOT majors — a major under --yes stays
1877
+ * frozen unless the user also passes `--pin CODE=NEW_TAG`.
1878
+ * - On `next`: no prompt (pull HEAD).
1879
+ * - On `pinned`: no prompt (stays pinned).
1880
+ * - No channel recorded and `version: null`: one-time migration prompt
1881
+ * ("Switch to stable / Keep on next").
1882
+ *
1883
+ * Decisions that freeze the current version are applied by adding a pin to
1884
+ * `channelOptions.pins` so downstream clone logic honors them.
1885
+ */
1886
+ async _resolveUpdateChannels({ bmadDir, selectedModules, channelOptions, yes }) {
1887
+ const { Manifest } = require('./core/manifest');
1888
+ const manifestObj = new Manifest();
1889
+ const manifest = await manifestObj.read(bmadDir);
1890
+ const existingByName = new Map();
1891
+ for (const m of manifest?.modulesDetailed || []) {
1892
+ if (m?.name) existingByName.set(m.name, m);
1893
+ }
1894
+ if (existingByName.size === 0) return;
1895
+
1896
+ const externalManager = new ExternalModuleManager();
1897
+ const externals = await externalManager.listAvailable();
1898
+ const externalByCode = new Map(externals.map((m) => [m.code, m]));
1899
+
1900
+ const { CommunityModuleManager } = require('./modules/community-manager');
1901
+ const communityMgr = new CommunityModuleManager();
1902
+ const community = await communityMgr.listAll();
1903
+ const communityByCode = new Map(community.map((m) => [m.code, m]));
1904
+
1905
+ const { fetchStableTags, classifyUpgrade, releaseNotesUrl } = require('./modules/channel-resolver');
1906
+ const { parseGitHubRepo } = require('./modules/channel-resolver');
1907
+
1908
+ // Interactive-only: offer a one-time gate to review / switch channels for
1909
+ // selected modules that are already installed. Default N so normal Modify
1910
+ // flows (add/remove modules) aren't interrupted.
1911
+ let reviewChannels = false;
1912
+ if (!yes) {
1913
+ const existingWithChannel = selectedModules.filter((code) => {
1914
+ const prev = existingByName.get(code);
1915
+ if (!prev) return false;
1916
+ const info = externalByCode.get(code) || communityByCode.get(code);
1917
+ return info && !info.builtIn;
1918
+ });
1919
+ if (existingWithChannel.length > 0) {
1920
+ reviewChannels = await prompts.confirm({
1921
+ message: 'Review channel assignments (stable / next / pin) for your existing modules?',
1922
+ default: false,
1923
+ });
1924
+ }
1925
+ }
1926
+
1927
+ for (const code of selectedModules) {
1928
+ const prev = existingByName.get(code);
1929
+ if (!prev) continue;
1930
+
1931
+ const info = externalByCode.get(code) || communityByCode.get(code);
1932
+ if (!info) continue;
1933
+ // Bundled modules (core/bmm) ship with the installer binary itself —
1934
+ // their version is stapled to the CLI version, not a git tag. Skip
1935
+ // tag-API lookups for them; the "upgrade" mechanism is `npx bmad@X install`.
1936
+ if (info.builtIn) continue;
1937
+
1938
+ const repoUrl = info.url;
1939
+ const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
1940
+
1941
+ // Legacy migration: manifest carries no channel and a null/empty
1942
+ // version. Offer the one-time pick between stable and next.
1943
+ const recordedChannel = prev.channel || null;
1944
+ const needsMigration = !recordedChannel && (prev.version == null || prev.version === '');
1945
+ if (needsMigration) {
1946
+ if (yes) {
1947
+ // Conservative headless default: stable.
1948
+ continue;
1949
+ }
1950
+ const chosen = await prompts.select({
1951
+ message: `${code}: your existing install tracks the main branch. Switch to stable releases (recommended for production), or keep on main?`,
1952
+ choices: [
1953
+ { name: 'Switch to stable', value: 'stable' },
1954
+ { name: 'Keep on main (next)', value: 'next' },
1955
+ ],
1956
+ default: 'stable',
1957
+ });
1958
+ if (chosen === 'next') channelOptions.nextSet.add(code);
1959
+ continue;
1960
+ }
1961
+
1962
+ // Optional channel-switch offer. Fires only when the user opted in via
1963
+ // the gate above. 'keep' falls through to the existing per-channel
1964
+ // logic (which runs upgrade classification for stable). Any switch
1965
+ // records the new intent into channelOptions and skips upgrade prompts.
1966
+ if (reviewChannels && recordedChannel) {
1967
+ const switchChoices = [
1968
+ {
1969
+ name: `Keep on '${recordedChannel}'${prev.version ? ` @ ${prev.version}` : ''}`,
1970
+ value: 'keep',
1971
+ },
1972
+ ];
1973
+ if (recordedChannel !== 'stable') {
1974
+ switchChoices.push({ name: 'Switch to stable (released version)', value: 'stable' });
1975
+ }
1976
+ if (recordedChannel !== 'next') {
1977
+ switchChoices.push({ name: 'Switch to next (main HEAD)', value: 'next' });
1978
+ }
1979
+ switchChoices.push({ name: 'Pin to a specific version tag', value: 'pin' });
1980
+
1981
+ const choice = await prompts.select({
1982
+ message: `${code} channel:`,
1983
+ choices: switchChoices,
1984
+ default: 'keep',
1985
+ });
1986
+
1987
+ if (choice === 'next') {
1988
+ channelOptions.nextSet.add(code);
1989
+ continue;
1990
+ }
1991
+ if (choice === 'pin') {
1992
+ const pinValue = await prompts.text({
1993
+ message: `Enter a version tag for '${code}' (e.g. v1.6.0):`,
1994
+ validate: (value) => {
1995
+ if (!value || !/^[\w.\-+/]+$/.test(String(value).trim())) {
1996
+ return 'Must be a non-empty tag name (letters, digits, dots, hyphens).';
1997
+ }
1998
+ },
1999
+ });
2000
+ channelOptions.pins.set(code, String(pinValue).trim());
2001
+ continue;
2002
+ }
2003
+ if (choice === 'stable') {
2004
+ // Switch to stable: install at the top stable tag without an
2005
+ // upgrade-classification prompt (the user explicitly opted in).
2006
+ // Also warm the tag cache here so the actual clone step doesn't
2007
+ // need a second GitHub API call (can hit rate limits).
2008
+ if (parsed) {
2009
+ try {
2010
+ await fetchStableTags(parsed.owner, parsed.repo);
2011
+ } catch {
2012
+ // best effort; clone step will surface any failure
2013
+ }
2014
+ }
2015
+ continue;
2016
+ }
2017
+ // 'keep' → fall through with recordedChannel below.
2018
+ }
2019
+
2020
+ if (recordedChannel === 'pinned' || recordedChannel === 'next') {
2021
+ // Respect any explicit channel intent the user already expressed via
2022
+ // CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG) or
2023
+ // via the interactive review gate above. Only auto-re-assert the
2024
+ // recorded channel when the user hasn't opted into anything else —
2025
+ // otherwise --all-stable (or a review "switch to stable") would be
2026
+ // silently clobbered by the prior channel.
2027
+ const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
2028
+ if (!alreadyDecided) {
2029
+ if (recordedChannel === 'pinned' && prev.version) {
2030
+ channelOptions.pins.set(code, prev.version);
2031
+ } else if (recordedChannel === 'next') {
2032
+ channelOptions.nextSet.add(code);
2033
+ }
2034
+ }
2035
+ continue;
2036
+ }
2037
+
2038
+ // Stable channel: check for a newer released tag.
2039
+ if (!parsed) continue;
2040
+ // Respect explicit CLI intent (--pin / --next=CODE / --all-*) and any
2041
+ // choice the user already made in the earlier review gate. Without this
2042
+ // guard the upgrade classifier below would unconditionally call
2043
+ // `channelOptions.pins.set(code, prev.version)` on decline/major-refuse/
2044
+ // fetch-error, silently clobbering the user's override.
2045
+ const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
2046
+ if (alreadyDecided) continue;
2047
+ let tags;
2048
+ try {
2049
+ tags = await fetchStableTags(parsed.owner, parsed.repo);
2050
+ } catch (error) {
2051
+ await prompts.log.warn(`Could not check for updates on ${code} (${error.message}). Leaving at ${prev.version}.`);
2052
+ if (prev.version) channelOptions.pins.set(code, prev.version);
2053
+ continue;
2054
+ }
2055
+ if (!tags || tags.length === 0) continue;
2056
+ const topTag = tags[0].tag; // e.g. "v1.7.0"
2057
+ const currentTag = prev.version || '';
2058
+ const diffClass = classifyUpgrade(currentTag, topTag);
2059
+
2060
+ if (diffClass === 'none') continue; // already at or above top tag
2061
+
2062
+ const notes = releaseNotesUrl(repoUrl, topTag);
2063
+ let accept;
2064
+ if (diffClass === 'major') {
2065
+ if (yes) {
2066
+ // Major under --yes is refused by design.
2067
+ await prompts.log.warn(
2068
+ `${code} ${currentTag} → ${topTag} is a new major release; staying on ${currentTag}. ` +
2069
+ `To accept, rerun with --pin ${code}=${topTag}.`,
2070
+ );
2071
+ channelOptions.pins.set(code, currentTag);
2072
+ continue;
2073
+ }
2074
+ accept = await prompts.confirm({
2075
+ message:
2076
+ `${code} ${topTag} available — new major release (may change behavior).` +
2077
+ (notes ? ` Release notes: ${notes}.` : '') +
2078
+ ' Upgrade?',
2079
+ default: false,
2080
+ });
2081
+ } else if (diffClass === 'minor') {
2082
+ if (yes) {
2083
+ accept = true;
2084
+ } else {
2085
+ accept = await prompts.confirm({
2086
+ message: `${code} ${topTag} available (new features).` + (notes ? ` Release notes: ${notes}.` : '') + ' Upgrade?',
2087
+ default: true,
2088
+ });
2089
+ }
2090
+ } else {
2091
+ // patch
2092
+ if (yes) {
2093
+ accept = true;
2094
+ } else {
2095
+ accept = await prompts.confirm({
2096
+ message: `${code} ${topTag} available. Upgrade?`,
2097
+ default: true,
2098
+ });
2099
+ }
2100
+ }
2101
+
2102
+ if (!accept && currentTag) {
2103
+ // Freeze the current version by pinning it for this run.
2104
+ channelOptions.pins.set(code, currentTag);
2105
+ }
2106
+ }
2107
+ }
1597
2108
  }
1598
2109
 
1599
2110
  module.exports = { UI };