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
@@ -4,6 +4,13 @@ const path = require('node:path');
4
4
  const { execSync } = require('node:child_process');
5
5
  const prompts = require('../prompts');
6
6
 
7
+ function quoteCustomRef(ref) {
8
+ if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
9
+ throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
10
+ }
11
+ return `"${ref}"`;
12
+ }
13
+
7
14
  /**
8
15
  * Manages custom modules installed from user-provided sources.
9
16
  * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
@@ -38,8 +45,8 @@ class CustomModuleManager {
38
45
  };
39
46
  }
40
47
 
41
- const trimmed = input.trim();
42
- if (!trimmed) {
48
+ const trimmedRaw = input.trim();
49
+ if (!trimmedRaw) {
43
50
  return {
44
51
  type: null,
45
52
  cloneUrl: null,
@@ -52,8 +59,53 @@ class CustomModuleManager {
52
59
  };
53
60
  }
54
61
 
62
+ // Extract optional @<tag-or-branch> suffix from the end of the input.
63
+ // Semver-valid characters: letters, digits, dot, hyphen, underscore, plus, slash.
64
+ // Raw commit SHAs are NOT supported here — `git clone --branch` can't take
65
+ // them; use --pin at the module level or check out the SHA manually.
66
+ // Only strip when the tail looks like a ref, so we don't disturb
67
+ // URLs without a version spec or the SSH protocol's `git@host:...` prefix.
68
+ let trimmed = trimmedRaw;
69
+ let versionSuffix = null;
70
+ const lastAt = trimmedRaw.lastIndexOf('@');
71
+ // Skip if @ is part of git@github.com:... (first char cannot be stripped as version)
72
+ // and skip if @ appears before the path rather than after a ref-shaped tail.
73
+ if (lastAt > 0) {
74
+ const candidate = trimmedRaw.slice(lastAt + 1);
75
+ const before = trimmedRaw.slice(0, lastAt);
76
+ // candidate must be ref-shaped and must not itself look like a URL / SSH host
77
+ if (/^[\w.\-+/]+$/.test(candidate) && !candidate.includes(':')) {
78
+ // Avoid consuming the @ in `git@host:owner/repo` — `before` wouldn't end with a path separator
79
+ // in that case. Require that the @ comes after the host/path, not inside the auth segment.
80
+ // Rule: the @ is a version suffix only if `before` looks like a complete URL or local path.
81
+ const beforeLooksLikeRepo =
82
+ before.startsWith('/') ||
83
+ before.startsWith('./') ||
84
+ before.startsWith('../') ||
85
+ before.startsWith('~') ||
86
+ /^https?:\/\//i.test(before) ||
87
+ /^git@[^:]+:.+/.test(before);
88
+ if (beforeLooksLikeRepo) {
89
+ versionSuffix = candidate;
90
+ trimmed = before;
91
+ }
92
+ }
93
+ }
94
+
55
95
  // Local path detection: starts with /, ./, ../, or ~
56
96
  if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~')) {
97
+ if (versionSuffix) {
98
+ return {
99
+ type: 'local',
100
+ cloneUrl: null,
101
+ subdir: null,
102
+ localPath: null,
103
+ cacheKey: null,
104
+ displayName: null,
105
+ isValid: false,
106
+ error: 'Local paths do not support @version suffixes',
107
+ };
108
+ }
57
109
  return this._parseLocalPath(trimmed);
58
110
  }
59
111
 
@@ -66,6 +118,8 @@ class CustomModuleManager {
66
118
  cloneUrl: trimmed,
67
119
  subdir: null,
68
120
  localPath: null,
121
+ version: versionSuffix || null,
122
+ rawInput: trimmedRaw,
69
123
  cacheKey: `${host}/${owner}/${repo}`,
70
124
  displayName: `${owner}/${repo}`,
71
125
  isValid: true,
@@ -79,29 +133,47 @@ class CustomModuleManager {
79
133
  const [, host, owner, repo, remainder] = httpsMatch;
80
134
  const cloneUrl = `https://${host}/${owner}/${repo}`;
81
135
  let subdir = null;
136
+ let urlRef = null; // branch/tag extracted from /tree/<ref>/subdir
82
137
 
83
138
  if (remainder) {
84
139
  // Extract subdir from deep path patterns used by various Git hosts
85
140
  const deepPathPatterns = [
86
- /^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path
87
- /^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree)
88
- /^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
141
+ { regex: /^\/(?:-\/)?tree\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // GitHub, GitLab
142
+ { regex: /^\/(?:-\/)?blob\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 },
143
+ { regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
89
144
  ];
145
+ // Also match `/tree/<ref>` with no subdir
146
+ const refOnlyPatterns = [/^\/(?:-\/)?tree\/([^/]+?)\/?$/, /^\/(?:-\/)?blob\/([^/]+?)\/?$/, /^\/src\/([^/]+?)\/?$/];
90
147
 
91
- for (const pattern of deepPathPatterns) {
92
- const match = remainder.match(pattern);
148
+ for (const p of deepPathPatterns) {
149
+ const match = remainder.match(p.regex);
93
150
  if (match) {
94
- subdir = match[1].replace(/\/$/, ''); // strip trailing slash
151
+ urlRef = match[p.refIdx];
152
+ subdir = match[p.pathIdx].replace(/\/$/, '');
95
153
  break;
96
154
  }
97
155
  }
156
+ if (!subdir) {
157
+ for (const r of refOnlyPatterns) {
158
+ const match = remainder.match(r);
159
+ if (match) {
160
+ urlRef = match[1];
161
+ break;
162
+ }
163
+ }
164
+ }
98
165
  }
99
166
 
167
+ // Precedence: explicit @version suffix > URL /tree/<ref> path segment.
168
+ const version = versionSuffix || urlRef || null;
169
+
100
170
  return {
101
171
  type: 'url',
102
172
  cloneUrl,
103
173
  subdir,
104
174
  localPath: null,
175
+ version,
176
+ rawInput: trimmedRaw,
105
177
  cacheKey: `${host}/${owner}/${repo}`,
106
178
  displayName: `${owner}/${repo}`,
107
179
  isValid: true,
@@ -255,6 +327,10 @@ class CustomModuleManager {
255
327
  const silent = options.silent || false;
256
328
  const displayName = parsed.displayName;
257
329
 
330
+ // Pin override: --pin CODE=TAG resolved at module-selection time overrides
331
+ // any @version suffix present in the URL.
332
+ const effectiveVersion = options.pinOverride || parsed.version || null;
333
+
258
334
  await fs.ensureDir(path.dirname(repoCacheDir));
259
335
 
260
336
  const createSpinner = async () => {
@@ -264,8 +340,23 @@ class CustomModuleManager {
264
340
  return await prompts.spinner();
265
341
  };
266
342
 
343
+ // If an existing cache exists but was cloned at a different version, re-clone.
344
+ // Tracked via .bmad-source.json's recorded version.
267
345
  if (await fs.pathExists(repoCacheDir)) {
268
- // Update existing clone
346
+ let cachedVersion = null;
347
+ try {
348
+ const existing = await fs.readJson(path.join(repoCacheDir, '.bmad-source.json'));
349
+ cachedVersion = existing?.version || null;
350
+ } catch {
351
+ // no metadata; treat as mismatched to be safe if a version was requested
352
+ }
353
+ if ((effectiveVersion || null) !== (cachedVersion || null)) {
354
+ await fs.remove(repoCacheDir);
355
+ }
356
+ }
357
+
358
+ if (await fs.pathExists(repoCacheDir)) {
359
+ // Update existing clone (same version as before)
269
360
  const fetchSpinner = await createSpinner();
270
361
  fetchSpinner.start(`Updating ${displayName}...`);
271
362
  try {
@@ -274,10 +365,25 @@ class CustomModuleManager {
274
365
  stdio: ['ignore', 'pipe', 'pipe'],
275
366
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
276
367
  });
277
- execSync('git reset --hard origin/HEAD', {
278
- cwd: repoCacheDir,
279
- stdio: ['ignore', 'pipe', 'pipe'],
280
- });
368
+ if (effectiveVersion) {
369
+ // Fetch the ref as either a tag or a branch — `origin <ref>` works
370
+ // for both, whereas `origin tag <ref>` fails for branch refs parsed
371
+ // out of /tree/<branch>/... URLs.
372
+ execSync(`git fetch --depth 1 origin ${quoteCustomRef(effectiveVersion)} --no-tags`, {
373
+ cwd: repoCacheDir,
374
+ stdio: ['ignore', 'pipe', 'pipe'],
375
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
376
+ });
377
+ execSync(`git checkout --quiet FETCH_HEAD`, {
378
+ cwd: repoCacheDir,
379
+ stdio: ['ignore', 'pipe', 'pipe'],
380
+ });
381
+ } else {
382
+ execSync('git reset --hard origin/HEAD', {
383
+ cwd: repoCacheDir,
384
+ stdio: ['ignore', 'pipe', 'pipe'],
385
+ });
386
+ }
281
387
  fetchSpinner.stop(`Updated ${displayName}`);
282
388
  } catch {
283
389
  fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
@@ -287,25 +393,44 @@ class CustomModuleManager {
287
393
 
288
394
  if (!(await fs.pathExists(repoCacheDir))) {
289
395
  const fetchSpinner = await createSpinner();
290
- fetchSpinner.start(`Cloning ${displayName}...`);
396
+ fetchSpinner.start(`Cloning ${displayName}${effectiveVersion ? ` @ ${effectiveVersion}` : ''}...`);
291
397
  try {
292
- execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
293
- stdio: ['ignore', 'pipe', 'pipe'],
294
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
295
- });
398
+ if (effectiveVersion) {
399
+ execSync(`git clone --depth 1 --branch ${quoteCustomRef(effectiveVersion)} "${parsed.cloneUrl}" "${repoCacheDir}"`, {
400
+ stdio: ['ignore', 'pipe', 'pipe'],
401
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
402
+ });
403
+ } else {
404
+ execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
405
+ stdio: ['ignore', 'pipe', 'pipe'],
406
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
407
+ });
408
+ }
296
409
  fetchSpinner.stop(`Cloned ${displayName}`);
297
410
  } catch (error_) {
298
411
  fetchSpinner.error(`Failed to clone ${displayName}`);
299
- throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
412
+ const refSuffix = effectiveVersion ? `@${effectiveVersion}` : '';
413
+ throw new Error(`Failed to clone ${parsed.cloneUrl}${refSuffix}: ${error_.message}`);
300
414
  }
301
415
  }
302
416
 
417
+ // Record the resolved SHA for the manifest writer.
418
+ let resolvedSha = null;
419
+ try {
420
+ resolvedSha = execSync('git rev-parse HEAD', { cwd: repoCacheDir, stdio: 'pipe' }).toString().trim();
421
+ } catch {
422
+ // swallow — a non-git repo (local path) wouldn't reach here anyway
423
+ }
424
+
303
425
  // Write source metadata for later URL reconstruction
304
426
  const metadataPath = path.join(repoCacheDir, '.bmad-source.json');
305
427
  await fs.writeJson(metadataPath, {
306
428
  cloneUrl: parsed.cloneUrl,
307
429
  cacheKey: parsed.cacheKey,
308
430
  displayName: parsed.displayName,
431
+ version: effectiveVersion || null,
432
+ rawInput: parsed.rawInput || sourceInput,
433
+ sha: resolvedSha,
309
434
  clonedAt: new Date().toISOString(),
310
435
  });
311
436
 
@@ -346,10 +471,26 @@ class CustomModuleManager {
346
471
  const resolver = new PluginResolver();
347
472
  const resolved = await resolver.resolve(repoPath, plugin);
348
473
 
474
+ // Read clone metadata (written by cloneRepo) so we can pick up the
475
+ // resolved git ref + SHA for manifest recording.
476
+ let cloneMetadata = null;
477
+ if (sourceUrl) {
478
+ try {
479
+ cloneMetadata = await fs.readJson(path.join(repoPath, '.bmad-source.json'));
480
+ } catch {
481
+ // no metadata — local-source or legacy cache
482
+ }
483
+ }
484
+
349
485
  // Stamp source info onto each resolved module for manifest tracking
350
486
  for (const mod of resolved) {
351
487
  if (sourceUrl) mod.repoUrl = sourceUrl;
352
488
  if (localPath) mod.localPath = localPath;
489
+ if (cloneMetadata) {
490
+ mod.cloneRef = cloneMetadata.version || null;
491
+ mod.cloneSha = cloneMetadata.sha || null;
492
+ mod.rawInput = cloneMetadata.rawInput || null;
493
+ }
353
494
  CustomModuleManager._resolutionCache.set(mod.code, mod);
354
495
  }
355
496
 
@@ -5,8 +5,50 @@ 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
  };
@@ -119,10 +176,15 @@ class ExternalModuleManager {
119
176
  }
120
177
 
121
178
  /**
122
- * 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
+ *
123
182
  * @param {string} moduleCode - Code of the external module
124
183
  * @param {Object} options - Clone options
125
- * @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').
126
188
  * @returns {string} Path to the cloned repository
127
189
  */
128
190
  async cloneExternalModule(moduleCode, options = {}) {
@@ -160,38 +222,160 @@ class ExternalModuleManager {
160
222
  return await prompts.spinner();
161
223
  };
162
224
 
163
- // 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
+
164
334
  let needsDependencyInstall = false;
165
335
  let wasNewClone = false;
166
336
 
167
- // 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
+
168
343
  if (await fs.pathExists(moduleCacheDir)) {
169
- // Try to update if it's a git repo
344
+ // Cache exists on the right channel. Refresh the ref.
170
345
  const fetchSpinner = await createSpinner();
171
346
  fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
172
347
  try {
173
- const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
174
- // Fetch and reset to remote - works better with shallow clones than pull
175
- execSync('git fetch origin --depth 1', {
176
- cwd: moduleCacheDir,
177
- stdio: ['ignore', 'pipe', 'pipe'],
178
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
179
- });
180
- execSync('git reset --hard origin/HEAD', {
181
- cwd: moduleCacheDir,
182
- stdio: ['ignore', 'pipe', 'pipe'],
183
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
184
- });
185
- 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();
186
349
 
187
- fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
188
- // Force dependency install if we got new code
189
- if (currentRef !== newRef) {
190
- 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
+ });
191
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;
192
377
  } catch {
193
378
  fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
194
- // If update fails, remove and re-clone
195
379
  await fs.remove(moduleCacheDir);
196
380
  wasNewClone = true;
197
381
  }
@@ -199,22 +383,41 @@ class ExternalModuleManager {
199
383
  wasNewClone = true;
200
384
  }
201
385
 
202
- // Clone if not exists or was removed
203
386
  if (wasNewClone) {
204
387
  const fetchSpinner = await createSpinner();
205
388
  fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
206
389
  try {
207
- execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
208
- stdio: ['ignore', 'pipe', 'pipe'],
209
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
210
- });
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
+ }
211
401
  fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
212
402
  } catch (error) {
213
403
  fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
214
- 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}`);
215
405
  }
216
406
  }
217
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
+
218
421
  // Install dependencies if package.json exists
219
422
  const packageJsonPath = path.join(moduleCacheDir, 'package.json');
220
423
  const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');