ma-agents 3.17.0-beta.0 → 3.17.0-beta.2

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.
package/bin/cli.js CHANGED
@@ -39,7 +39,8 @@ const { reconfigure: runReconfigure, ReconfigureYesRejectedError, ManifestNotFou
39
39
  const { uninstallProfileArtifacts } = require('../lib/uninstall');
40
40
  const bmad = require('../lib/bmad');
41
41
  const { getBmadPlatformCode } = require('../lib/agents');
42
- const { enumerateCustomSource, NFR71_WARNING, CustomMarketplaceError, reconcileCustomMarketplaceSelection } = require('../lib/custom-marketplace');
42
+ const { enumerateCustomSource, NFR71_WARNING, CustomMarketplaceError, reconcileCustomMarketplaceSelection, registryTrustLabel } = require('../lib/custom-marketplace');
43
+ const { setLogSink, diag, diagError } = require('../lib/diag');
43
44
  const { handleCreateSkill, handleValidateSkill, handleSetMandatory, handleCustomizeAgent, handleCreateAgent } = require('../lib/skill-authoring');
44
45
 
45
46
  const PKG = require('../package.json');
@@ -55,9 +56,21 @@ const VERSION = PKG.version;
55
56
  */
56
57
  function setupLogging(logFilePath) {
57
58
  const logFd = fs.openSync(logFilePath, 'w');
58
- const header = `[${new Date().toISOString()}] ${NAME} v${VERSION} log started\n`;
59
+ // Rich header the environment facts needed to make sense of a report later:
60
+ // exact version, how it was invoked, runtime, platform, and where it ran.
61
+ const header =
62
+ `[${new Date().toISOString()}] ${NAME} v${VERSION} — log started\n` +
63
+ ` argv: ${process.argv.slice(1).join(' ')}\n` +
64
+ ` node: ${process.version}\n` +
65
+ ` platform: ${process.platform} ${process.arch}\n` +
66
+ ` cwd: ${process.cwd()}\n`;
59
67
  fs.writeSync(logFd, header);
60
68
 
69
+ // Route log-file-only diagnostics (lib/diag.js) to this fd. These breadcrumbs
70
+ // (custom-marketplace phases, swallowed error causes, etc.) land ONLY in the
71
+ // log, never on the user's console.
72
+ setLogSink((s) => { try { fs.writeSync(logFd, s); } catch { /* ignore */ } });
73
+
61
74
  const origStdoutWrite = process.stdout.write.bind(process.stdout);
62
75
  const origStderrWrite = process.stderr.write.bind(process.stderr);
63
76
 
@@ -81,6 +94,7 @@ function setupLogging(logFilePath) {
81
94
  process.stderr.write = origStderrWrite;
82
95
  const footer = `\n[${new Date().toISOString()}] log ended\n`;
83
96
  try { fs.writeSync(logFd, footer); } catch { /* ignore */ }
97
+ setLogSink(null);
84
98
  try { fs.closeSync(logFd); } catch { /* ignore */ }
85
99
  delete process.env.MA_AGENTS_LOG_ACTIVE;
86
100
  };
@@ -990,7 +1004,7 @@ function resolveBaseBmadOk({ bmadInstalled, attempted, success }) {
990
1004
  * @param {Object} ctx
991
1005
  * @param {string} [ctx.bmadCustomSourceFlag] - `--bmad-custom-source <ref>` value
992
1006
  * @param {boolean} [ctx.yesFlag] - non-interactive run
993
- * @returns {Promise<null|{ source: string, mode: 'discovery'|'direct', selectedCodes: string[],
1007
+ * @returns {Promise<null|{ source: string, mode: 'discovery'|'direct'|'registry', selectedCodes: string[],
994
1008
  * plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
995
1009
  */
996
1010
  async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = false } = {}) {
@@ -1025,13 +1039,16 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
1025
1039
  }
1026
1040
 
1027
1041
  let enumerated;
1042
+ diag('custom-source: enumerate begin', { source });
1028
1043
  try {
1029
1044
  enumerated = await enumerateCustomSource(source);
1030
1045
  } catch (err) {
1031
1046
  // Fail clean (spike §3 contract) — a short, human-readable message only.
1032
1047
  // Never print err.stack here: an unreachable/unauthorized custom source
1033
1048
  // must not dump a raw upstream stack, and must never touch the base
1034
- // install (the rest of installWizard proceeds unaffected).
1049
+ // install (the rest of installWizard proceeds unaffected). The full cause
1050
+ // (and stack) goes to the LOG FILE only, for diagnosis.
1051
+ diagError('custom-source: enumerate failed', err);
1035
1052
  const message = err instanceof CustomMarketplaceError ? err.message : `Custom marketplace source error: ${err.message}`;
1036
1053
  console.log(chalk.red(`\n ${message}`));
1037
1054
  console.log(chalk.gray(' Skipping custom marketplace source; the rest of the install is unaffected.'));
@@ -1039,6 +1056,11 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
1039
1056
  }
1040
1057
 
1041
1058
  const { plugins, mode, rootDir, sourceUrl } = enumerated;
1059
+ diag('custom-source: enumerated', {
1060
+ source, mode, rootDir, sourceUrl,
1061
+ pluginCount: (plugins || []).length,
1062
+ codes: (plugins || []).map((p) => p.code),
1063
+ });
1042
1064
  if (!plugins || plugins.length === 0) {
1043
1065
  console.log(chalk.yellow(` Custom marketplace source "${source}" declared no plugins — nothing to select.`));
1044
1066
  return { source, mode, selectedCodes: [], plugins: [], rootDir, sourceUrl };
@@ -1056,15 +1078,31 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
1056
1078
  return { source, mode, selectedCodes, plugins, rootDir, sourceUrl };
1057
1079
  }
1058
1080
 
1081
+ // Registry mode (this story): show display_name + trust tier + category and
1082
+ // make the community/unverified trust tier explicit PER ENTRY (AC2, NFR71).
1083
+ // Registry entries are opt-in — nothing is pre-selected (AC1/AC2). Discovery/
1084
+ // direct keep their existing "all pre-selected" behavior unchanged (AC6).
1085
+ const isRegistry = mode === 'registry';
1059
1086
  const { chosenCodes } = await prompts({
1060
1087
  type: 'multiselect',
1061
1088
  name: 'chosenCodes',
1062
1089
  message: `Select extensions to install from "${source}":`,
1063
- choices: plugins.map((p) => ({
1064
- title: chalk.white(p.code) + chalk.gray(p.description ? ` — ${p.description}` : ''),
1065
- value: p.code,
1066
- selected: true,
1067
- })),
1090
+ choices: plugins.map((p) => {
1091
+ if (isRegistry) {
1092
+ const label = registryTrustLabel(p);
1093
+ const certified = p.trustTier === 'bmad-certified';
1094
+ const head = chalk.white(p.displayName || p.name || p.code);
1095
+ const tier = (certified ? chalk.green : chalk.yellow)(`[${label}]`);
1096
+ const cat = p.category ? chalk.gray(` {${p.category}${p.subcategory ? `/${p.subcategory}` : ''}}`) : '';
1097
+ const desc = p.description ? chalk.gray(` — ${p.description}`) : '';
1098
+ return { title: `${head} ${tier}${cat}${desc}`, value: p.code, selected: false };
1099
+ }
1100
+ return {
1101
+ title: chalk.white(p.code) + chalk.gray(p.description ? ` — ${p.description}` : ''),
1102
+ value: p.code,
1103
+ selected: true,
1104
+ };
1105
+ }),
1068
1106
  instructions: chalk.gray(' Use space to toggle, enter to confirm.'),
1069
1107
  });
1070
1108
 
@@ -1093,7 +1131,7 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
1093
1131
  *
1094
1132
  * @param {Object} ctx
1095
1133
  * @param {string} ctx.projectRoot
1096
- * @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct',
1134
+ * @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct'|'registry',
1097
1135
  * selectedCodes: string[], plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
1098
1136
  * `undefined` — nothing persisted; caller should fall back to `promptCustomMarketplace()`.
1099
1137
  * `null` — persisted source exists but could not be re-enumerated (fail-loud; skip this run).
@@ -1101,12 +1139,16 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
1101
1139
  */
1102
1140
  async function reconcileCustomMarketplaceOnUpdate({ projectRoot }) {
1103
1141
  const persisted = getCustomMarketplaceSelection(projectRoot);
1142
+ diag('custom-source: reconcile on update', persisted
1143
+ ? { source: persisted.source, mode: persisted.mode, selectedCodes: persisted.selectedCodes }
1144
+ : { persisted: false });
1104
1145
  if (!persisted) return undefined;
1105
1146
 
1106
1147
  let enumerated;
1107
1148
  try {
1108
1149
  enumerated = await enumerateCustomSource(persisted.source);
1109
1150
  } catch (err) {
1151
+ diagError('custom-source: reconcile re-enumerate failed', err);
1110
1152
  const message = err instanceof CustomMarketplaceError ? err.message : `Custom marketplace source error: ${err.message}`;
1111
1153
  console.log(chalk.red(`\n Custom marketplace update FAILED: ${message}`));
1112
1154
  console.log(
@@ -1531,6 +1573,12 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
1531
1573
  }
1532
1574
  }
1533
1575
  if (customMarketplaceSelection && customMarketplaceSelection.selectedCodes.length > 0) {
1576
+ diag('custom-source: install begin', {
1577
+ mode: customMarketplaceSelection.mode,
1578
+ source: customMarketplaceSelection.source,
1579
+ selectedCodes: customMarketplaceSelection.selectedCodes,
1580
+ baseBmadOk,
1581
+ });
1534
1582
  if (!baseBmadOk) {
1535
1583
  // AC 6 (30.6b) — never layer --custom-source onto a project with no
1536
1584
  // (or a broken) base BMAD install. The rest of the install is
@@ -1549,10 +1597,14 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
1549
1597
  tools: bmadTools,
1550
1598
  selection: customMarketplaceSelection,
1551
1599
  });
1600
+ diag('custom-source: install result', { success: !!success });
1552
1601
  if (success) {
1553
1602
  console.log(chalk.green(' Custom marketplace subset installed successfully!'));
1554
1603
  }
1555
1604
  } catch (err) {
1605
+ // User sees the one-liner; the full cause/stack (which entry, the
1606
+ // git/resolve error, the FR238 zero-module detail) goes to the log.
1607
+ diagError('custom-source: install failed', err);
1556
1608
  console.log(chalk.red(`\n Custom marketplace install failed: ${err.message}`));
1557
1609
  }
1558
1610
  }
@@ -37,7 +37,7 @@
37
37
  "name": "ma-skills",
38
38
  "source": "./",
39
39
  "description": "ma-agents extension module providing enterprise SDLC personas and operational workflow skills.",
40
- "version": "3.17.0-beta.0",
40
+ "version": "3.17.0-beta.2",
41
41
  "author": {
42
42
  "name": "Alon Mayaffit"
43
43
  },
package/lib/bmad.js CHANGED
@@ -1686,7 +1686,7 @@ async function deployClineWorkflows(projectRoot, tools = []) {
1686
1686
  * @param {string} ctx.projectRoot
1687
1687
  * @param {string[]} ctx.modules - The same bmad module set used for the base install.
1688
1688
  * @param {string[]} ctx.tools - The same IDE/tool set used for the base install.
1689
- * @param {{ source: string, mode: 'discovery'|'direct', selectedCodes: string[],
1689
+ * @param {{ source: string, mode: 'discovery'|'direct'|'registry', selectedCodes: string[],
1690
1690
  * plugins: Array<Object>, rootDir: string, sourceUrl: string|null }} ctx.selection -
1691
1691
  * 30.6a's `promptCustomMarketplace()` return value (extended with `rootDir`/`sourceUrl`).
1692
1692
  * @returns {Promise<boolean>} true on success; false on any failure (logged, never thrown).
@@ -1705,6 +1705,7 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1705
1705
  // bmad-method child process or fighting Node's require-cache semantics
1706
1706
  // to stub out module-internal function bindings.
1707
1707
  const stageFn = deps.stageCustomMarketplaceSubset || customMarketplace.stageCustomMarketplaceSubset;
1708
+ const stageRegistryFn = deps.stageRegistrySubset || customMarketplace.stageRegistrySubset;
1708
1709
  const runCommandFn = deps.runCommand || runCommand;
1709
1710
  const deployClineWorkflowsFn = deps.deployClineWorkflows || deployClineWorkflows;
1710
1711
  const cleanupStageFn = deps.cleanupStage || cleanupStage;
@@ -1719,15 +1720,27 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1719
1720
  let customSourceArg;
1720
1721
  let resolvedModules;
1721
1722
  try {
1722
- ({ stagePath, customSourceArg, resolvedModules } = await stageFn({
1723
- source: selection.source,
1724
- mode: selection.mode,
1725
- rootDir: selection.rootDir,
1726
- sourceUrl: selection.sourceUrl,
1727
- plugins: selection.plugins,
1728
- selectedCodes: selection.selectedCodes,
1729
- projectRoot,
1730
- }));
1723
+ if (selection.mode === 'registry') {
1724
+ // Registry-index mode (this story): two-level clone+resolve+stage of
1725
+ // each selected entry's own repository. Shares the same stage dir +
1726
+ // install path as discovery/direct; only the staging step differs.
1727
+ ({ stagePath, customSourceArg, resolvedModules } = await stageRegistryFn({
1728
+ source: selection.source,
1729
+ plugins: selection.plugins,
1730
+ selectedCodes: selection.selectedCodes,
1731
+ projectRoot,
1732
+ }));
1733
+ } else {
1734
+ ({ stagePath, customSourceArg, resolvedModules } = await stageFn({
1735
+ source: selection.source,
1736
+ mode: selection.mode,
1737
+ rootDir: selection.rootDir,
1738
+ sourceUrl: selection.sourceUrl,
1739
+ plugins: selection.plugins,
1740
+ selectedCodes: selection.selectedCodes,
1741
+ projectRoot,
1742
+ }));
1743
+ }
1731
1744
  } catch (err) {
1732
1745
  const message = err instanceof CustomMarketplaceError ? err.message : `Custom marketplace staging error: ${err.message}`;
1733
1746
  console.error(chalk.red(` Custom marketplace subset staging failed: ${message}`));
@@ -1763,14 +1776,28 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1763
1776
  // failure is logged as a warning only — it must never turn an
1764
1777
  // otherwise-successful install into a reported failure.
1765
1778
  try {
1766
- const sha = (resolvedModules || []).map((m) => m.cloneSha).find((s) => s) || null;
1767
- setCustomMarketplaceSelectionFn(projectRoot, {
1779
+ const persistEntry = {
1768
1780
  source: selection.source,
1769
1781
  sourceUrl: selection.sourceUrl ?? null,
1770
1782
  mode: selection.mode,
1771
1783
  selectedCodes: selection.selectedCodes,
1772
- sha,
1773
- });
1784
+ };
1785
+ if (selection.mode === 'registry') {
1786
+ // AC5 — record each selected entry's registry-provided
1787
+ // `approved_sha` (the immutable pin the registry declares), plus
1788
+ // its `approved_tag`. `sha` (the top-level field) mirrors the
1789
+ // first available approved_sha for back-compat with readers that
1790
+ // only look at `sha`.
1791
+ const selectedSet = new Set(selection.selectedCodes);
1792
+ const registryEntries = (selection.plugins || [])
1793
+ .filter((p) => selectedSet.has(p.code))
1794
+ .map((p) => ({ code: p.code, approvedSha: p.approvedSha ?? null, approvedTag: p.approvedTag ?? null }));
1795
+ persistEntry.entries = registryEntries;
1796
+ persistEntry.sha = registryEntries.map((e) => e.approvedSha).find((s) => s) || null;
1797
+ } else {
1798
+ persistEntry.sha = (resolvedModules || []).map((m) => m.cloneSha).find((s) => s) || null;
1799
+ }
1800
+ setCustomMarketplaceSelectionFn(projectRoot, persistEntry);
1774
1801
  } catch (persistErr) {
1775
1802
  console.warn(chalk.yellow(` Warning: could not persist custom marketplace selection: ${persistErr.message}`));
1776
1803
  }
@@ -39,10 +39,34 @@
39
39
 
40
40
  const path = require('node:path');
41
41
  const fs = require('fs-extra');
42
+ const yaml = require('js-yaml');
43
+ const { diag } = require('./diag');
42
44
 
43
45
  /** Deep, pinned-internal require path (NFR17). Single call site. */
44
46
  const CUSTOM_MODULE_MANAGER_PATH = 'bmad-method/tools/installer/modules/custom-module-manager.js';
45
47
 
48
+ /**
49
+ * Registry-index mode (this story) — the BMAD registry layout used by the
50
+ * official plugin registry (`bmad-code-org/bmad-plugins-marketplace`) and any
51
+ * source that follows the same convention. A `registry/` directory holding
52
+ * `official.yaml` and/or `community-index.yaml` (each a top-level `modules:`
53
+ * list) is a THIRD source shape, distinct from `discovery`
54
+ * (`.claude-plugin/marketplace.json`) and `direct` (whole repo = one module).
55
+ * Detection precedence is: discovery > registry > direct (see
56
+ * `enumerateCustomSource`).
57
+ */
58
+ const REGISTRY_DIR_NAME = 'registry';
59
+ const REGISTRY_OFFICIAL_INDEX = 'official.yaml';
60
+ const REGISTRY_COMMUNITY_INDEX = 'community-index.yaml';
61
+ const REGISTRY_SCHEMA_FILE = 'registry-schema.yaml';
62
+
63
+ /** Convert an OS-native relative path to a POSIX ('/'-separated) one for the
64
+ * staged marketplace.json (bmad-method's parseSource/skills detection is
65
+ * POSIX-oriented, and JSON marketplaces are always written POSIX-style). */
66
+ function toPosix(relPath) {
67
+ return relPath.split(path.sep).join('/');
68
+ }
69
+
46
70
  /**
47
71
  * The `CustomModuleManager` methods the custom-marketplace feature depends on.
48
72
  * Any drift in this set (renamed/removed on a bmad-method pin bump) must fail
@@ -63,6 +87,23 @@ const NFR71_WARNING =
63
87
  'Custom marketplace content is outside ma-agents\' sealed-bundle guarantee — ' +
64
88
  'it is installed as-is from the source you provided and is not vetted by ma-agents.';
65
89
 
90
+ /**
91
+ * NFR71 (registry mode) — a per-entry trust annotation the wizard appends to
92
+ * each registry choice so the community/unverified trust tier is explicit at
93
+ * the point of selection (not just in the one-time source-level warning). A
94
+ * `bmad-certified` (official) entry is labelled as such; anything else is made
95
+ * explicitly "unverified/community — installed as-is, not vetted".
96
+ *
97
+ * @param {{ trustTier?: string, type?: string }} plugin - a normalized registry entry
98
+ * @returns {string} human-readable trust label
99
+ */
100
+ function registryTrustLabel(plugin) {
101
+ if (plugin && plugin.trustTier === 'bmad-certified') {
102
+ return 'bmad-certified';
103
+ }
104
+ return 'unverified/community — installed as-is, not vetted by ma-agents';
105
+ }
106
+
66
107
  /**
67
108
  * Error type raised by this adapter for any enumeration failure (unreachable
68
109
  * source, invalid input, drifted surface). Callers should catch this and
@@ -150,6 +191,150 @@ function assertCustomModuleManagerSurface() {
150
191
  return { version, mgr };
151
192
  }
152
193
 
194
+ // ─── Registry-index mode (this story) ──────────────────────────────────────
195
+
196
+ /**
197
+ * Detect the BMAD registry-index layout inside an already-resolved source root.
198
+ * A source is treated as a registry when its `registry/` directory holds an
199
+ * `official.yaml` and/or a `community-index.yaml` module index (or, failing
200
+ * those, a `registry-schema.yaml`, which still marks the source as a registry
201
+ * even if it currently declares no modules). Called ONLY after discovery
202
+ * (`.claude-plugin/marketplace.json`) has been ruled out, so discovery always
203
+ * wins (AC1 precedence).
204
+ *
205
+ * @param {string} rootDir - Absolute resolved source root.
206
+ * @returns {Promise<{ isRegistry: boolean, officialPath: string|null, communityPath: string|null }>}
207
+ */
208
+ async function detectRegistryLayout(rootDir) {
209
+ const registryDir = path.join(rootDir, REGISTRY_DIR_NAME);
210
+ if (!(await fs.pathExists(registryDir))) {
211
+ return { isRegistry: false, officialPath: null, communityPath: null };
212
+ }
213
+ const officialAbs = path.join(registryDir, REGISTRY_OFFICIAL_INDEX);
214
+ const communityAbs = path.join(registryDir, REGISTRY_COMMUNITY_INDEX);
215
+ const schemaAbs = path.join(registryDir, REGISTRY_SCHEMA_FILE);
216
+ const [hasOfficial, hasCommunity, hasSchema] = await Promise.all([
217
+ fs.pathExists(officialAbs),
218
+ fs.pathExists(communityAbs),
219
+ fs.pathExists(schemaAbs),
220
+ ]);
221
+ if (!hasOfficial && !hasCommunity && !hasSchema) {
222
+ return { isRegistry: false, officialPath: null, communityPath: null };
223
+ }
224
+ return {
225
+ isRegistry: true,
226
+ officialPath: hasOfficial ? officialAbs : null,
227
+ communityPath: hasCommunity ? communityAbs : null,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Normalize one raw registry-index entry (conforming to
233
+ * `registry/registry-schema.yaml`) into the SAME plugin-choice object shape the
234
+ * wizard's multiselect (30.6a/6b) already consumes, plus the registry-only
235
+ * pointer fields the two-level install needs (`repository`, `moduleDefinition`,
236
+ * `approvedSha`, `approvedTag`, `version`, `trustTier`).
237
+ *
238
+ * @param {Object} entry - raw YAML module entry
239
+ * @param {{ official: boolean }} ctx - which index the entry came from
240
+ * @returns {Object} normalized plugin-choice object
241
+ */
242
+ function normalizeRegistryEntry(entry, { official }) {
243
+ const trustTier = entry.trust_tier || (official ? 'bmad-certified' : 'unverified');
244
+ const type = entry.type || (official ? 'bmad-org' : 'community');
245
+ return {
246
+ code: entry.code || entry.name,
247
+ name: entry.name,
248
+ displayName: entry.display_name || entry.name,
249
+ description: entry.description || '',
250
+ type,
251
+ trustTier,
252
+ category: entry.category || null,
253
+ subcategory: entry.subcategory || null,
254
+ // Registry-only pointer fields (two-level install).
255
+ repository: entry.repository || null,
256
+ moduleDefinition: entry.module_definition || null,
257
+ approvedSha: entry.approved_sha || null,
258
+ approvedTag: entry.approved_tag || null,
259
+ version: entry.version || null,
260
+ builtIn: entry.built_in === true,
261
+ isExternal: true,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Parse one registry index file (`official.yaml` / `community-index.yaml`) into
267
+ * a normalized entry list. A missing/empty `modules:` key yields `[]`; a YAML
268
+ * syntax error or a non-list `modules:` throws a clean `CustomMarketplaceError`
269
+ * (AC4 malformed-index fail-clean). Individual entries that lack a usable
270
+ * `name` or `repository` pointer are SKIPPED (they are not installable and must
271
+ * not sink an otherwise-valid registry).
272
+ *
273
+ * @param {string} indexPath - absolute path to the index file
274
+ * @param {{ official: boolean }} ctx
275
+ * @returns {Promise<Array<Object>>} normalized entries
276
+ * @throws {CustomMarketplaceError}
277
+ */
278
+ async function parseRegistryIndex(indexPath, { official }) {
279
+ const label = path.basename(indexPath);
280
+ let raw;
281
+ try {
282
+ raw = await fs.readFile(indexPath, 'utf8');
283
+ } catch (err) {
284
+ throw new CustomMarketplaceError(`Could not read registry index "${label}": ${err.message}`, { cause: err });
285
+ }
286
+ let doc;
287
+ try {
288
+ doc = yaml.load(raw);
289
+ } catch (err) {
290
+ throw new CustomMarketplaceError(
291
+ `Registry index "${label}" is not valid YAML: ${err.message}`,
292
+ { cause: err }
293
+ );
294
+ }
295
+ if (doc === null || doc === undefined) return [];
296
+ if (typeof doc !== 'object' || Array.isArray(doc)) {
297
+ throw new CustomMarketplaceError(`Registry index "${label}" is not a mapping with a "modules" list.`);
298
+ }
299
+ const modules = doc.modules;
300
+ if (modules === undefined || modules === null) return [];
301
+ if (!Array.isArray(modules)) {
302
+ throw new CustomMarketplaceError(`Registry index "${label}" has a "modules" key that is not a list.`);
303
+ }
304
+ const out = [];
305
+ for (const entry of modules) {
306
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
307
+ if (typeof entry.name !== 'string' || !entry.name.trim()) continue; // not addressable — skip
308
+ if (typeof entry.repository !== 'string' || !entry.repository.trim()) continue; // no pointer — not installable
309
+ out.push(normalizeRegistryEntry(entry, { official }));
310
+ }
311
+ return out;
312
+ }
313
+
314
+ /**
315
+ * Build the `registry`-mode enumeration result: parse the official + community
316
+ * indexes, merge, and de-dupe by `name` (official — bmad-certified — wins over
317
+ * a same-named community entry).
318
+ *
319
+ * @param {string} rootDir
320
+ * @param {string|null} sourceUrl
321
+ * @param {{ officialPath: string|null, communityPath: string|null }} layout
322
+ * @returns {Promise<{ mode: 'registry', plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
323
+ */
324
+ async function enumerateRegistrySource(rootDir, sourceUrl, layout) {
325
+ const official = layout.officialPath ? await parseRegistryIndex(layout.officialPath, { official: true }) : [];
326
+ const community = layout.communityPath ? await parseRegistryIndex(layout.communityPath, { official: false }) : [];
327
+
328
+ // Official entries are appended first so they win the de-dupe on a name
329
+ // collision (a bmad-certified module must never be shadowed by an unverified
330
+ // community entry of the same name).
331
+ const byName = new Map();
332
+ for (const p of [...official, ...community]) {
333
+ if (!byName.has(p.name)) byName.set(p.name, p);
334
+ }
335
+ return { mode: 'registry', plugins: [...byName.values()], rootDir, sourceUrl };
336
+ }
337
+
153
338
  /**
154
339
  * Enumerate the plugins declared by a custom marketplace source.
155
340
  *
@@ -204,6 +389,31 @@ async function enumerateCustomSource(source) {
204
389
  return { mode: 'discovery', plugins, rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
205
390
  }
206
391
 
392
+ // AC1 precedence: discovery (above) wins; else a `registry/` layout →
393
+ // `registry` mode; else `direct` (below). The registry index is a
394
+ // higher-level pointer format (each entry names a separate module repo),
395
+ // so it is enumerated distinctly from a single-repo direct source.
396
+ let registryLayout;
397
+ try {
398
+ registryLayout = await detectRegistryLayout(resolved.rootDir);
399
+ } catch (err) {
400
+ throw new CustomMarketplaceError(
401
+ `Could not inspect "${source}" for a registry layout: ${err.message}`,
402
+ { cause: err }
403
+ );
404
+ }
405
+ if (registryLayout.isRegistry) {
406
+ try {
407
+ return await enumerateRegistrySource(resolved.rootDir, resolved.sourceUrl, registryLayout);
408
+ } catch (err) {
409
+ if (err instanceof CustomMarketplaceError) throw err;
410
+ throw new CustomMarketplaceError(
411
+ `Could not read the registry index from "${source}": ${err.message}`,
412
+ { cause: err }
413
+ );
414
+ }
415
+ }
416
+
207
417
  // FR240 — "direct" mode (no marketplace.json at the source root): treat the
208
418
  // whole source as a single module so the wizard's multiselect still has
209
419
  // exactly one, well-described choice. 30.6b decides how a direct-mode
@@ -244,17 +454,17 @@ const CUSTOM_MARKETPLACE_STAGE_DIR_NAME = '.ma-agents-custom-marketplace-stage';
244
454
  * @param {string} rootDir - Absolute path to the direct-mode source root.
245
455
  * @returns {Promise<string[]>} Directory names (not paths) that contain a SKILL.md.
246
456
  */
247
- async function scanDirectModeSkillDirs(rootDir) {
457
+ async function scanSkillDirs(baseDir) {
248
458
  let entries;
249
459
  try {
250
- entries = await fs.readdir(rootDir, { withFileTypes: true });
460
+ entries = await fs.readdir(baseDir, { withFileTypes: true });
251
461
  } catch {
252
462
  return [];
253
463
  }
254
464
  const skillDirs = [];
255
465
  for (const entry of entries) {
256
466
  if (!entry.isDirectory()) continue;
257
- const skillMd = path.join(rootDir, entry.name, 'SKILL.md');
467
+ const skillMd = path.join(baseDir, entry.name, 'SKILL.md');
258
468
  if (await fs.pathExists(skillMd)) {
259
469
  skillDirs.push(entry.name);
260
470
  }
@@ -262,6 +472,12 @@ async function scanDirectModeSkillDirs(rootDir) {
262
472
  return skillDirs;
263
473
  }
264
474
 
475
+ /** Story 30.6b direct-mode scan (thin alias of {@link scanSkillDirs} at the
476
+ * source root) — kept as a named export for the 30.6b surface. */
477
+ async function scanDirectModeSkillDirs(rootDir) {
478
+ return scanSkillDirs(rootDir);
479
+ }
480
+
265
481
  /**
266
482
  * Resolve every selected plugin's installable skill/module layout via
267
483
  * bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
@@ -336,14 +552,38 @@ async function resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selec
336
552
  * @returns {Array<Object>} The flattened list of resolved modules (all plugins).
337
553
  * @throws {CustomMarketplaceError} on any violation above.
338
554
  */
339
- function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '' } = {}) {
555
+ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '', mode = null } = {}) {
340
556
  const allModules = resolvedByPlugin.flatMap((r) => r.resolved || []);
341
557
 
558
+ // Per-plugin resolution detail → log file only. When the guard below trips,
559
+ // this line pinpoints WHICH entry resolved to nothing (and how many
560
+ // skills each resolved module got) — the crux of a "zero modules" report.
561
+ diag('custom-source: resolution', {
562
+ source, mode,
563
+ perPlugin: resolvedByPlugin.map((r) => ({
564
+ plugin: (r.plugin && (r.plugin.code || r.plugin.name)) || (r.rawPlugin && r.rawPlugin.name) || '?',
565
+ modules: (r.resolved || []).length,
566
+ skills: (r.resolved || []).reduce((n, m) => n + ((m.skillPaths && m.skillPaths.length) || 0), 0),
567
+ })),
568
+ totalModules: allModules.length,
569
+ });
570
+
342
571
  if (allModules.length === 0) {
572
+ // AC7 — a direct-mode source that resolves to nothing is the exact
573
+ // failure a mis-pointed registry URL used to produce. Name the likely
574
+ // cause and note that registry indexes are now supported (so re-running
575
+ // will auto-detect them), instead of a bare "zero modules" abort.
576
+ const directHint =
577
+ mode === 'direct'
578
+ ? ' The source has no `.claude-plugin/marketplace.json` and no `module.yaml`/skills ' +
579
+ 'directories at its root. If it is a BMAD registry index (a `registry/` directory ' +
580
+ 'with `official.yaml`/`community-index.yaml`), that format is now supported — re-run ' +
581
+ 'and it will be detected as a `registry` source.'
582
+ : '';
343
583
  throw new CustomMarketplaceError(
344
584
  `Custom marketplace selection from "${source}" resolved to zero installable modules ` +
345
585
  `— bmad-method's PluginResolver could not locate module.yaml/skills for any selected ` +
346
- `plugin. Nothing would be installed; aborting before staging (FR238).`
586
+ `plugin. Nothing would be installed; aborting before staging (FR238).${directHint}`
347
587
  );
348
588
  }
349
589
 
@@ -392,17 +632,19 @@ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = ''
392
632
  * @throws {CustomMarketplaceError} on a drifted surface, an unmatched selection, or a failed
393
633
  * FR238 pre-flight check (see assertResolutionNonEmptyAndUniqueLeaves).
394
634
  */
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
-
635
+ /**
636
+ * Prepare a clean, empty stage directory under `projectRoot`. Safety pattern
637
+ * mirrors lib/bmad.js#stagePlugin (Story 22.6/22.8): never follow a pre-existing
638
+ * stage path that is a symlink, and wipe any stale stage from a prior failed run
639
+ * before copying fresh content. Shared by both the discovery/direct
640
+ * (`stageCustomMarketplaceSubset`) and registry (`stageRegistrySubset`) paths.
641
+ *
642
+ * @param {string} projectRoot - already-resolved absolute project root
643
+ * @returns {string} absolute stage path (created empty)
644
+ * @throws {CustomMarketplaceError}
645
+ */
646
+ function prepareCleanStageDir(projectRoot) {
401
647
  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
648
  if (fs.existsSync(stagePath)) {
407
649
  const stageLstat = fs.lstatSync(stagePath);
408
650
  if (stageLstat.isSymbolicLink()) {
@@ -418,6 +660,16 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
418
660
  }
419
661
  }
420
662
  fs.mkdirpSync(stagePath);
663
+ return stagePath;
664
+ }
665
+
666
+ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl, plugins, selectedCodes, projectRoot }) {
667
+ projectRoot = path.resolve(projectRoot);
668
+
669
+ const resolvedByPlugin = await resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selectedCodes });
670
+ const allModules = assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source, mode });
671
+
672
+ const stagePath = prepareCleanStageDir(projectRoot);
421
673
 
422
674
  // Copy every resolved file, preserving its path relative to rootDir, so
423
675
  // bmad-method's own re-resolution against the stage (a fresh 'local' type
@@ -459,6 +711,235 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
459
711
  return { stagePath, customSourceArg, resolvedModules: allModules };
460
712
  }
461
713
 
714
+ // ─── Registry-index mode — two-level stage + install ───────────────────────
715
+
716
+ /**
717
+ * Resolve one selected registry entry into a staged rawPlugin + resolved
718
+ * modules. Two-level: clone the entry's `repository` (via bmad-method's own
719
+ * `resolveSource`/`cloneRepo` — ambient git creds, GIT_TERMINAL_PROMPT=0, no
720
+ * token handling — FR241), locate the module at `module_definition` (fallback:
721
+ * the repo root, i.e. PluginResolver over the whole clone), scan for skill
722
+ * directories, and resolve via `resolvePlugin`.
723
+ *
724
+ * A community entry is cloned at its `approved_tag` (the tag the registry
725
+ * publishes for the approved release — it points at `approved_sha`). We pin to
726
+ * the TAG rather than the raw SHA because bmad-method's `cloneRepo()` drives
727
+ * `git clone --branch <ref>` / `git fetch origin <ref>`, which accept a tag or
728
+ * branch but NOT a bare commit SHA. The `approved_sha` is still recorded on the
729
+ * persisted selection (see lib/bmad.js#installCustomMarketplaceSubset) as the
730
+ * immutable record of what the registry approved.
731
+ *
732
+ * @param {Object} plugin - a normalized registry entry (from enumerateCustomSource)
733
+ * @param {(input: string, opts: Object) => Promise<Object>} resolveSourceFn
734
+ * @param {(repoPath: string, rawPlugin: Object, url: string|null, local: string|null) => Promise<Array>} resolvePluginFn
735
+ * @returns {Promise<{ plugin: Object, rawPlugin: Object, resolved: Array<Object>, entryRootDir: string }>}
736
+ * @throws {CustomMarketplaceError}
737
+ */
738
+ async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
739
+ if (!plugin.repository) {
740
+ throw new CustomMarketplaceError(
741
+ `Registry entry '${plugin.code}' has no repository pointer — cannot install it.`
742
+ );
743
+ }
744
+
745
+ // Pin ref (tag/branch) via the `@<ref>` SOURCE-STRING SUFFIX that
746
+ // bmad-method's resolveSource() parses (custom-module-manager.js:
747
+ // "Extract optional @<tag-or-branch> suffix"). resolveSource has NO
748
+ // pinOverride/option for this — the ref MUST ride on the input string. A raw
749
+ // commit SHA is unsupported (`git clone --branch` can't take one), so we pin
750
+ // to `approved_tag` (the registry publishes it pointing at `approved_sha`);
751
+ // `approved_sha` is still recorded verbatim on the persisted selection.
752
+ const pinRef = plugin.approvedTag || null;
753
+ // The ref must match resolveSource's own ref rule (/^[\w.\-+/]+$/, no ':')
754
+ // or we omit it and clone the default branch rather than build a malformed
755
+ // source string.
756
+ const safePinRef = pinRef && /^[\w.\-+/]+$/.test(pinRef) && !pinRef.includes(':') ? pinRef : null;
757
+ const cloneInput = safePinRef ? `${plugin.repository}@${safePinRef}` : plugin.repository;
758
+ diag('custom-source: registry entry clone', {
759
+ code: plugin.code,
760
+ repository: plugin.repository,
761
+ pinRef: safePinRef,
762
+ approvedSha: plugin.approvedSha || null,
763
+ moduleDefinition: plugin.moduleDefinition || '(none — repo root)',
764
+ });
765
+
766
+ let resolvedSource;
767
+ try {
768
+ resolvedSource = await resolveSourceFn(cloneInput, {
769
+ skipInstall: true,
770
+ silent: true,
771
+ });
772
+ } catch (err) {
773
+ throw new CustomMarketplaceError(
774
+ `Could not clone registry entry '${plugin.code}' from "${plugin.repository}"` +
775
+ `${safePinRef ? ` @ ${safePinRef}` : ''}: ${err.message}`,
776
+ { cause: err }
777
+ );
778
+ }
779
+
780
+ const entryRootDir = resolvedSource.rootDir;
781
+ const entrySourceUrl = resolvedSource.sourceUrl || null;
782
+
783
+ // Locate the module within the clone. `module_definition` is a POSIX,
784
+ // repo-relative path to the module's module.yaml; its directory is the
785
+ // module root. Absent → fall back to the repo root (PluginResolver over the
786
+ // whole clone), matching the AC3 fallback.
787
+ let relModuleDirPosix = '.';
788
+ if (plugin.moduleDefinition) {
789
+ const defPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
790
+ const moduleYamlAbs = path.join(entryRootDir, ...defPosix.split('/'));
791
+ if (!(await fs.pathExists(moduleYamlAbs))) {
792
+ throw new CustomMarketplaceError(
793
+ `Registry entry '${plugin.code}': module_definition "${plugin.moduleDefinition}" ` +
794
+ `was not found inside ${plugin.repository}. The registry pointer is stale or the ` +
795
+ `repository layout changed; aborting before staging (FR238).`
796
+ );
797
+ }
798
+ relModuleDirPosix = path.posix.dirname(defPosix);
799
+ }
800
+
801
+ const moduleBaseAbs =
802
+ relModuleDirPosix === '.' ? entryRootDir : path.join(entryRootDir, ...relModuleDirPosix.split('/'));
803
+ const skillNames = await scanSkillDirs(moduleBaseAbs);
804
+ const skillRelPaths = skillNames.map((n) => (relModuleDirPosix === '.' ? n : `${relModuleDirPosix}/${n}`));
805
+ diag('custom-source: registry entry resolved', {
806
+ code: plugin.code,
807
+ moduleDir: relModuleDirPosix,
808
+ skillDirsFound: skillNames.length,
809
+ skills: skillNames,
810
+ });
811
+
812
+ const rawPlugin = {
813
+ name: plugin.code,
814
+ description: plugin.description || '',
815
+ version: plugin.version || null,
816
+ source: relModuleDirPosix,
817
+ // Empty when the module dir has no SKILL.md subdirs — resolvePlugin then
818
+ // returns [] and the FR238 guard aborts cleanly (zero installable modules).
819
+ skills: skillRelPaths,
820
+ };
821
+
822
+ const localPath = entrySourceUrl ? null : entryRootDir;
823
+ const resolved = await resolvePluginFn(entryRootDir, rawPlugin, entrySourceUrl, localPath);
824
+
825
+ return { plugin, rawPlugin, resolved, entryRootDir };
826
+ }
827
+
828
+ /**
829
+ * Registry-mode counterpart of {@link stageCustomMarketplaceSubset}. For each
830
+ * selected registry entry it clones the entry's `repository`, resolves the
831
+ * pointed module, and stages the resolved content into the SAME project-local
832
+ * stage directory the discovery/direct path uses — but namespaced under the
833
+ * entry's `code/` so modules pulled from DIFFERENT repositories can never
834
+ * collide on a shared relative path. Enforces the same FR238 non-empty +
835
+ * unique-leaf guard, then returns the same `{ stagePath, customSourceArg,
836
+ * resolvedModules }` shape so lib/bmad.js#installCustomMarketplaceSubset installs
837
+ * it identically via `buildBmadArgs` + `--custom-source`.
838
+ *
839
+ * @param {Object} ctx
840
+ * @param {string} ctx.source - Original registry source string (messages only).
841
+ * @param {Array<Object>} ctx.plugins - Full enumerated registry entry list.
842
+ * @param {string[]} ctx.selectedCodes
843
+ * @param {string} ctx.projectRoot
844
+ * @param {Object} [ctx.deps] - Injectable seams (tests): { resolveSource, resolvePlugin }.
845
+ * @returns {Promise<{ stagePath: string, customSourceArg: string, resolvedModules: Array<Object> }>}
846
+ * @throws {CustomMarketplaceError}
847
+ */
848
+ async function stageRegistrySubset({ source, plugins, selectedCodes, projectRoot, deps = {} }) {
849
+ projectRoot = path.resolve(projectRoot);
850
+ const { mgr } = assertCustomModuleManagerSurface();
851
+ const resolveSourceFn = deps.resolveSource || ((input, opts) => mgr.resolveSource(input, opts));
852
+ const resolvePluginFn =
853
+ deps.resolvePlugin || ((repoPath, rawPlugin, url, local) => mgr.resolvePlugin(repoPath, rawPlugin, url, local));
854
+
855
+ const selectedSet = new Set(selectedCodes);
856
+ const selected = (plugins || []).filter((p) => selectedSet.has(p.code));
857
+ if (selected.length === 0) {
858
+ throw new CustomMarketplaceError(
859
+ `None of the selected codes (${[...selectedSet].join(', ') || '(none)'}) matched a ` +
860
+ `registry entry — nothing to stage.`
861
+ );
862
+ }
863
+
864
+ // Each entry is staged under its own `code/` namespace, so two selected
865
+ // entries sharing a code would silently overwrite each other. enumerate
866
+ // de-dupes by `name`, but a malformed registry could give two differently-
867
+ // named entries the same `code` — reject that here rather than stage a
868
+ // corrupt subset (FR238 fail-clean).
869
+ const seenCodes = new Set();
870
+ for (const p of selected) {
871
+ if (seenCodes.has(p.code)) {
872
+ throw new CustomMarketplaceError(
873
+ `Two selected registry entries share the code '${p.code}' — codes must be unique ` +
874
+ `to install. Aborting before staging (FR238).`
875
+ );
876
+ }
877
+ seenCodes.add(p.code);
878
+ }
879
+
880
+ // Resolve every selected entry (each from its own clone) BEFORE touching the
881
+ // stage dir, so an unreachable/stale entry aborts clean with nothing staged.
882
+ const entries = [];
883
+ for (const plugin of selected) {
884
+ entries.push(await resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn));
885
+ }
886
+
887
+ const allModules = assertResolutionNonEmptyAndUniqueLeaves(
888
+ entries.map(({ plugin, rawPlugin, resolved }) => ({ plugin, rawPlugin, resolved })),
889
+ { source, mode: 'registry' }
890
+ );
891
+
892
+ const stagePath = prepareCleanStageDir(projectRoot);
893
+
894
+ // Copy each entry's resolved content under a `code/` namespace so entries
895
+ // cloned from different repos never overwrite each other, and rewrite the
896
+ // staged rawPlugin's skill/source paths to match that namespace so
897
+ // bmad-method's re-resolution against the stage reproduces the identical
898
+ // module (skills at code/<rel>, module.yaml at their common parent).
899
+ const stagedPlugins = [];
900
+ try {
901
+ for (const { plugin, resolved, entryRootDir } of entries) {
902
+ const prefix = plugin.code;
903
+ const relFromEntry = (absSrc) => toPosix(path.relative(entryRootDir, absSrc));
904
+ const copyUnderPrefix = (absSrc) => {
905
+ const dest = path.join(stagePath, prefix, ...relFromEntry(absSrc).split('/'));
906
+ fs.copySync(absSrc, dest, { overwrite: true, dereference: true });
907
+ };
908
+
909
+ const stagedSkills = [];
910
+ for (const mod of resolved) {
911
+ for (const skillPath of mod.skillPaths) {
912
+ copyUnderPrefix(skillPath);
913
+ stagedSkills.push(`${prefix}/${relFromEntry(skillPath)}`);
914
+ }
915
+ if (mod.moduleYamlPath) copyUnderPrefix(mod.moduleYamlPath);
916
+ if (mod.moduleHelpCsvPath) copyUnderPrefix(mod.moduleHelpCsvPath);
917
+ }
918
+
919
+ stagedPlugins.push({
920
+ name: plugin.code,
921
+ description: plugin.description || '',
922
+ version: plugin.version || null,
923
+ source: prefix,
924
+ skills: stagedSkills,
925
+ });
926
+ }
927
+ } catch (err) {
928
+ try { fs.removeSync(stagePath); } catch { /* swallow — primary error wins */ }
929
+ throw new CustomMarketplaceError(`Staging the registry subset failed: ${err.message}`, { cause: err });
930
+ }
931
+
932
+ const marketplaceDir = path.join(stagePath, '.claude-plugin');
933
+ fs.mkdirpSync(marketplaceDir);
934
+ fs.writeFileSync(
935
+ path.join(marketplaceDir, 'marketplace.json'),
936
+ JSON.stringify({ name: 'ma-agents-custom-registry-subset', plugins: stagedPlugins }, null, 2)
937
+ );
938
+
939
+ const customSourceArg = `./${CUSTOM_MARKETPLACE_STAGE_DIR_NAME}`;
940
+ return { stagePath, customSourceArg, resolvedModules: allModules };
941
+ }
942
+
462
943
  // ─── Story 30.7 — persist + re-enumerate/reconcile on update ───────────────
463
944
 
464
945
  /**
@@ -499,9 +980,21 @@ module.exports = {
499
980
  // Story 30.6b — prune + stage the selected subset
500
981
  CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
501
982
  scanDirectModeSkillDirs,
983
+ scanSkillDirs,
502
984
  resolveSelectedPlugins,
503
985
  assertResolutionNonEmptyAndUniqueLeaves,
504
986
  stageCustomMarketplaceSubset,
987
+ // Registry-index mode (this story)
988
+ REGISTRY_DIR_NAME,
989
+ REGISTRY_OFFICIAL_INDEX,
990
+ REGISTRY_COMMUNITY_INDEX,
991
+ registryTrustLabel,
992
+ detectRegistryLayout,
993
+ parseRegistryIndex,
994
+ normalizeRegistryEntry,
995
+ enumerateRegistrySource,
996
+ resolveRegistryEntry,
997
+ stageRegistrySubset,
505
998
  // Story 30.7 — persist + reconcile on update
506
999
  reconcileCustomMarketplaceSelection,
507
1000
  };
package/lib/diag.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Log-file-only diagnostics.
5
+ *
6
+ * When `--log` is active, bin/cli.js#setupLogging installs a sink that writes
7
+ * to the log file descriptor. `diag()`/`diagError()` then emit structured
8
+ * breadcrumbs to the LOG FILE ONLY — never to the user's console (the wizard's
9
+ * user-facing output intentionally stays clean; e.g. a failed custom-marketplace
10
+ * source shows a one-line message, not a raw stack — NFR71). Without a sink
11
+ * (no `--log`), every call is a cheap no-op.
12
+ *
13
+ * This is how the swallowed root cause of a custom-marketplace failure (the
14
+ * git/clone/resolve error behind a clean CustomMarketplaceError, the per-entry
15
+ * module_definition that was missing, the FR238 zero-module trigger, etc.)
16
+ * reaches the log for post-hoc diagnosis without leaking to the terminal.
17
+ */
18
+
19
+ let sink = null;
20
+
21
+ /**
22
+ * @param {((text: string) => void)|null} fn - writer for log-file output, or null to disable.
23
+ */
24
+ function setLogSink(fn) {
25
+ sink = typeof fn === 'function' ? fn : null;
26
+ }
27
+
28
+ /** @returns {boolean} whether diagnostics are being captured. */
29
+ function isActive() {
30
+ return sink !== null;
31
+ }
32
+
33
+ function stamp() {
34
+ // new Date() is available in the CLI runtime (this is not a workflow script).
35
+ return new Date().toISOString();
36
+ }
37
+
38
+ function stringify(detail) {
39
+ if (typeof detail === 'string') return detail;
40
+ try {
41
+ return JSON.stringify(detail);
42
+ } catch {
43
+ return String(detail);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Emit one diagnostic breadcrumb to the log file (no-op without a sink).
49
+ * @param {string} label
50
+ * @param {*} [detail] - string or JSON-serializable value.
51
+ */
52
+ function diag(label, detail) {
53
+ if (!sink) return;
54
+ let line = `[diag ${stamp()}] ${label}`;
55
+ if (detail !== undefined) line += `: ${stringify(detail)}`;
56
+ try {
57
+ sink(line + '\n');
58
+ } catch {
59
+ /* never let diagnostics break the install */
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Emit an error — including its `stack` AND any wrapped `cause` (message +
65
+ * stack) — to the log file. This is the crucial bit for custom-marketplace
66
+ * failures, whose user-facing message deliberately hides the upstream cause.
67
+ * @param {string} label
68
+ * @param {Error|*} err
69
+ */
70
+ function diagError(label, err) {
71
+ if (!sink || !err) return;
72
+ const parts = [`[diag ${stamp()}] ${label}: ${err.message || String(err)}`];
73
+ if (err.stack) parts.push(` stack: ${err.stack}`);
74
+ if (err.cause) {
75
+ const c = err.cause;
76
+ parts.push(` cause: ${(c && c.message) || String(c)}`);
77
+ if (c && c.stack) parts.push(` cause.stack: ${c.stack}`);
78
+ }
79
+ try {
80
+ sink(parts.join('\n') + '\n');
81
+ } catch {
82
+ /* never let diagnostics break the install */
83
+ }
84
+ }
85
+
86
+ module.exports = { setLogSink, isActive, diag, diagError };
package/lib/profile.js CHANGED
@@ -286,9 +286,11 @@ function markNoticeShown(projectRoot, noticeId) {
286
286
  * {
287
287
  * source: string, // original user input (local path or Git URL)
288
288
  * sourceUrl: string|null, // resolved clone URL (Git sources) or null (local)
289
- * mode: 'discovery'|'direct',
289
+ * mode: 'discovery'|'direct'|'registry',
290
290
  * selectedCodes: string[],
291
- * sha: string|null, // resolved commit SHA pin (Git-URL sources only)
291
+ * sha: string|null, // resolved commit SHA pin (Git-URL / registry sources)
292
+ * entries?: Array<{ code: string, approvedSha: string|null, approvedTag: string|null }>,
293
+ * // registry-mode only: per-entry approved_sha/tag pins (AC5)
292
294
  * updatedAt: string, // ISO timestamp of the last persist
293
295
  * }
294
296
  */
@@ -342,8 +344,8 @@ function setCustomMarketplaceSelection(projectRoot, entry) {
342
344
  if (typeof entry.source !== 'string' || !entry.source) {
343
345
  throw new Error('setCustomMarketplaceSelection requires a non-empty "source" string');
344
346
  }
345
- if (entry.mode !== 'discovery' && entry.mode !== 'direct') {
346
- throw new Error(`setCustomMarketplaceSelection requires mode 'discovery' or 'direct' (got ${JSON.stringify(entry.mode)})`);
347
+ if (entry.mode !== 'discovery' && entry.mode !== 'direct' && entry.mode !== 'registry') {
348
+ throw new Error(`setCustomMarketplaceSelection requires mode 'discovery', 'direct' or 'registry' (got ${JSON.stringify(entry.mode)})`);
347
349
  }
348
350
  if (!Array.isArray(entry.selectedCodes)) {
349
351
  throw new Error('setCustomMarketplaceSelection requires "selectedCodes" to be an array');
@@ -362,7 +364,7 @@ function setCustomMarketplaceSelection(projectRoot, entry) {
362
364
  manifest = bootstrapRootManifest();
363
365
  }
364
366
 
365
- manifest.customMarketplace = {
367
+ const record = {
366
368
  source: entry.source,
367
369
  sourceUrl: entry.sourceUrl ?? null,
368
370
  mode: entry.mode,
@@ -370,6 +372,23 @@ function setCustomMarketplaceSelection(projectRoot, entry) {
370
372
  sha: entry.sha ?? null,
371
373
  updatedAt: new Date().toISOString(),
372
374
  };
375
+
376
+ // Registry-mode (this story): each selected entry pins its own commit via the
377
+ // registry's `approved_sha`. Record them per-entry so a later reconcile/update
378
+ // knows exactly what the registry approved at install time (AC5). Only
379
+ // persisted when the caller supplies a well-formed `entries` array — the
380
+ // discovery/direct path leaves this key absent (back-compat).
381
+ if (Array.isArray(entry.entries)) {
382
+ record.entries = entry.entries
383
+ .filter(e => e && typeof e === 'object' && typeof e.code === 'string' && e.code.length > 0)
384
+ .map(e => ({
385
+ code: e.code,
386
+ approvedSha: e.approvedSha ?? null,
387
+ approvedTag: e.approvedTag ?? null,
388
+ }));
389
+ }
390
+
391
+ manifest.customMarketplace = record;
373
392
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
374
393
  }
375
394
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ma-agents",
3
- "version": "3.17.0-beta.0",
3
+ "version": "3.17.0-beta.2",
4
4
  "description": "NPX tool to install skills for AI coding agents (Claude Code, Gemini, Copilot, Kilocode, Cline, Cursor, Roo Code)",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/cli.js",
11
- "test": "node test/yes-flag.test.js && node test/skill-authoring.test.js && node test/skill-validation.test.js && node test/skill-mandatory.test.js && node test/skill-customize-agent.test.js && node test/create-agent.test.js && node test/generate-project-context.test.js && node test/build-bmad-args.test.js && node test/build-bmad-cache-reader.test.js && node test/bundle-filter-set-equality.test.js && node test/bmad-version-bump.test.js && node test/staging-6-10-0-regression.test.js && node test/uv-transition-resilience.test.js && node test/passthrough-reconciliation-6-9-0.test.js && node test/extension-module-restructure.test.js && node test/convert-agents-to-skills.test.js && node test/migration.test.js && node test/migration-validation.test.js && node test/story-15-5-workflow-skills.test.js && node test/repo-layout.test.js && node test/config-storage.test.js && node test/cross-repo-validation.test.js && node test/config-lost-on-update.test.js && node test/portable-paths.test.js && node test/cicd-remote-mode.test.js && node test/config-layout.test.js && node test/roo-code-agent.test.js && node test/roo-code-injection.test.js && node test/profile.test.js && node test/instruction-block.test.js && node test/instruction-injection.test.js && node test/agents-md.test.js && node test/instruction-block-git.test.js && node test/onprem-injection.test.js && node test/onprem-layer.test.js && node test/copilot-instructions-path.test.js && node test/roomodes.test.js && node test/clinerules.test.js && node test/cline-workflow-reconciliation.test.js && node test/reconfigure.test.js && node test/experimental-warning.test.js && node test/bmad-persona-phase-prefix.test.js && node test/f1a-on-prem-prefix-post-install.test.js && node test/retired-skills-migration.test.js && node test/rename-migration.test.js && node test/uninstall.test.js && node test/offline-recompile.test.js && node test/plugin-manifests.test.js && node test/demerzel-ml-removal-drift.test.js && node test/demerzel-deprecation-notice.test.js && node test/build-plugin.test.js && node test/config-path-migration.test.js && node test/gitignore-plugin-stage.test.js && node test/customizations-translation.test.js && node test/routing-parity.test.js && node test/bmad-extension.test.js && node test/integration-verification.test.js && node test/agent-scope.test.js && node test/obsolete-tool-dirs.test.js && node test/bmad-empty-tools-guard.test.js && node test/v68-pluginresolver-enumeration-passthrough.test.js && node test/agents.test.js && node test/installer.test.js && node test/bmad.test.js && node test/cli.test.js && node test/knowledge-atlas-smoke.test.js && node test/knowledge-atlas-manifest.test.js && node test/knowledge-atlas-render.test.js && node test/knowledge-atlas-graph.test.js && node test/knowledge-atlas-packaging.test.js && node test/knowledge-atlas-offline-safety.test.js && node test/knowledge-atlas-reconcile.test.js && node test/knowledge-atlas-determinism.test.js && node test/knowledge-atlas-degradation.test.js && node test/knowledge-atlas-realproject.test.js && node test/knowledge-atlas-link-integrity.test.js && node test/knowledge-atlas-config-scan.test.js && node test/knowledge-atlas-references.test.js && node test/agent-injection-strategy.test.js && node test/bmad-module-selection.test.js && node test/custom-marketplace-surface-drift.test.js && node test/custom-marketplace-wizard.test.js && node test/custom-marketplace-live-clone.test.js && node test/custom-marketplace-stage-install.test.js && node test/custom-marketplace-persist-reconcile.test.js && node test/demerzel-nondestructive-invariant.test.js && node test/fr243-custom-source-allowlist-nonleak.test.js",
11
+ "test": "node test/yes-flag.test.js && node test/skill-authoring.test.js && node test/skill-validation.test.js && node test/skill-mandatory.test.js && node test/skill-customize-agent.test.js && node test/create-agent.test.js && node test/generate-project-context.test.js && node test/build-bmad-args.test.js && node test/build-bmad-cache-reader.test.js && node test/bundle-filter-set-equality.test.js && node test/bmad-version-bump.test.js && node test/staging-6-10-0-regression.test.js && node test/uv-transition-resilience.test.js && node test/passthrough-reconciliation-6-9-0.test.js && node test/extension-module-restructure.test.js && node test/convert-agents-to-skills.test.js && node test/migration.test.js && node test/migration-validation.test.js && node test/story-15-5-workflow-skills.test.js && node test/repo-layout.test.js && node test/config-storage.test.js && node test/cross-repo-validation.test.js && node test/config-lost-on-update.test.js && node test/portable-paths.test.js && node test/cicd-remote-mode.test.js && node test/config-layout.test.js && node test/roo-code-agent.test.js && node test/roo-code-injection.test.js && node test/profile.test.js && node test/instruction-block.test.js && node test/instruction-injection.test.js && node test/agents-md.test.js && node test/instruction-block-git.test.js && node test/onprem-injection.test.js && node test/onprem-layer.test.js && node test/copilot-instructions-path.test.js && node test/roomodes.test.js && node test/clinerules.test.js && node test/cline-workflow-reconciliation.test.js && node test/reconfigure.test.js && node test/experimental-warning.test.js && node test/bmad-persona-phase-prefix.test.js && node test/f1a-on-prem-prefix-post-install.test.js && node test/retired-skills-migration.test.js && node test/rename-migration.test.js && node test/uninstall.test.js && node test/offline-recompile.test.js && node test/plugin-manifests.test.js && node test/demerzel-ml-removal-drift.test.js && node test/demerzel-deprecation-notice.test.js && node test/build-plugin.test.js && node test/config-path-migration.test.js && node test/gitignore-plugin-stage.test.js && node test/customizations-translation.test.js && node test/routing-parity.test.js && node test/bmad-extension.test.js && node test/integration-verification.test.js && node test/agent-scope.test.js && node test/obsolete-tool-dirs.test.js && node test/bmad-empty-tools-guard.test.js && node test/v68-pluginresolver-enumeration-passthrough.test.js && node test/agents.test.js && node test/installer.test.js && node test/bmad.test.js && node test/cli.test.js && node test/knowledge-atlas-smoke.test.js && node test/knowledge-atlas-manifest.test.js && node test/knowledge-atlas-render.test.js && node test/knowledge-atlas-graph.test.js && node test/knowledge-atlas-packaging.test.js && node test/knowledge-atlas-offline-safety.test.js && node test/knowledge-atlas-reconcile.test.js && node test/knowledge-atlas-determinism.test.js && node test/knowledge-atlas-degradation.test.js && node test/knowledge-atlas-realproject.test.js && node test/knowledge-atlas-link-integrity.test.js && node test/knowledge-atlas-config-scan.test.js && node test/knowledge-atlas-references.test.js && node test/agent-injection-strategy.test.js && node test/bmad-module-selection.test.js && node test/custom-marketplace-surface-drift.test.js && node test/custom-marketplace-wizard.test.js && node test/custom-marketplace-live-clone.test.js && node test/custom-marketplace-stage-install.test.js && node test/custom-marketplace-persist-reconcile.test.js && node test/demerzel-nondestructive-invariant.test.js && node test/fr243-custom-source-allowlist-nonleak.test.js && node test/custom-marketplace-registry.test.js && node test/diag.test.js",
12
12
  "build:bmad-cache": "node scripts/build-bmad-cache.js",
13
13
  "build:plugin": "node scripts/build-plugin.js",
14
14
  "build:atlas": "node scripts/build-atlas.js",