ma-agents 3.16.0 → 3.17.0-beta.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 (141) hide show
  1. package/README.md +85 -4
  2. package/bin/cli.js +323 -6
  3. package/lib/agents.js +22 -15
  4. package/lib/bmad-cache/cache-manifest.json +2 -2
  5. package/lib/bmad-extension/.claude-plugin/marketplace.json.template +2 -15
  6. package/lib/bmad-extension/skills/module-help.csv +0 -13
  7. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +3 -16
  8. package/lib/bmad-extension-plugin/skills/module-help.csv +0 -13
  9. package/lib/bmad.js +116 -1
  10. package/lib/custom-marketplace.js +507 -0
  11. package/lib/installer.js +19 -7
  12. package/lib/knowledge-atlas/dist/atlas.cjs +14651 -14651
  13. package/lib/knowledge-atlas/src/render/vendor.ts +43 -43
  14. package/lib/knowledge-atlas/vendor/drawio-viewer.min.js +7563 -7563
  15. package/lib/knowledge-atlas/vendor/mermaid.min.js +2314 -2314
  16. package/lib/profile.js +201 -0
  17. package/lib/skill-authoring.js +3 -1
  18. package/package.json +3 -3
  19. package/scripts/build-bmad-cache.js +127 -43
  20. package/lib/bmad-extension/skills/ma-agent-ml/.gitkeep +0 -0
  21. package/lib/bmad-extension/skills/ma-agent-ml/SKILL.md +0 -59
  22. package/lib/bmad-extension/skills/ma-agent-ml/bmad-skill-manifest.yaml +0 -11
  23. package/lib/bmad-extension/skills/ml-advise/.gitkeep +0 -0
  24. package/lib/bmad-extension/skills/ml-advise/SKILL.md +0 -76
  25. package/lib/bmad-extension/skills/ml-advise/bmad-skill-manifest.yaml +0 -3
  26. package/lib/bmad-extension/skills/ml-advise/skill.json +0 -7
  27. package/lib/bmad-extension/skills/ml-analysis/.gitkeep +0 -0
  28. package/lib/bmad-extension/skills/ml-analysis/SKILL.md +0 -60
  29. package/lib/bmad-extension/skills/ml-analysis/bmad-skill-manifest.yaml +0 -3
  30. package/lib/bmad-extension/skills/ml-analysis/skill.json +0 -7
  31. package/lib/bmad-extension/skills/ml-architecture/.gitkeep +0 -0
  32. package/lib/bmad-extension/skills/ml-architecture/SKILL.md +0 -55
  33. package/lib/bmad-extension/skills/ml-architecture/bmad-skill-manifest.yaml +0 -3
  34. package/lib/bmad-extension/skills/ml-architecture/skill.json +0 -7
  35. package/lib/bmad-extension/skills/ml-detailed-design/.gitkeep +0 -0
  36. package/lib/bmad-extension/skills/ml-detailed-design/SKILL.md +0 -67
  37. package/lib/bmad-extension/skills/ml-detailed-design/bmad-skill-manifest.yaml +0 -3
  38. package/lib/bmad-extension/skills/ml-detailed-design/skill.json +0 -7
  39. package/lib/bmad-extension/skills/ml-eda/.gitkeep +0 -0
  40. package/lib/bmad-extension/skills/ml-eda/SKILL.md +0 -56
  41. package/lib/bmad-extension/skills/ml-eda/bmad-skill-manifest.yaml +0 -3
  42. package/lib/bmad-extension/skills/ml-eda/scripts/baseline_classifier.py +0 -522
  43. package/lib/bmad-extension/skills/ml-eda/scripts/class_weights_calculator.py +0 -295
  44. package/lib/bmad-extension/skills/ml-eda/scripts/clustering_explorer.py +0 -383
  45. package/lib/bmad-extension/skills/ml-eda/scripts/eda_analyzer.py +0 -654
  46. package/lib/bmad-extension/skills/ml-eda/skill.json +0 -7
  47. package/lib/bmad-extension/skills/ml-experiment/.gitkeep +0 -0
  48. package/lib/bmad-extension/skills/ml-experiment/SKILL.md +0 -74
  49. package/lib/bmad-extension/skills/ml-experiment/assets/advanced_trainer_configs.py +0 -430
  50. package/lib/bmad-extension/skills/ml-experiment/assets/quick_trainer_setup.py +0 -233
  51. package/lib/bmad-extension/skills/ml-experiment/assets/template_datamodule.py +0 -219
  52. package/lib/bmad-extension/skills/ml-experiment/assets/template_gnn_module.py +0 -341
  53. package/lib/bmad-extension/skills/ml-experiment/assets/template_lightning_module.py +0 -158
  54. package/lib/bmad-extension/skills/ml-experiment/bmad-skill-manifest.yaml +0 -3
  55. package/lib/bmad-extension/skills/ml-experiment/skill.json +0 -7
  56. package/lib/bmad-extension/skills/ml-hparam/.gitkeep +0 -0
  57. package/lib/bmad-extension/skills/ml-hparam/SKILL.md +0 -81
  58. package/lib/bmad-extension/skills/ml-hparam/bmad-skill-manifest.yaml +0 -3
  59. package/lib/bmad-extension/skills/ml-hparam/skill.json +0 -7
  60. package/lib/bmad-extension/skills/ml-ideation/.gitkeep +0 -0
  61. package/lib/bmad-extension/skills/ml-ideation/SKILL.md +0 -50
  62. package/lib/bmad-extension/skills/ml-ideation/bmad-skill-manifest.yaml +0 -3
  63. package/lib/bmad-extension/skills/ml-ideation/scripts/validate_ml_prd.py +0 -287
  64. package/lib/bmad-extension/skills/ml-ideation/skill.json +0 -7
  65. package/lib/bmad-extension/skills/ml-infra/.gitkeep +0 -0
  66. package/lib/bmad-extension/skills/ml-infra/SKILL.md +0 -58
  67. package/lib/bmad-extension/skills/ml-infra/bmad-skill-manifest.yaml +0 -3
  68. package/lib/bmad-extension/skills/ml-infra/skill.json +0 -7
  69. package/lib/bmad-extension/skills/ml-retrospective/.gitkeep +0 -0
  70. package/lib/bmad-extension/skills/ml-retrospective/SKILL.md +0 -63
  71. package/lib/bmad-extension/skills/ml-retrospective/bmad-skill-manifest.yaml +0 -3
  72. package/lib/bmad-extension/skills/ml-retrospective/skill.json +0 -7
  73. package/lib/bmad-extension/skills/ml-revision/.gitkeep +0 -0
  74. package/lib/bmad-extension/skills/ml-revision/SKILL.md +0 -82
  75. package/lib/bmad-extension/skills/ml-revision/bmad-skill-manifest.yaml +0 -3
  76. package/lib/bmad-extension/skills/ml-revision/skill.json +0 -7
  77. package/lib/bmad-extension/skills/ml-techspec/.gitkeep +0 -0
  78. package/lib/bmad-extension/skills/ml-techspec/SKILL.md +0 -80
  79. package/lib/bmad-extension/skills/ml-techspec/bmad-skill-manifest.yaml +0 -3
  80. package/lib/bmad-extension/skills/ml-techspec/skill.json +0 -7
  81. package/lib/bmad-extension-plugin/skills/ma-agent-ml/.gitkeep +0 -0
  82. package/lib/bmad-extension-plugin/skills/ma-agent-ml/SKILL.md +0 -59
  83. package/lib/bmad-extension-plugin/skills/ma-agent-ml/bmad-skill-manifest.yaml +0 -11
  84. package/lib/bmad-extension-plugin/skills/ml-advise/.gitkeep +0 -0
  85. package/lib/bmad-extension-plugin/skills/ml-advise/SKILL.md +0 -76
  86. package/lib/bmad-extension-plugin/skills/ml-advise/bmad-skill-manifest.yaml +0 -3
  87. package/lib/bmad-extension-plugin/skills/ml-advise/skill.json +0 -7
  88. package/lib/bmad-extension-plugin/skills/ml-analysis/.gitkeep +0 -0
  89. package/lib/bmad-extension-plugin/skills/ml-analysis/SKILL.md +0 -60
  90. package/lib/bmad-extension-plugin/skills/ml-analysis/bmad-skill-manifest.yaml +0 -3
  91. package/lib/bmad-extension-plugin/skills/ml-analysis/skill.json +0 -7
  92. package/lib/bmad-extension-plugin/skills/ml-architecture/.gitkeep +0 -0
  93. package/lib/bmad-extension-plugin/skills/ml-architecture/SKILL.md +0 -55
  94. package/lib/bmad-extension-plugin/skills/ml-architecture/bmad-skill-manifest.yaml +0 -3
  95. package/lib/bmad-extension-plugin/skills/ml-architecture/skill.json +0 -7
  96. package/lib/bmad-extension-plugin/skills/ml-detailed-design/.gitkeep +0 -0
  97. package/lib/bmad-extension-plugin/skills/ml-detailed-design/SKILL.md +0 -67
  98. package/lib/bmad-extension-plugin/skills/ml-detailed-design/bmad-skill-manifest.yaml +0 -3
  99. package/lib/bmad-extension-plugin/skills/ml-detailed-design/skill.json +0 -7
  100. package/lib/bmad-extension-plugin/skills/ml-eda/.gitkeep +0 -0
  101. package/lib/bmad-extension-plugin/skills/ml-eda/SKILL.md +0 -56
  102. package/lib/bmad-extension-plugin/skills/ml-eda/bmad-skill-manifest.yaml +0 -3
  103. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/baseline_classifier.py +0 -522
  104. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/class_weights_calculator.py +0 -295
  105. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/clustering_explorer.py +0 -383
  106. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/eda_analyzer.py +0 -654
  107. package/lib/bmad-extension-plugin/skills/ml-eda/skill.json +0 -7
  108. package/lib/bmad-extension-plugin/skills/ml-experiment/.gitkeep +0 -0
  109. package/lib/bmad-extension-plugin/skills/ml-experiment/SKILL.md +0 -74
  110. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/advanced_trainer_configs.py +0 -430
  111. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/quick_trainer_setup.py +0 -233
  112. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_datamodule.py +0 -219
  113. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_gnn_module.py +0 -341
  114. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_lightning_module.py +0 -158
  115. package/lib/bmad-extension-plugin/skills/ml-experiment/bmad-skill-manifest.yaml +0 -3
  116. package/lib/bmad-extension-plugin/skills/ml-experiment/skill.json +0 -7
  117. package/lib/bmad-extension-plugin/skills/ml-hparam/.gitkeep +0 -0
  118. package/lib/bmad-extension-plugin/skills/ml-hparam/SKILL.md +0 -81
  119. package/lib/bmad-extension-plugin/skills/ml-hparam/bmad-skill-manifest.yaml +0 -3
  120. package/lib/bmad-extension-plugin/skills/ml-hparam/skill.json +0 -7
  121. package/lib/bmad-extension-plugin/skills/ml-ideation/.gitkeep +0 -0
  122. package/lib/bmad-extension-plugin/skills/ml-ideation/SKILL.md +0 -50
  123. package/lib/bmad-extension-plugin/skills/ml-ideation/bmad-skill-manifest.yaml +0 -3
  124. package/lib/bmad-extension-plugin/skills/ml-ideation/scripts/validate_ml_prd.py +0 -287
  125. package/lib/bmad-extension-plugin/skills/ml-ideation/skill.json +0 -7
  126. package/lib/bmad-extension-plugin/skills/ml-infra/.gitkeep +0 -0
  127. package/lib/bmad-extension-plugin/skills/ml-infra/SKILL.md +0 -58
  128. package/lib/bmad-extension-plugin/skills/ml-infra/bmad-skill-manifest.yaml +0 -3
  129. package/lib/bmad-extension-plugin/skills/ml-infra/skill.json +0 -7
  130. package/lib/bmad-extension-plugin/skills/ml-retrospective/.gitkeep +0 -0
  131. package/lib/bmad-extension-plugin/skills/ml-retrospective/SKILL.md +0 -63
  132. package/lib/bmad-extension-plugin/skills/ml-retrospective/bmad-skill-manifest.yaml +0 -3
  133. package/lib/bmad-extension-plugin/skills/ml-retrospective/skill.json +0 -7
  134. package/lib/bmad-extension-plugin/skills/ml-revision/.gitkeep +0 -0
  135. package/lib/bmad-extension-plugin/skills/ml-revision/SKILL.md +0 -82
  136. package/lib/bmad-extension-plugin/skills/ml-revision/bmad-skill-manifest.yaml +0 -3
  137. package/lib/bmad-extension-plugin/skills/ml-revision/skill.json +0 -7
  138. package/lib/bmad-extension-plugin/skills/ml-techspec/.gitkeep +0 -0
  139. package/lib/bmad-extension-plugin/skills/ml-techspec/SKILL.md +0 -80
  140. package/lib/bmad-extension-plugin/skills/ml-techspec/bmad-skill-manifest.yaml +0 -3
  141. package/lib/bmad-extension-plugin/skills/ml-techspec/skill.json +0 -7
@@ -0,0 +1,507 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Story 30.6a — Custom marketplace source: thin adapter over bmad-method's
5
+ * `CustomModuleManager`.
6
+ *
7
+ * Scope (per the 30.5 spike hand-off design, `_bmad-output/implementation-
8
+ * artifacts/epic-runs/epic-30/30-5-spike-findings.md` §5): enumerate ONLY.
9
+ * This module does NOT prune/stage/install anything — that lands in Story
10
+ * 30.6b, which consumes `enumerateCustomSource()`'s return value (the
11
+ * discovered plugin list) and the wizard's `selectedCodes` (see
12
+ * `bin/cli.js#promptCustomMarketplace`).
13
+ *
14
+ * NFR17 — pinned-internal coupling.
15
+ * bmad-method's `package.json` has no `exports` map, so a deep require of
16
+ * `tools/installer/modules/custom-module-manager.js` is reachable but is an
17
+ * installer-internal surface, not a documented/supported API. This is a
18
+ * single-call-site (blast-radius-of-one) wrapper around that surface, per the
19
+ * spike's mitigation list. `test/custom-marketplace-surface-drift.test.js`
20
+ * fails loudly if the deep path, exported class, consumed methods, or the
21
+ * installed bmad-method version drift from what this file was written
22
+ * against.
23
+ *
24
+ * FR241 — zero token handling. Enumeration relies entirely on bmad-method's
25
+ * own `cloneRepo()`, which sets `GIT_TERMINAL_PROMPT=0` (never prompts) and
26
+ * spreads `process.env` (ambient SSH agent / credential helper / git config
27
+ * creds). ma-agents never reads, stores, or logs a credential here.
28
+ *
29
+ * Story 30.6b — prune + stage the user's selected subset (consumes
30
+ * `enumerateCustomSource()`'s output + the wizard's `selectedCodes`). This
31
+ * still lives in the SAME file as 30.6a's enumerate adapter (not a new
32
+ * module) because staging a selection also needs the pinned-internal
33
+ * `resolvePlugin` method — keeping every deep-require consumer in one file
34
+ * preserves the single-call-site NFR17 mitigation. `stageCustomMarketplaceSubset()`
35
+ * only stages to a local directory; it never invokes bmad-method itself — the
36
+ * actual `--custom-source` install is `lib/bmad.js#installCustomMarketplaceSubset()`,
37
+ * which reuses `buildBmadArgs`/`runCommand` (no hand-rolled child-process/git logic).
38
+ */
39
+
40
+ const path = require('node:path');
41
+ const fs = require('fs-extra');
42
+
43
+ /** Deep, pinned-internal require path (NFR17). Single call site. */
44
+ const CUSTOM_MODULE_MANAGER_PATH = 'bmad-method/tools/installer/modules/custom-module-manager.js';
45
+
46
+ /**
47
+ * The `CustomModuleManager` methods the custom-marketplace feature depends on.
48
+ * Any drift in this set (renamed/removed on a bmad-method pin bump) must fail
49
+ * the surface-drift test. 6a's `enumerateCustomSource()` calls `resolveSource`
50
+ * + `discoverModules` (and `parseSource` transitively); `resolvePlugin` is the
51
+ * staging primitive Story 30.6b will consume — it is guarded here now so the
52
+ * drift test catches a regression BEFORE 6b lands, per the 30.5 spike
53
+ * mitigation list (the four methods it names as the consumed surface).
54
+ */
55
+ const REQUIRED_METHODS = ['parseSource', 'resolveSource', 'discoverModules', 'resolvePlugin'];
56
+
57
+ /**
58
+ * NFR71 — custom-source content is pass-through and outside ma-agents' sealed
59
+ * -bundle guarantee. Shown once, before the user is offered the multiselect
60
+ * (i.e. before anything from the source could be installed).
61
+ */
62
+ const NFR71_WARNING =
63
+ 'Custom marketplace content is outside ma-agents\' sealed-bundle guarantee — ' +
64
+ 'it is installed as-is from the source you provided and is not vetted by ma-agents.';
65
+
66
+ /**
67
+ * Error type raised by this adapter for any enumeration failure (unreachable
68
+ * source, invalid input, drifted surface). Callers should catch this and
69
+ * print `err.message` only — never `err.stack` — to keep the wizard's
70
+ * "fail clean" contract (no raw stack-dump on an unreachable source).
71
+ */
72
+ class CustomMarketplaceError extends Error {
73
+ constructor(message, { cause } = {}) {
74
+ super(message);
75
+ this.name = 'CustomMarketplaceError';
76
+ if (cause !== undefined) this.cause = cause;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Deep-require bmad-method's CustomModuleManager (NFR17 single call site).
82
+ *
83
+ * @returns {{ CustomModuleManager: Function }}
84
+ * @throws {CustomMarketplaceError} if the pinned-internal surface is not
85
+ * reachable at all (module moved/renamed on a bmad-method version bump).
86
+ */
87
+ function loadCustomModuleManagerModule() {
88
+ try {
89
+ // eslint-disable-next-line global-require, import/no-dynamic-require
90
+ return require(CUSTOM_MODULE_MANAGER_PATH);
91
+ } catch (err) {
92
+ throw new CustomMarketplaceError(
93
+ `bmad-method's CustomModuleManager is not reachable at the pinned-internal ` +
94
+ `path "${CUSTOM_MODULE_MANAGER_PATH}" (NFR17). This usually means the ` +
95
+ `installed bmad-method version moved or renamed the module — re-run the ` +
96
+ `surface-drift test (test/custom-marketplace-surface-drift.test.js) after ` +
97
+ `any bmad-method version bump.`,
98
+ { cause: err }
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Assert the pinned-internal surface this adapter depends on is present and
105
+ * shaped as expected. Used by both this module (defensively, before use) and
106
+ * the drift/smoke test (explicitly, as its own assertion).
107
+ *
108
+ * @returns {{ version: string, mgr: import('bmad-method/tools/installer/modules/custom-module-manager').CustomModuleManager }}
109
+ * @throws {CustomMarketplaceError} on any drift — missing class, missing
110
+ * method, or a method that is no longer a function.
111
+ */
112
+ function assertCustomModuleManagerSurface() {
113
+ const mod = loadCustomModuleManagerModule();
114
+ if (typeof mod.CustomModuleManager !== 'function') {
115
+ throw new CustomMarketplaceError(
116
+ `bmad-method's "${CUSTOM_MODULE_MANAGER_PATH}" no longer exports a ` +
117
+ `CustomModuleManager class (NFR17 surface drift). Cannot enumerate custom ` +
118
+ `marketplace sources until ma-agents' adapter (lib/custom-marketplace.js) ` +
119
+ `is updated for the new shape.`
120
+ );
121
+ }
122
+
123
+ let mgr;
124
+ try {
125
+ mgr = new mod.CustomModuleManager();
126
+ } catch (err) {
127
+ throw new CustomMarketplaceError(
128
+ `bmad-method's CustomModuleManager constructor threw (NFR17 surface drift): ${err.message}`,
129
+ { cause: err }
130
+ );
131
+ }
132
+
133
+ const missing = REQUIRED_METHODS.filter((m) => typeof mgr[m] !== 'function');
134
+ if (missing.length > 0) {
135
+ throw new CustomMarketplaceError(
136
+ `bmad-method's CustomModuleManager is missing expected method(s): ` +
137
+ `${missing.join(', ')} (NFR17 surface drift). The custom-marketplace ` +
138
+ `feature depends on: ${REQUIRED_METHODS.join(', ')}.`
139
+ );
140
+ }
141
+
142
+ let version = null;
143
+ try {
144
+ // eslint-disable-next-line global-require
145
+ version = require('bmad-method/package.json').version;
146
+ } catch {
147
+ // Best-effort only — absence doesn't invalidate the surface check above.
148
+ }
149
+
150
+ return { version, mgr };
151
+ }
152
+
153
+ /**
154
+ * Enumerate the plugins declared by a custom marketplace source.
155
+ *
156
+ * Thin wrapper over `CustomModuleManager#resolveSource` +
157
+ * `CustomModuleManager#discoverModules` (FR240). Performs NO staging, no
158
+ * pruning, no install — see the module doc-comment above. Authentication is
159
+ * whatever bmad-method's own `cloneRepo()` uses (ambient git creds; zero
160
+ * token handling — FR241).
161
+ *
162
+ * @param {string} source - A local filesystem path OR a Git URL (HTTPS/SSH)
163
+ * pointing at a repository. If it contains `.claude-plugin/marketplace.json`
164
+ * ("discovery" mode) every declared plugin is enumerated; otherwise
165
+ * ("direct" mode, FR240) the source itself is treated as a single module.
166
+ * @returns {Promise<{ mode: 'discovery'|'direct', plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
167
+ * @throws {CustomMarketplaceError} on invalid input, an unreachable/unauthorized
168
+ * remote, or a drifted pinned-internal surface. Always a clean, human-readable
169
+ * message — never a raw upstream stack.
170
+ */
171
+ async function enumerateCustomSource(source) {
172
+ if (!source || typeof source !== 'string' || !source.trim()) {
173
+ throw new CustomMarketplaceError('A custom marketplace source (path or Git URL) is required.');
174
+ }
175
+
176
+ const { mgr } = assertCustomModuleManagerSurface();
177
+
178
+ let resolved;
179
+ try {
180
+ // skipInstall: true — this is enumeration only, never run a source's own
181
+ // `npm install` as a side effect of browsing it.
182
+ // silent: true — ma-agents owns all user-facing output; suppress
183
+ // bmad-method's own spinner/log lines here.
184
+ resolved = await mgr.resolveSource(source, { skipInstall: true, silent: true });
185
+ } catch (err) {
186
+ // Fail clean (NFR71 posture + spike §3 "fails clean" contract): a
187
+ // human-readable one-liner, no upstream stack surfaced to the wizard.
188
+ throw new CustomMarketplaceError(
189
+ `Could not reach custom marketplace source "${source}": ${err.message}`,
190
+ { cause: err }
191
+ );
192
+ }
193
+
194
+ if (resolved.mode === 'discovery') {
195
+ let plugins;
196
+ try {
197
+ plugins = await mgr.discoverModules(resolved.marketplace, resolved.sourceUrl);
198
+ } catch (err) {
199
+ throw new CustomMarketplaceError(
200
+ `Could not read the plugin list from "${source}": ${err.message}`,
201
+ { cause: err }
202
+ );
203
+ }
204
+ return { mode: 'discovery', plugins, rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
205
+ }
206
+
207
+ // FR240 — "direct" mode (no marketplace.json at the source root): treat the
208
+ // whole source as a single module so the wizard's multiselect still has
209
+ // exactly one, well-described choice. 30.6b decides how a direct-mode
210
+ // selection stages; 6a only surfaces it.
211
+ const singlePlugin = {
212
+ code: path.basename(resolved.rootDir),
213
+ name: path.basename(resolved.rootDir),
214
+ description: '(direct-mode source — no marketplace.json; the whole source is one module)',
215
+ source: '.',
216
+ skills: [],
217
+ type: 'custom-direct',
218
+ trustTier: 'unverified',
219
+ builtIn: false,
220
+ isExternal: true,
221
+ };
222
+ return { mode: 'direct', plugins: [singlePlugin], rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
223
+ }
224
+
225
+ // ─── Story 30.6b — prune + stage the selected subset ───────────────────────
226
+
227
+ /** Project-relative stage dir for the pruned custom-marketplace subset. Sibling of
228
+ * `lib/bmad.js#PLUGIN_STAGE_DIR_NAME` (the bundled-plugin stage) but a distinct
229
+ * directory: the two stages are unrelated content and must never merge. */
230
+ const CUSTOM_MARKETPLACE_STAGE_DIR_NAME = '.ma-agents-custom-marketplace-stage';
231
+
232
+ /**
233
+ * FR240 direct-mode gap-fill: `enumerateCustomSource()`'s synthetic direct-mode
234
+ * plugin object always carries `skills: []` (6a only needs it to render ONE
235
+ * multiselect choice). bmad-method's own `PluginResolver.resolve()` returns `[]`
236
+ * immediately when `plugin.skills` is empty, so staging a direct-mode selection
237
+ * as-is would resolve to zero installable skills. bmad-method's own CLI
238
+ * (`ui.js#_resolveCustomSourcesCli`, direct-mode branch) works around this by
239
+ * scanning the source root for immediate subdirectories containing `SKILL.md`
240
+ * and using THOSE directory names as `plugin.skills` before calling
241
+ * `resolvePlugin`. This mirrors that scan exactly so 30.6b's direct-mode
242
+ * staging matches what bmad-method's own non-interactive install would do.
243
+ *
244
+ * @param {string} rootDir - Absolute path to the direct-mode source root.
245
+ * @returns {Promise<string[]>} Directory names (not paths) that contain a SKILL.md.
246
+ */
247
+ async function scanDirectModeSkillDirs(rootDir) {
248
+ let entries;
249
+ try {
250
+ entries = await fs.readdir(rootDir, { withFileTypes: true });
251
+ } catch {
252
+ return [];
253
+ }
254
+ const skillDirs = [];
255
+ for (const entry of entries) {
256
+ if (!entry.isDirectory()) continue;
257
+ const skillMd = path.join(rootDir, entry.name, 'SKILL.md');
258
+ if (await fs.pathExists(skillMd)) {
259
+ skillDirs.push(entry.name);
260
+ }
261
+ }
262
+ return skillDirs;
263
+ }
264
+
265
+ /**
266
+ * Resolve every selected plugin's installable skill/module layout via
267
+ * bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
268
+ * method 30.6a already guards in `REQUIRED_METHODS`, reserved for this story).
269
+ *
270
+ * @param {Object} ctx
271
+ * @param {'discovery'|'direct'} ctx.mode
272
+ * @param {string} ctx.rootDir - Absolute path returned by `enumerateCustomSource`.
273
+ * @param {string|null} ctx.sourceUrl - Absolute/null, as returned by `enumerateCustomSource`.
274
+ * @param {Array<Object>} ctx.plugins - The FULL enumerated plugin list (30.6a's return value).
275
+ * @param {string[]} ctx.selectedCodes - The user's chosen subset of plugin codes.
276
+ * @returns {Promise<Array<{ plugin: Object, rawPlugin: Object, resolved: Array<Object> }>>}
277
+ * @throws {CustomMarketplaceError} if no enumerated plugin matches `selectedCodes`.
278
+ */
279
+ async function resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selectedCodes }) {
280
+ const { mgr } = assertCustomModuleManagerSurface();
281
+ const selectedSet = new Set(selectedCodes);
282
+ const selectedPlugins = (plugins || []).filter((p) => selectedSet.has(p.code));
283
+
284
+ if (selectedPlugins.length === 0) {
285
+ throw new CustomMarketplaceError(
286
+ `None of the selected codes (${[...selectedSet].join(', ') || '(none)'}) matched an ` +
287
+ `enumerated plugin — nothing to stage.`
288
+ );
289
+ }
290
+
291
+ // Local sources pass their rootDir as `localPath` (manifest tracking only);
292
+ // URL sources pass `null` here since the clone metadata already carries the
293
+ // origin. Mirrors resolvePlugin's own call sites (custom-module-manager.js,
294
+ // ui.js#_resolveCustomSourcesCli).
295
+ const localPath = sourceUrl ? null : rootDir;
296
+
297
+ const resolvedByPlugin = [];
298
+ for (const plugin of selectedPlugins) {
299
+ let rawPlugin;
300
+ if (mode === 'discovery') {
301
+ rawPlugin = plugin.rawPlugin;
302
+ } else {
303
+ // FR240 direct mode — see scanDirectModeSkillDirs() doc comment.
304
+ const skillDirNames = await scanDirectModeSkillDirs(rootDir);
305
+ rawPlugin = {
306
+ name: plugin.code,
307
+ description: plugin.description || '',
308
+ version: plugin.version || null,
309
+ source: '.',
310
+ skills: skillDirNames,
311
+ };
312
+ }
313
+ const resolved = await mgr.resolvePlugin(rootDir, rawPlugin, sourceUrl, localPath);
314
+ resolvedByPlugin.push({ plugin, rawPlugin, resolved });
315
+ }
316
+ return resolvedByPlugin;
317
+ }
318
+
319
+ /**
320
+ * FR238 — pre-flight guard mirroring (and pre-empting, with a friendlier
321
+ * message) bmad-method 6.10.0's `_copyResolvedSkills` fail-loud invariants
322
+ * (`tools/installer/modules/official-modules.js`):
323
+ * (a) the overall resolution must be non-empty (an empty selection would
324
+ * otherwise silently install nothing — worse than a loud abort, since
325
+ * the custom-module install path has no built-in guard for this case);
326
+ * (b) each individual resolved module's skill directories must have
327
+ * UNIQUE leaf (basename) names — `_copyResolvedSkills` flattens skills
328
+ * by leaf name into that module's target dir and throws on a collision.
329
+ * Scope note: uniqueness is checked PER MODULE, matching the upstream guard's
330
+ * exact scope — the same leaf name reused across two DIFFERENT modules is
331
+ * fine (each module installs into its own `bmadDir/<code>/`).
332
+ *
333
+ * @param {Array<{ plugin: Object, rawPlugin: Object, resolved: Array<Object> }>} resolvedByPlugin
334
+ * @param {Object} [ctx]
335
+ * @param {string} [ctx.source] - Original source string, for error messages only.
336
+ * @returns {Array<Object>} The flattened list of resolved modules (all plugins).
337
+ * @throws {CustomMarketplaceError} on any violation above.
338
+ */
339
+ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '' } = {}) {
340
+ const allModules = resolvedByPlugin.flatMap((r) => r.resolved || []);
341
+
342
+ if (allModules.length === 0) {
343
+ throw new CustomMarketplaceError(
344
+ `Custom marketplace selection from "${source}" resolved to zero installable modules ` +
345
+ `— bmad-method's PluginResolver could not locate module.yaml/skills for any selected ` +
346
+ `plugin. Nothing would be installed; aborting before staging (FR238).`
347
+ );
348
+ }
349
+
350
+ for (const mod of allModules) {
351
+ if (!Array.isArray(mod.skillPaths) || mod.skillPaths.length === 0) {
352
+ throw new CustomMarketplaceError(
353
+ `Module '${mod.code}' (from plugin '${mod.pluginName}') resolved to zero skill ` +
354
+ `directories — installing it would create an empty module. Aborting before staging (FR238).`
355
+ );
356
+ }
357
+ const seenLeaves = new Map();
358
+ for (const skillPath of mod.skillPaths) {
359
+ const leaf = path.basename(skillPath);
360
+ if (seenLeaves.has(leaf)) {
361
+ throw new CustomMarketplaceError(
362
+ `Module '${mod.code}' has two skill directories that share the leaf name ` +
363
+ `'${leaf}' ('${seenLeaves.get(leaf)}' and '${skillPath}') — they would overwrite ` +
364
+ `each other on install. bmad-method 6.10.0's _copyResolvedSkills fails loud on ` +
365
+ `this exact case; ma-agents checks it first so the failure is clean (FR238).`
366
+ );
367
+ }
368
+ seenLeaves.set(leaf, skillPath);
369
+ }
370
+ }
371
+
372
+ return allModules;
373
+ }
374
+
375
+ /**
376
+ * Prune the enumerated marketplace down to `selectedCodes` and stage the
377
+ * resulting subset's actual content into a project-local directory, ready to
378
+ * be handed to bmad-method as an additional `--custom-source`.
379
+ *
380
+ * Stages ONLY (no bmad-method invocation) — `lib/bmad.js#installCustomMarketplaceSubset`
381
+ * performs the actual install using this function's return value.
382
+ *
383
+ * @param {Object} ctx
384
+ * @param {string} ctx.source - Original source string (path or Git URL), for messages only.
385
+ * @param {'discovery'|'direct'} ctx.mode
386
+ * @param {string} ctx.rootDir
387
+ * @param {string|null} ctx.sourceUrl
388
+ * @param {Array<Object>} ctx.plugins - Full enumerated list (30.6a's return value).
389
+ * @param {string[]} ctx.selectedCodes
390
+ * @param {string} ctx.projectRoot
391
+ * @returns {Promise<{ stagePath: string, customSourceArg: string, resolvedModules: Array<Object> }>}
392
+ * @throws {CustomMarketplaceError} on a drifted surface, an unmatched selection, or a failed
393
+ * FR238 pre-flight check (see assertResolutionNonEmptyAndUniqueLeaves).
394
+ */
395
+ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl, plugins, selectedCodes, projectRoot }) {
396
+ projectRoot = path.resolve(projectRoot);
397
+
398
+ const resolvedByPlugin = await resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selectedCodes });
399
+ const allModules = assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source });
400
+
401
+ const stagePath = path.join(projectRoot, CUSTOM_MARKETPLACE_STAGE_DIR_NAME);
402
+
403
+ // Safety pattern mirrors lib/bmad.js#stagePlugin (Story 22.6/22.8): never
404
+ // follow a pre-existing stage path that is a symlink, and wipe any stale
405
+ // stage from a prior failed run before copying fresh content.
406
+ if (fs.existsSync(stagePath)) {
407
+ const stageLstat = fs.lstatSync(stagePath);
408
+ if (stageLstat.isSymbolicLink()) {
409
+ throw new CustomMarketplaceError(
410
+ `Refusing to stage custom marketplace subset — ${stagePath} is a symbolic link. ` +
411
+ `Remove it manually and re-run.`
412
+ );
413
+ }
414
+ try {
415
+ fs.removeSync(stagePath);
416
+ } catch (err) {
417
+ throw new CustomMarketplaceError(`Cannot wipe stale stage ${stagePath}: ${err.message}`);
418
+ }
419
+ }
420
+ fs.mkdirpSync(stagePath);
421
+
422
+ // Copy every resolved file, preserving its path relative to rootDir, so
423
+ // bmad-method's own re-resolution against the stage (a fresh 'local' type
424
+ // source once staged) reproduces the identical result: same skill dirs at
425
+ // the same relative locations, same module.yaml if one was found, and the
426
+ // same synthesized fallback if strategy 5 applies (a pure function of the
427
+ // marketplace.json entry + the copied skill dirs' SKILL.md frontmatter).
428
+ const copyRelativeToRoot = (absSrc) => {
429
+ const rel = path.relative(rootDir, absSrc);
430
+ const dest = path.join(stagePath, rel);
431
+ fs.copySync(absSrc, dest, { overwrite: true, dereference: true });
432
+ };
433
+ try {
434
+ for (const mod of allModules) {
435
+ for (const skillPath of mod.skillPaths) copyRelativeToRoot(skillPath);
436
+ if (mod.moduleYamlPath) copyRelativeToRoot(mod.moduleYamlPath);
437
+ if (mod.moduleHelpCsvPath) copyRelativeToRoot(mod.moduleHelpCsvPath);
438
+ }
439
+ } catch (err) {
440
+ try { fs.removeSync(stagePath); } catch { /* swallow — primary error wins */ }
441
+ throw new CustomMarketplaceError(`Staging the custom marketplace subset failed: ${err.message}`, { cause: err });
442
+ }
443
+
444
+ // Prune: write a marketplace.json containing ONLY the selected plugins'
445
+ // raw entries (direct mode has no original marketplace.json to prune, so
446
+ // this synthesizes the one entry from the direct-mode plugin descriptor).
447
+ const prunedPlugins = resolvedByPlugin.map(({ rawPlugin }) => rawPlugin);
448
+ const marketplaceDir = path.join(stagePath, '.claude-plugin');
449
+ fs.mkdirpSync(marketplaceDir);
450
+ fs.writeFileSync(
451
+ path.join(marketplaceDir, 'marketplace.json'),
452
+ JSON.stringify({ name: path.basename(rootDir), plugins: prunedPlugins }, null, 2)
453
+ );
454
+
455
+ // POSIX-style `./<dir>` satisfies bmad-method's parseSource() local-path
456
+ // detector on both Windows and POSIX (see lib/bmad.js#stagePlugin for the
457
+ // same rationale) — bmad-method's cwd is always projectRoot.
458
+ const customSourceArg = `./${CUSTOM_MARKETPLACE_STAGE_DIR_NAME}`;
459
+ return { stagePath, customSourceArg, resolvedModules: allModules };
460
+ }
461
+
462
+ // ─── Story 30.7 — persist + re-enumerate/reconcile on update ───────────────
463
+
464
+ /**
465
+ * Pure reconcile — no I/O. Compares a freshly re-enumerated plugin list
466
+ * against the PERSISTED selection from a prior install and reports the
467
+ * difference. Newly-appeared plugins are reported only — never added to the
468
+ * returned selection (FR242's "report only" contract); the caller decides
469
+ * what to do with `added`/`removed` (typically: print a report, and re-install
470
+ * only `stillPresent`).
471
+ *
472
+ * @param {Object} ctx
473
+ * @param {string[]} ctx.persistedSelectedCodes - The previously-persisted `selectedCodes`.
474
+ * @param {Array<{ code: string }>} ctx.enumeratedPlugins - Fresh `enumerateCustomSource()` output.
475
+ * @returns {{ added: string[], removed: string[], stillPresent: string[] }}
476
+ * `added` — declared now but not previously selected (report only, not auto-selected).
477
+ * `removed` — previously selected but no longer declared by the source.
478
+ * `stillPresent` — previously selected AND still declared (safe to re-install as-is).
479
+ */
480
+ function reconcileCustomMarketplaceSelection({ persistedSelectedCodes, enumeratedPlugins }) {
481
+ const persistedSet = new Set(persistedSelectedCodes || []);
482
+ const enumeratedCodes = (enumeratedPlugins || []).map((p) => p.code);
483
+ const enumeratedSet = new Set(enumeratedCodes);
484
+
485
+ const added = enumeratedCodes.filter((c) => !persistedSet.has(c));
486
+ const removed = [...persistedSet].filter((c) => !enumeratedSet.has(c));
487
+ const stillPresent = [...persistedSet].filter((c) => enumeratedSet.has(c));
488
+
489
+ return { added, removed, stillPresent };
490
+ }
491
+
492
+ module.exports = {
493
+ CUSTOM_MODULE_MANAGER_PATH,
494
+ REQUIRED_METHODS,
495
+ NFR71_WARNING,
496
+ CustomMarketplaceError,
497
+ assertCustomModuleManagerSurface,
498
+ enumerateCustomSource,
499
+ // Story 30.6b — prune + stage the selected subset
500
+ CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
501
+ scanDirectModeSkillDirs,
502
+ resolveSelectedPlugins,
503
+ assertResolutionNonEmptyAndUniqueLeaves,
504
+ stageCustomMarketplaceSubset,
505
+ // Story 30.7 — persist + reconcile on update
506
+ reconcileCustomMarketplaceSelection,
507
+ };
package/lib/installer.js CHANGED
@@ -14,6 +14,11 @@ const TEMPLATE_PATH = path.join(__dirname, 'templates', 'project-context.templat
14
14
  const UNIVERSAL_INSTRUCTION_TEMPLATE_PATH = path.join(__dirname, 'templates', 'instruction-block-universal.template.md');
15
15
  const GIT_INSTRUCTION_TEMPLATE_PATH = path.join(__dirname, 'templates', 'instruction-block-git.template.md');
16
16
  const ONPREM_INSTRUCTION_TEMPLATE_PATH = path.join(__dirname, 'templates', 'instruction-block-onprem.template.md');
17
+ // Retained for test reference (test/clinerules.test.js asserts the template's
18
+ // existence + body). Production template selection is now registry-driven via
19
+ // agent.injectionStrategy.framingTemplate + EXTRA_TEMPLATE_DIR (Story 30.9), so
20
+ // this constant is no longer read by install logic — do not reintroduce an
21
+ // agent-id literal that consumes it directly.
17
22
  const CLINERULES_TEMPLATE_PATH = path.join(__dirname, 'templates', 'clinerules.template.md');
18
23
  const EXTRA_TEMPLATE_DIR = path.join(__dirname, 'templates');
19
24
 
@@ -1238,21 +1243,28 @@ async function updateAgentInstructions(agent, projectRoot, opts = {}) {
1238
1243
  const wrappedInstruction = `${markerStart}${planningInstruction}${markerEnd}`;
1239
1244
  const wrappedInstructionWithTrailingNewline = wrappedInstruction + '\n';
1240
1245
 
1241
- // Story 21.5 AC #6 — Cline dual-file drift detection.
1246
+ // Story 21.5 AC #6 / Story 30.9 — dual-file drift detection, driven by the
1247
+ // registry's injectionStrategy.dualFileDriftCheck flag rather than a
1248
+ // hardcoded agent-id literal (test/agent-injection-strategy.test.js #3.3).
1242
1249
  // Runs BEFORE the file loop so we abort cleanly without partial writes.
1243
1250
  // `--yes` does NOT bypass (explicit documented exception, AC #6).
1244
- if (agent.id === 'cline') {
1251
+ if (agent.injectionStrategy?.dualFileDriftCheck) {
1245
1252
  checkClinerulesDualFileDrift(projectRoot);
1246
1253
  }
1247
1254
 
1248
- // Story 21.5 AC #1, #2 — optional framing template for fresh Cline file creation.
1249
- // The composer produces the universal body once; the framing supplies the
1250
- // Cline-flavored header paragraph + Architect-mode guidance line. Framing is
1255
+ // Story 21.5 AC #1, #2 / Story 30.9 — optional framing template for fresh
1256
+ // file creation. The composer produces the universal body once; the
1257
+ // framing supplies an agent-flavored header/guidance wrapper. Framing is
1251
1258
  // only used when creating a NEW file — existing files go through the normal
1252
1259
  // marker-replace path so user content outside markers is preserved (AC #4).
1260
+ // The template name is registry-driven (injectionStrategy.framingTemplate),
1261
+ // not gated on the agent id literal.
1253
1262
  let frameworkTemplate = null;
1254
- if (agent.id === 'cline' && fs.existsSync(CLINERULES_TEMPLATE_PATH)) {
1255
- frameworkTemplate = fs.readFileSync(CLINERULES_TEMPLATE_PATH, 'utf-8');
1263
+ if (agent.injectionStrategy?.framingTemplate) {
1264
+ const framingTemplatePath = path.join(EXTRA_TEMPLATE_DIR, agent.injectionStrategy.framingTemplate);
1265
+ if (fs.existsSync(framingTemplatePath)) {
1266
+ frameworkTemplate = fs.readFileSync(framingTemplatePath, 'utf-8');
1267
+ }
1256
1268
  }
1257
1269
 
1258
1270
  for (const fileName of agent.instructionFiles) {