bmad-method 6.3.1-next.2 → 6.3.1-next.21

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 (160) hide show
  1. package/package.json +3 -3
  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-08-scoping.md +70 -23
  35. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/steps-c/step-11-polish.md +1 -1
  36. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/steps-c/step-12-complete.md +6 -0
  37. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/SKILL.md +70 -1
  38. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/customize.toml +41 -0
  39. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-14-complete.md +6 -0
  40. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/SKILL.md +97 -1
  41. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/customize.toml +42 -0
  42. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +2 -0
  43. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/SKILL.md +99 -1
  44. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/customize.toml +42 -0
  45. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/steps-v/step-v-13-report-complete.md +1 -0
  46. package/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md +50 -30
  47. package/src/bmm-skills/3-solutioning/bmad-agent-architect/customize.toml +65 -0
  48. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/SKILL.md +86 -1
  49. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/customize.toml +41 -0
  50. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-06-final-assessment.md +6 -0
  51. package/src/bmm-skills/3-solutioning/bmad-create-architecture/SKILL.md +69 -1
  52. package/src/bmm-skills/3-solutioning/bmad-create-architecture/customize.toml +41 -0
  53. package/src/bmm-skills/3-solutioning/bmad-create-architecture/steps/step-08-complete.md +6 -0
  54. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md +88 -1
  55. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml +41 -0
  56. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/steps/step-04-final-validation.md +6 -0
  57. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/SKILL.md +76 -1
  58. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/customize.toml +41 -0
  59. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/steps/step-03-complete.md +6 -0
  60. package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +48 -43
  61. package/src/bmm-skills/4-implementation/bmad-agent-dev/customize.toml +90 -0
  62. package/src/bmm-skills/4-implementation/bmad-correct-course/SKILL.md +296 -1
  63. package/src/bmm-skills/4-implementation/bmad-correct-course/customize.toml +41 -0
  64. package/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md +412 -1
  65. package/src/bmm-skills/4-implementation/bmad-create-story/customize.toml +41 -0
  66. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md +171 -1
  67. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/customize.toml +41 -0
  68. package/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md +1507 -1
  69. package/src/bmm-skills/4-implementation/bmad-retrospective/customize.toml +41 -0
  70. package/src/bmm-skills/module.yaml +49 -0
  71. package/src/core-skills/bmad-advanced-elicitation/SKILL.md +7 -1
  72. package/src/core-skills/bmad-customize/SKILL.md +111 -0
  73. package/src/core-skills/bmad-customize/scripts/list_customizable_skills.py +231 -0
  74. package/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py +249 -0
  75. package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +1 -1
  76. package/src/core-skills/bmad-party-mode/SKILL.md +13 -10
  77. package/src/core-skills/module-help.csv +1 -0
  78. package/src/core-skills/module.yaml +3 -0
  79. package/src/scripts/resolve_config.py +176 -0
  80. package/src/scripts/resolve_customization.py +230 -0
  81. package/tools/installer/cli-utils.js +0 -137
  82. package/tools/installer/commands/install.js +13 -0
  83. package/tools/installer/commands/status.js +1 -1
  84. package/tools/installer/commands/uninstall.js +1 -1
  85. package/tools/installer/core/config.js +4 -1
  86. package/tools/installer/core/existing-install.js +1 -1
  87. package/tools/installer/core/install-paths.js +12 -6
  88. package/tools/installer/core/installer.js +182 -95
  89. package/tools/installer/core/manifest-generator.js +347 -190
  90. package/tools/installer/core/manifest.js +49 -642
  91. package/tools/installer/file-ops.js +1 -1
  92. package/tools/installer/fs-native.js +116 -0
  93. package/tools/installer/ide/_config-driven.js +1 -1
  94. package/tools/installer/ide/platform-codes.js +1 -1
  95. package/tools/installer/ide/shared/path-utils.js +0 -145
  96. package/tools/installer/ide/shared/skill-manifest.js +1 -1
  97. package/tools/installer/message-loader.js +1 -1
  98. package/tools/installer/modules/channel-plan.js +203 -0
  99. package/tools/installer/modules/channel-resolver.js +241 -0
  100. package/tools/installer/modules/community-manager.js +131 -24
  101. package/tools/installer/modules/custom-module-manager.js +161 -47
  102. package/tools/installer/modules/external-manager.js +236 -73
  103. package/tools/installer/modules/official-modules.js +61 -63
  104. package/tools/installer/modules/plugin-resolver.js +1 -1
  105. package/tools/installer/modules/registry-client.js +133 -12
  106. package/tools/installer/modules/registry-fallback.yaml +8 -0
  107. package/tools/installer/modules/version-resolver.js +336 -0
  108. package/tools/installer/project-root.js +55 -1
  109. package/tools/installer/prompts.js +0 -106
  110. package/tools/installer/ui.js +457 -51
  111. package/tools/migrate-custom-module-paths.js +1 -1
  112. package/src/bmm-skills/1-analysis/bmad-agent-analyst/bmad-skill-manifest.yaml +0 -11
  113. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/bmad-skill-manifest.yaml +0 -11
  114. package/src/bmm-skills/1-analysis/bmad-document-project/workflow.md +0 -25
  115. package/src/bmm-skills/1-analysis/research/bmad-domain-research/workflow.md +0 -51
  116. package/src/bmm-skills/1-analysis/research/bmad-market-research/workflow.md +0 -51
  117. package/src/bmm-skills/1-analysis/research/bmad-technical-research/workflow.md +0 -52
  118. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/bmad-skill-manifest.yaml +0 -11
  119. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/bmad-skill-manifest.yaml +0 -11
  120. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/workflow.md +0 -61
  121. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/workflow.md +0 -35
  122. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/workflow.md +0 -62
  123. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/workflow.md +0 -61
  124. package/src/bmm-skills/3-solutioning/bmad-agent-architect/bmad-skill-manifest.yaml +0 -11
  125. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +0 -47
  126. package/src/bmm-skills/3-solutioning/bmad-create-architecture/workflow.md +0 -32
  127. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +0 -51
  128. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/workflow.md +0 -39
  129. package/src/bmm-skills/4-implementation/bmad-agent-dev/bmad-skill-manifest.yaml +0 -11
  130. package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +0 -267
  131. package/src/bmm-skills/4-implementation/bmad-create-story/workflow.md +0 -380
  132. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/workflow.md +0 -136
  133. package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +0 -1479
  134. package/tools/installer/ide/shared/agent-command-generator.js +0 -180
  135. package/tools/installer/ide/shared/bmad-artifacts.js +0 -208
  136. package/tools/installer/ide/shared/module-injections.js +0 -136
  137. package/tools/installer/ide/templates/agent-command-template.md +0 -14
  138. package/tools/installer/ide/templates/combined/antigravity.md +0 -8
  139. package/tools/installer/ide/templates/combined/default-agent.md +0 -15
  140. package/tools/installer/ide/templates/combined/default-task.md +0 -10
  141. package/tools/installer/ide/templates/combined/default-tool.md +0 -10
  142. package/tools/installer/ide/templates/combined/default-workflow.md +0 -6
  143. package/tools/installer/ide/templates/combined/gemini-agent.toml +0 -14
  144. package/tools/installer/ide/templates/combined/gemini-task.toml +0 -11
  145. package/tools/installer/ide/templates/combined/gemini-tool.toml +0 -11
  146. package/tools/installer/ide/templates/combined/gemini-workflow-yaml.toml +0 -16
  147. package/tools/installer/ide/templates/combined/gemini-workflow.toml +0 -14
  148. package/tools/installer/ide/templates/combined/kiro-agent.md +0 -16
  149. package/tools/installer/ide/templates/combined/kiro-task.md +0 -9
  150. package/tools/installer/ide/templates/combined/kiro-tool.md +0 -9
  151. package/tools/installer/ide/templates/combined/kiro-workflow.md +0 -7
  152. package/tools/installer/ide/templates/combined/opencode-agent.md +0 -15
  153. package/tools/installer/ide/templates/combined/opencode-task.md +0 -13
  154. package/tools/installer/ide/templates/combined/opencode-tool.md +0 -13
  155. package/tools/installer/ide/templates/combined/opencode-workflow-yaml.md +0 -16
  156. package/tools/installer/ide/templates/combined/opencode-workflow.md +0 -16
  157. package/tools/installer/ide/templates/combined/rovodev.md +0 -9
  158. package/tools/installer/ide/templates/combined/trae.md +0 -9
  159. package/tools/installer/ide/templates/combined/windsurf-workflow.md +0 -10
  160. package/tools/installer/ide/templates/split/.gitkeep +0 -0
@@ -1,12 +1,54 @@
1
- const fs = require('fs-extra');
1
+ const fs = require('../fs-native');
2
2
  const os = require('node:os');
3
3
  const path = require('node:path');
4
4
  const { execSync } = require('node:child_process');
5
5
  const yaml = require('yaml');
6
6
  const prompts = require('../prompts');
7
7
  const { RegistryClient } = require('./registry-client');
8
+ const { resolveChannel, tagExists, parseGitHubRepo } = require('./channel-resolver');
9
+ const { decideChannelForModule } = require('./channel-plan');
8
10
 
9
- const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
11
+ const VALID_CHANNELS = new Set(['stable', 'next', 'pinned']);
12
+
13
+ function normalizeChannelName(raw) {
14
+ if (typeof raw !== 'string') return null;
15
+ const lower = raw.trim().toLowerCase();
16
+ return VALID_CHANNELS.has(lower) ? lower : null;
17
+ }
18
+
19
+ /**
20
+ * Conservative quoting for tag names passed to git commands. Tags are
21
+ * user-typed (--pin) or come from the GitHub API. Only allow the semver
22
+ * character class we use to tag BMad releases; anything else throws.
23
+ */
24
+ function quoteShell(ref) {
25
+ if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
26
+ throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
27
+ }
28
+ return `"${ref}"`;
29
+ }
30
+
31
+ async function readChannelMarker(markerPath) {
32
+ try {
33
+ if (!(await fs.pathExists(markerPath))) return null;
34
+ const content = await fs.readFile(markerPath, 'utf8');
35
+ return JSON.parse(content);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function writeChannelMarker(markerPath, data) {
42
+ try {
43
+ await fs.writeFile(markerPath, JSON.stringify({ ...data, writtenAt: new Date().toISOString() }, null, 2));
44
+ } catch {
45
+ // Best-effort: marker is an optimization, not a correctness requirement.
46
+ }
47
+ }
48
+
49
+ const MARKETPLACE_OWNER = 'bmad-code-org';
50
+ const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
51
+ const MARKETPLACE_REF = 'main';
10
52
  const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
11
53
 
12
54
  /**
@@ -17,10 +59,25 @@ const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
17
59
  * @class ExternalModuleManager
18
60
  */
19
61
  class ExternalModuleManager {
62
+ // moduleCode → { channel, version, ref, sha, repoUrl, resolvedFallback }
63
+ // Populated when cloneExternalModule resolves a channel. Shared across all
64
+ // instances so the manifest writer (which often instantiates a fresh
65
+ // ExternalModuleManager) sees resolutions made during install.
66
+ static _resolutions = new Map();
67
+
20
68
  constructor() {
21
69
  this._client = new RegistryClient();
22
70
  }
23
71
 
72
+ /**
73
+ * Get the most recent channel resolution for a module (if any).
74
+ * @param {string} moduleCode
75
+ * @returns {Object|null}
76
+ */
77
+ getResolution(moduleCode) {
78
+ return ExternalModuleManager._resolutions.get(moduleCode) || null;
79
+ }
80
+
24
81
  /**
25
82
  * Load the official modules registry from GitHub, falling back to the
26
83
  * bundled YAML file if the fetch fails.
@@ -33,8 +90,7 @@ class ExternalModuleManager {
33
90
 
34
91
  // Try remote registry first
35
92
  try {
36
- const content = await this._client.fetch(REGISTRY_RAW_URL);
37
- const config = yaml.parse(content);
93
+ const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF);
38
94
  if (config?.modules?.length) {
39
95
  this.cachedModules = config;
40
96
  return config;
@@ -74,6 +130,7 @@ class ExternalModuleManager {
74
130
  defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
75
131
  type: mod.type || 'bmad-org',
76
132
  npmPackage: mod.npm_package || mod.npmPackage || null,
133
+ defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
77
134
  builtIn: mod.built_in === true,
78
135
  isExternal: mod.built_in !== true,
79
136
  };
@@ -109,46 +166,6 @@ class ExternalModuleManager {
109
166
  return modules.find((m) => m.code === code) || null;
110
167
  }
111
168
 
112
- /**
113
- * Get module info by key
114
- * @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite')
115
- * @returns {Object|null} Module info or null if not found
116
- */
117
- async getModuleByKey(key) {
118
- const modules = await this.listAvailable();
119
- return modules.find((m) => m.key === key) || null;
120
- }
121
-
122
- /**
123
- * Check if a module code exists in external modules
124
- * @param {string} code - The module code to check
125
- * @returns {boolean} True if the module exists
126
- */
127
- async hasModule(code) {
128
- const module = await this.getModuleByCode(code);
129
- return module !== null;
130
- }
131
-
132
- /**
133
- * Get the URL for a module by code
134
- * @param {string} code - The module code
135
- * @returns {string|null} The URL or null if not found
136
- */
137
- async getModuleUrl(code) {
138
- const module = await this.getModuleByCode(code);
139
- return module ? module.url : null;
140
- }
141
-
142
- /**
143
- * Get the module definition path for a module by code
144
- * @param {string} code - The module code
145
- * @returns {string|null} The module definition path or null if not found
146
- */
147
- async getModuleDefinition(code) {
148
- const module = await this.getModuleByCode(code);
149
- return module ? module.moduleDefinition : null;
150
- }
151
-
152
169
  /**
153
170
  * Get the cache directory for external modules
154
171
  * @returns {string} Path to the external modules cache directory
@@ -159,10 +176,15 @@ class ExternalModuleManager {
159
176
  }
160
177
 
161
178
  /**
162
- * Clone an external module repository to cache
179
+ * Clone an external module repository to cache, resolving the requested
180
+ * channel (stable / next / pinned) to a concrete git ref.
181
+ *
163
182
  * @param {string} moduleCode - Code of the external module
164
183
  * @param {Object} options - Clone options
165
- * @param {boolean} options.silent - Suppress spinner output
184
+ * @param {boolean} [options.silent] - Suppress spinner output
185
+ * @param {Object} [options.channelOptions] - Parsed channel flags. See
186
+ * modules/channel-plan.js. When absent, the module installs on its
187
+ * registry-declared default channel (typically 'stable').
166
188
  * @returns {string} Path to the cloned repository
167
189
  */
168
190
  async cloneExternalModule(moduleCode, options = {}) {
@@ -200,38 +222,160 @@ class ExternalModuleManager {
200
222
  return await prompts.spinner();
201
223
  };
202
224
 
203
- // Track if we need to install dependencies
225
+ // ─── Resolve channel plan ─────────────────────────────────────────────
226
+ // Post-install callers (config generation, directory setup, help catalog
227
+ // rebuild) invoke findModuleSource/cloneExternalModule without
228
+ // channelOptions just to locate the module's files. Those calls must not
229
+ // redecide the channel — the install step already chose one, cloned the
230
+ // right ref, and recorded a resolution. If we re-resolve without flags,
231
+ // we'd snap back to stable and overwrite a pinned install.
232
+ const hasExplicitChannelInput =
233
+ options.channelOptions &&
234
+ (options.channelOptions.global ||
235
+ (options.channelOptions.nextSet && options.channelOptions.nextSet.size > 0) ||
236
+ (options.channelOptions.pins && options.channelOptions.pins.size > 0));
237
+ const existingResolution = ExternalModuleManager._resolutions.get(moduleCode);
238
+ const haveUsableCache = await fs.pathExists(moduleCacheDir);
239
+
240
+ if (!hasExplicitChannelInput && existingResolution && haveUsableCache) {
241
+ // This is a look-up only; the module is already installed at its chosen
242
+ // ref. Skip cloning and return the cached path unchanged.
243
+ return moduleCacheDir;
244
+ }
245
+
246
+ const planEntry = decideChannelForModule({
247
+ code: moduleCode,
248
+ channelOptions: options.channelOptions,
249
+ registryDefault: moduleInfo.defaultChannel,
250
+ });
251
+
252
+ // Same-plan short-circuit: a single install calls cloneExternalModule
253
+ // several times (config collection, directory setup, help-catalog rebuild)
254
+ // with the same channelOptions. The first call resolves + clones; later
255
+ // calls with an identical plan and a valid cache should return immediately
256
+ // instead of re-running resolveChannel() and `git fetch` (slow; can fail
257
+ // on flaky networks even though the tagCache dedupes the GitHub API hit).
258
+ if (existingResolution && haveUsableCache && existingResolution.channel === planEntry.channel) {
259
+ const samePin = planEntry.channel !== 'pinned' || existingResolution.version === planEntry.pin;
260
+ if (samePin) return moduleCacheDir;
261
+ }
262
+
263
+ let resolved;
264
+ try {
265
+ resolved = await resolveChannel({
266
+ channel: planEntry.channel,
267
+ pin: planEntry.pin,
268
+ repoUrl: moduleInfo.url,
269
+ });
270
+ } catch (error) {
271
+ // Tag-API failure (rate limit, transient network). If we already have
272
+ // a usable cache at a recorded ref, treat this as "couldn't check for
273
+ // updates" and re-use the cached version silently — that's the right
274
+ // call for an update/quick-update, since the semantics don't change
275
+ // and the user isn't worse off than before they ran this command.
276
+ const cachedMarker = await readChannelMarker(path.join(moduleCacheDir, '.bmad-channel.json'));
277
+ if (cachedMarker?.channel && (await fs.pathExists(moduleCacheDir))) {
278
+ if (!silent) {
279
+ await prompts.log.warn(
280
+ `Could not check for updates to ${moduleInfo.name} (${error.message}); using cached ${cachedMarker.version || cachedMarker.channel}.`,
281
+ );
282
+ }
283
+ ExternalModuleManager._resolutions.set(moduleCode, {
284
+ channel: cachedMarker.channel,
285
+ version: cachedMarker.version || 'main',
286
+ ref: cachedMarker.version && cachedMarker.version !== 'main' ? cachedMarker.version : null,
287
+ sha: cachedMarker.sha,
288
+ repoUrl: moduleInfo.url,
289
+ resolvedFallback: false,
290
+ planSource: 'cached',
291
+ });
292
+ return moduleCacheDir;
293
+ }
294
+ // No cache to fall back on — this is effectively a fresh install with
295
+ // no offline safety net. Surface a clear error with actionable guidance.
296
+ const isRateLimited = /rate limit/i.test(error.message);
297
+ const hint = isRateLimited
298
+ ? process.env.GITHUB_TOKEN
299
+ ? 'Your GITHUB_TOKEN may have expired or been rate-limited on its own budget. Try a different token or wait for the reset.'
300
+ : 'Set a GITHUB_TOKEN env var (any personal access token with public-repo read) to raise the 60-req/hour anonymous limit.'
301
+ : `Check your network connection, or rerun with \`--next=${moduleCode}\` / \`--pin ${moduleCode}=<tag>\` to skip the tag lookup.`;
302
+ throw new Error(`Could not resolve stable tag for '${moduleCode}' (${error.message}). ${hint}`);
303
+ }
304
+
305
+ if (resolved.resolvedFallback && !silent) {
306
+ if (resolved.reason === 'no-stable-tags') {
307
+ await prompts.log.warn(`No stable releases found for ${moduleInfo.name}; installing from main.`);
308
+ } else if (resolved.reason === 'not-a-github-url') {
309
+ await prompts.log.warn(`Cannot determine stable tags for ${moduleInfo.name} (non-GitHub URL); installing from main.`);
310
+ }
311
+ }
312
+
313
+ // Validate pin before we burn time cloning. Best-effort: skip on non-GitHub URLs.
314
+ if (planEntry.channel === 'pinned') {
315
+ const parsed = parseGitHubRepo(moduleInfo.url);
316
+ if (parsed) {
317
+ try {
318
+ const exists = await tagExists(parsed.owner, parsed.repo, planEntry.pin);
319
+ if (!exists) {
320
+ throw new Error(`Tag '${planEntry.pin}' not found in ${parsed.owner}/${parsed.repo}.`);
321
+ }
322
+ } catch (error) {
323
+ if (error.message?.includes('not found')) throw error;
324
+ // Network hiccup on tag verification — let the clone attempt fail clearly.
325
+ }
326
+ }
327
+ }
328
+
329
+ // ─── Clone or update cache by resolved channel ────────────────────────
330
+ const markerPath = path.join(moduleCacheDir, '.bmad-channel.json');
331
+ const currentMarker = await readChannelMarker(markerPath);
332
+ const needsChannelReset = currentMarker && currentMarker.channel !== resolved.channel;
333
+
204
334
  let needsDependencyInstall = false;
205
335
  let wasNewClone = false;
206
336
 
207
- // Check if already cloned
337
+ if (needsChannelReset && (await fs.pathExists(moduleCacheDir))) {
338
+ // Channel changed (e.g. user switched stable→next). Blow away and re-clone
339
+ // to avoid tangling shallow clones of different refs.
340
+ await fs.remove(moduleCacheDir);
341
+ }
342
+
208
343
  if (await fs.pathExists(moduleCacheDir)) {
209
- // Try to update if it's a git repo
344
+ // Cache exists on the right channel. Refresh the ref.
210
345
  const fetchSpinner = await createSpinner();
211
346
  fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
212
347
  try {
213
- const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
214
- // Fetch and reset to remote - works better with shallow clones than pull
215
- execSync('git fetch origin --depth 1', {
216
- cwd: moduleCacheDir,
217
- stdio: ['ignore', 'pipe', 'pipe'],
218
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
219
- });
220
- execSync('git reset --hard origin/HEAD', {
221
- cwd: moduleCacheDir,
222
- stdio: ['ignore', 'pipe', 'pipe'],
223
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
224
- });
225
- const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
348
+ const currentSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
226
349
 
227
- fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
228
- // Force dependency install if we got new code
229
- if (currentRef !== newRef) {
230
- needsDependencyInstall = true;
350
+ if (resolved.channel === 'next') {
351
+ execSync('git fetch origin --depth 1', {
352
+ cwd: moduleCacheDir,
353
+ stdio: ['ignore', 'pipe', 'pipe'],
354
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
355
+ });
356
+ execSync('git reset --hard origin/HEAD', {
357
+ cwd: moduleCacheDir,
358
+ stdio: ['ignore', 'pipe', 'pipe'],
359
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
360
+ });
361
+ } else {
362
+ // stable or pinned — fetch the specific tag and check it out.
363
+ execSync(`git fetch --depth 1 origin tag ${quoteShell(resolved.ref)} --no-tags`, {
364
+ cwd: moduleCacheDir,
365
+ stdio: ['ignore', 'pipe', 'pipe'],
366
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
367
+ });
368
+ execSync(`git checkout --quiet FETCH_HEAD`, {
369
+ cwd: moduleCacheDir,
370
+ stdio: ['ignore', 'pipe', 'pipe'],
371
+ });
231
372
  }
373
+
374
+ const newSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
375
+ fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
376
+ if (currentSha !== newSha) needsDependencyInstall = true;
232
377
  } catch {
233
378
  fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
234
- // If update fails, remove and re-clone
235
379
  await fs.remove(moduleCacheDir);
236
380
  wasNewClone = true;
237
381
  }
@@ -239,22 +383,41 @@ class ExternalModuleManager {
239
383
  wasNewClone = true;
240
384
  }
241
385
 
242
- // Clone if not exists or was removed
243
386
  if (wasNewClone) {
244
387
  const fetchSpinner = await createSpinner();
245
388
  fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
246
389
  try {
247
- execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
248
- stdio: ['ignore', 'pipe', 'pipe'],
249
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
250
- });
390
+ if (resolved.channel === 'next') {
391
+ execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
392
+ stdio: ['ignore', 'pipe', 'pipe'],
393
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
394
+ });
395
+ } else {
396
+ execSync(`git clone --depth 1 --branch ${quoteShell(resolved.ref)} "${moduleInfo.url}" "${moduleCacheDir}"`, {
397
+ stdio: ['ignore', 'pipe', 'pipe'],
398
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
399
+ });
400
+ }
251
401
  fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
252
402
  } catch (error) {
253
403
  fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
254
- throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
404
+ throw new Error(`Failed to clone external module '${moduleCode}' at ${resolved.version}: ${error.message}`);
255
405
  }
256
406
  }
257
407
 
408
+ // Record resolution (channel + tag + SHA) for the manifest writer to pick up.
409
+ const sha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
410
+ ExternalModuleManager._resolutions.set(moduleCode, {
411
+ channel: resolved.channel,
412
+ version: resolved.version,
413
+ ref: resolved.ref,
414
+ sha,
415
+ repoUrl: moduleInfo.url,
416
+ resolvedFallback: !!resolved.resolvedFallback,
417
+ planSource: planEntry.source,
418
+ });
419
+ await writeChannelMarker(markerPath, { channel: resolved.channel, version: resolved.version, sha });
420
+
258
421
  // Install dependencies if package.json exists
259
422
  const packageJsonPath = path.join(moduleCacheDir, 'package.json');
260
423
  const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
@@ -1,5 +1,5 @@
1
1
  const path = require('node:path');
2
- const fs = require('fs-extra');
2
+ const fs = require('../fs-native');
3
3
  const yaml = require('yaml');
4
4
  const prompts = require('../prompts');
5
5
  const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
@@ -12,7 +12,14 @@ class OfficialModules {
12
12
  // Config collection state (merged from ConfigCollector)
13
13
  this.collectedConfig = {};
14
14
  this._existingConfig = null;
15
+ // Tracked during interactive config collection so {directory_name}
16
+ // placeholder defaults can be resolved in buildQuestion().
15
17
  this.currentProjectDir = null;
18
+ // Install-time channel flag state. Set by Config.build once, then used as
19
+ // the default for every findModuleSource/cloneExternalModule call so that
20
+ // pre-install config collection and the install step agree on which ref
21
+ // to clone.
22
+ this.channelOptions = options.channelOptions || null;
16
23
  }
17
24
 
18
25
  /**
@@ -36,7 +43,7 @@ class OfficialModules {
36
43
  * @returns {OfficialModules}
37
44
  */
38
45
  static async build(config, paths) {
39
- const instance = new OfficialModules();
46
+ const instance = new OfficialModules({ channelOptions: config.channelOptions });
40
47
 
41
48
  // Pre-collected by UI or quickUpdate — store and load existing for path-change detection
42
49
  if (config.moduleConfigs) {
@@ -194,6 +201,12 @@ class OfficialModules {
194
201
  * @returns {string|null} Path to the module source or null if not found
195
202
  */
196
203
  async findModuleSource(moduleCode, options = {}) {
204
+ // Inherit channelOptions from the install-scoped instance when the caller
205
+ // didn't pass one explicitly. Keeps pre-install config collection and the
206
+ // actual install step looking at the same git ref.
207
+ if (options.channelOptions === undefined && this.channelOptions) {
208
+ options = { ...options, channelOptions: this.channelOptions };
209
+ }
197
210
  const projectRoot = getProjectRoot();
198
211
 
199
212
  // Check for core module (directly under src/core-skills)
@@ -212,13 +225,13 @@ class OfficialModules {
212
225
  }
213
226
  }
214
227
 
215
- // Check external official modules
228
+ // Check external official modules (pass channelOptions so channel plan applies)
216
229
  const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
217
230
  if (externalSource) {
218
231
  return externalSource;
219
232
  }
220
233
 
221
- // Check community modules
234
+ // Check community modules (pass channelOptions for --next/--pin overrides)
222
235
  const { CommunityModuleManager } = require('./community-manager');
223
236
  const communityMgr = new CommunityModuleManager();
224
237
  const communitySource = await communityMgr.findModuleSource(moduleCode, options);
@@ -256,7 +269,10 @@ class OfficialModules {
256
269
  return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
257
270
  }
258
271
 
259
- const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
272
+ const sourcePath = await this.findModuleSource(moduleName, {
273
+ silent: options.silent,
274
+ channelOptions: options.channelOptions,
275
+ });
260
276
  const targetPath = path.join(bmadDir, moduleName);
261
277
 
262
278
  if (!sourcePath) {
@@ -279,11 +295,24 @@ class OfficialModules {
279
295
  const manifestObj = new Manifest();
280
296
  const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
281
297
 
298
+ // Pick up channel resolution recorded by whichever manager did the clone.
299
+ const externalResolution = this.externalModuleManager.getResolution(moduleName);
300
+ let communityResolution = null;
301
+ if (!externalResolution) {
302
+ const { CommunityModuleManager } = require('./community-manager');
303
+ communityResolution = new CommunityModuleManager().getResolution(moduleName);
304
+ }
305
+ const resolution = externalResolution || communityResolution;
306
+
282
307
  await manifestObj.addModule(bmadDir, moduleName, {
283
- version: versionInfo.version,
308
+ version: resolution?.version || versionInfo.version,
284
309
  source: versionInfo.source,
285
310
  npmPackage: versionInfo.npmPackage,
286
311
  repoUrl: versionInfo.repoUrl,
312
+ channel: resolution?.channel,
313
+ sha: resolution?.sha,
314
+ registryApprovedTag: communityResolution?.registryApprovedTag,
315
+ registryApprovedSha: communityResolution?.registryApprovedSha,
287
316
  });
288
317
 
289
318
  return { success: true, module: moduleName, path: targetPath, versionInfo };
@@ -331,18 +360,37 @@ class OfficialModules {
331
360
  await this.createModuleDirectories(resolved.code, bmadDir, options);
332
361
  }
333
362
 
334
- // Update manifest
363
+ // Update manifest. For custom modules, derive channel from the git ref:
364
+ // cloneRef present → pinned at that ref
365
+ // cloneRef absent → next (main HEAD)
366
+ // local path → no channel concept
335
367
  const { Manifest } = require('../core/manifest');
336
368
  const manifestObj = new Manifest();
337
369
 
338
- await manifestObj.addModule(bmadDir, resolved.code, {
339
- version: resolved.version || null,
370
+ const hasGitClone = !!resolved.repoUrl;
371
+ const manifestEntry = {
372
+ version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
340
373
  source: 'custom',
341
374
  npmPackage: null,
342
375
  repoUrl: resolved.repoUrl || null,
343
- });
376
+ };
377
+ if (hasGitClone) {
378
+ manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
379
+ if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
380
+ if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
381
+ }
382
+ if (resolved.localPath) manifestEntry.localPath = resolved.localPath;
383
+ await manifestObj.addModule(bmadDir, resolved.code, manifestEntry);
344
384
 
345
- return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } };
385
+ return {
386
+ success: true,
387
+ module: resolved.code,
388
+ path: targetPath,
389
+ // Match the manifestEntry.version expression above so downstream summary
390
+ // lines show the cloned ref (tag or 'main') instead of the on-disk
391
+ // package.json version for git-backed custom installs.
392
+ versionInfo: { version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || '') },
393
+ };
346
394
  }
347
395
 
348
396
  /**
@@ -500,32 +548,6 @@ class OfficialModules {
500
548
  }
501
549
  }
502
550
 
503
- /**
504
- * Find all .md agent files recursively in a directory
505
- * @param {string} dir - Directory to search
506
- * @returns {Array} List of .md agent file paths
507
- */
508
- async findAgentMdFiles(dir) {
509
- const agentFiles = [];
510
-
511
- async function searchDirectory(searchDir) {
512
- const entries = await fs.readdir(searchDir, { withFileTypes: true });
513
-
514
- for (const entry of entries) {
515
- const fullPath = path.join(searchDir, entry.name);
516
-
517
- if (entry.isFile() && entry.name.endsWith('.md')) {
518
- agentFiles.push(fullPath);
519
- } else if (entry.isDirectory()) {
520
- await searchDirectory(fullPath);
521
- }
522
- }
523
- }
524
-
525
- await searchDirectory(dir);
526
- return agentFiles;
527
- }
528
-
529
551
  /**
530
552
  * Create directories declared in module.yaml's `directories` key
531
553
  * This replaces the security-risky module installer pattern with declarative config
@@ -699,29 +721,6 @@ class OfficialModules {
699
721
  return { createdDirs, movedDirs, createdWdsFolders };
700
722
  }
701
723
 
702
- /**
703
- * Private: Process module configuration
704
- * @param {string} modulePath - Path to installed module
705
- * @param {string} moduleName - Module name
706
- */
707
- async processModuleConfig(modulePath, moduleName) {
708
- const configPath = path.join(modulePath, 'config.yaml');
709
-
710
- if (await fs.pathExists(configPath)) {
711
- try {
712
- let configContent = await fs.readFile(configPath, 'utf8');
713
-
714
- // Replace path placeholders
715
- configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
716
- configContent = configContent.replaceAll('{module}', moduleName);
717
-
718
- await fs.writeFile(configPath, configContent, 'utf8');
719
- } catch (error) {
720
- await prompts.log.warn(`Failed to process module config: ${error.message}`);
721
- }
722
- }
723
- }
724
-
725
724
  /**
726
725
  * Private: Sync module files (preserving user modifications)
727
726
  * @param {string} sourcePath - Source module path
@@ -867,10 +866,10 @@ class OfficialModules {
867
866
  let foundAny = false;
868
867
  const entries = await fs.readdir(bmadDir, { withFileTypes: true });
869
868
 
869
+ const nonModuleDirs = new Set(['_config', '_memory', 'memory', 'docs', 'scripts', 'custom']);
870
870
  for (const entry of entries) {
871
871
  if (entry.isDirectory()) {
872
- // Skip the _config directory - it's for system use
873
- if (entry.name === '_config' || entry.name === '_memory') {
872
+ if (nonModuleDirs.has(entry.name)) {
874
873
  continue;
875
874
  }
876
875
 
@@ -1091,7 +1090,6 @@ class OfficialModules {
1091
1090
  */
1092
1091
  async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) {
1093
1092
  this.currentProjectDir = projectDir;
1094
-
1095
1093
  // Load existing config if not already loaded
1096
1094
  if (!this._existingConfig) {
1097
1095
  await this.loadExistingConfig(projectDir);
@@ -1,4 +1,4 @@
1
- const fs = require('fs-extra');
1
+ const fs = require('../fs-native');
2
2
  const path = require('node:path');
3
3
  const yaml = require('yaml');
4
4