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

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,7 @@ 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
43
  const { handleCreateSkill, handleValidateSkill, handleSetMandatory, handleCustomizeAgent, handleCreateAgent } = require('../lib/skill-authoring');
44
44
 
45
45
  const PKG = require('../package.json');
@@ -990,7 +990,7 @@ function resolveBaseBmadOk({ bmadInstalled, attempted, success }) {
990
990
  * @param {Object} ctx
991
991
  * @param {string} [ctx.bmadCustomSourceFlag] - `--bmad-custom-source <ref>` value
992
992
  * @param {boolean} [ctx.yesFlag] - non-interactive run
993
- * @returns {Promise<null|{ source: string, mode: 'discovery'|'direct', selectedCodes: string[],
993
+ * @returns {Promise<null|{ source: string, mode: 'discovery'|'direct'|'registry', selectedCodes: string[],
994
994
  * plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
995
995
  */
996
996
  async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = false } = {}) {
@@ -1056,15 +1056,31 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
1056
1056
  return { source, mode, selectedCodes, plugins, rootDir, sourceUrl };
1057
1057
  }
1058
1058
 
1059
+ // Registry mode (this story): show display_name + trust tier + category and
1060
+ // make the community/unverified trust tier explicit PER ENTRY (AC2, NFR71).
1061
+ // Registry entries are opt-in — nothing is pre-selected (AC1/AC2). Discovery/
1062
+ // direct keep their existing "all pre-selected" behavior unchanged (AC6).
1063
+ const isRegistry = mode === 'registry';
1059
1064
  const { chosenCodes } = await prompts({
1060
1065
  type: 'multiselect',
1061
1066
  name: 'chosenCodes',
1062
1067
  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
- })),
1068
+ choices: plugins.map((p) => {
1069
+ if (isRegistry) {
1070
+ const label = registryTrustLabel(p);
1071
+ const certified = p.trustTier === 'bmad-certified';
1072
+ const head = chalk.white(p.displayName || p.name || p.code);
1073
+ const tier = (certified ? chalk.green : chalk.yellow)(`[${label}]`);
1074
+ const cat = p.category ? chalk.gray(` {${p.category}${p.subcategory ? `/${p.subcategory}` : ''}}`) : '';
1075
+ const desc = p.description ? chalk.gray(` — ${p.description}`) : '';
1076
+ return { title: `${head} ${tier}${cat}${desc}`, value: p.code, selected: false };
1077
+ }
1078
+ return {
1079
+ title: chalk.white(p.code) + chalk.gray(p.description ? ` — ${p.description}` : ''),
1080
+ value: p.code,
1081
+ selected: true,
1082
+ };
1083
+ }),
1068
1084
  instructions: chalk.gray(' Use space to toggle, enter to confirm.'),
1069
1085
  });
1070
1086
 
@@ -1093,7 +1109,7 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
1093
1109
  *
1094
1110
  * @param {Object} ctx
1095
1111
  * @param {string} ctx.projectRoot
1096
- * @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct',
1112
+ * @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct'|'registry',
1097
1113
  * selectedCodes: string[], plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
1098
1114
  * `undefined` — nothing persisted; caller should fall back to `promptCustomMarketplace()`.
1099
1115
  * `null` — persisted source exists but could not be re-enumerated (fail-loud; skip this run).
@@ -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.1",
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,33 @@
39
39
 
40
40
  const path = require('node:path');
41
41
  const fs = require('fs-extra');
42
+ const yaml = require('js-yaml');
42
43
 
43
44
  /** Deep, pinned-internal require path (NFR17). Single call site. */
44
45
  const CUSTOM_MODULE_MANAGER_PATH = 'bmad-method/tools/installer/modules/custom-module-manager.js';
45
46
 
47
+ /**
48
+ * Registry-index mode (this story) — the BMAD registry layout used by the
49
+ * official plugin registry (`bmad-code-org/bmad-plugins-marketplace`) and any
50
+ * source that follows the same convention. A `registry/` directory holding
51
+ * `official.yaml` and/or `community-index.yaml` (each a top-level `modules:`
52
+ * list) is a THIRD source shape, distinct from `discovery`
53
+ * (`.claude-plugin/marketplace.json`) and `direct` (whole repo = one module).
54
+ * Detection precedence is: discovery > registry > direct (see
55
+ * `enumerateCustomSource`).
56
+ */
57
+ const REGISTRY_DIR_NAME = 'registry';
58
+ const REGISTRY_OFFICIAL_INDEX = 'official.yaml';
59
+ const REGISTRY_COMMUNITY_INDEX = 'community-index.yaml';
60
+ const REGISTRY_SCHEMA_FILE = 'registry-schema.yaml';
61
+
62
+ /** Convert an OS-native relative path to a POSIX ('/'-separated) one for the
63
+ * staged marketplace.json (bmad-method's parseSource/skills detection is
64
+ * POSIX-oriented, and JSON marketplaces are always written POSIX-style). */
65
+ function toPosix(relPath) {
66
+ return relPath.split(path.sep).join('/');
67
+ }
68
+
46
69
  /**
47
70
  * The `CustomModuleManager` methods the custom-marketplace feature depends on.
48
71
  * Any drift in this set (renamed/removed on a bmad-method pin bump) must fail
@@ -63,6 +86,23 @@ const NFR71_WARNING =
63
86
  'Custom marketplace content is outside ma-agents\' sealed-bundle guarantee — ' +
64
87
  'it is installed as-is from the source you provided and is not vetted by ma-agents.';
65
88
 
89
+ /**
90
+ * NFR71 (registry mode) — a per-entry trust annotation the wizard appends to
91
+ * each registry choice so the community/unverified trust tier is explicit at
92
+ * the point of selection (not just in the one-time source-level warning). A
93
+ * `bmad-certified` (official) entry is labelled as such; anything else is made
94
+ * explicitly "unverified/community — installed as-is, not vetted".
95
+ *
96
+ * @param {{ trustTier?: string, type?: string }} plugin - a normalized registry entry
97
+ * @returns {string} human-readable trust label
98
+ */
99
+ function registryTrustLabel(plugin) {
100
+ if (plugin && plugin.trustTier === 'bmad-certified') {
101
+ return 'bmad-certified';
102
+ }
103
+ return 'unverified/community — installed as-is, not vetted by ma-agents';
104
+ }
105
+
66
106
  /**
67
107
  * Error type raised by this adapter for any enumeration failure (unreachable
68
108
  * source, invalid input, drifted surface). Callers should catch this and
@@ -150,6 +190,150 @@ function assertCustomModuleManagerSurface() {
150
190
  return { version, mgr };
151
191
  }
152
192
 
193
+ // ─── Registry-index mode (this story) ──────────────────────────────────────
194
+
195
+ /**
196
+ * Detect the BMAD registry-index layout inside an already-resolved source root.
197
+ * A source is treated as a registry when its `registry/` directory holds an
198
+ * `official.yaml` and/or a `community-index.yaml` module index (or, failing
199
+ * those, a `registry-schema.yaml`, which still marks the source as a registry
200
+ * even if it currently declares no modules). Called ONLY after discovery
201
+ * (`.claude-plugin/marketplace.json`) has been ruled out, so discovery always
202
+ * wins (AC1 precedence).
203
+ *
204
+ * @param {string} rootDir - Absolute resolved source root.
205
+ * @returns {Promise<{ isRegistry: boolean, officialPath: string|null, communityPath: string|null }>}
206
+ */
207
+ async function detectRegistryLayout(rootDir) {
208
+ const registryDir = path.join(rootDir, REGISTRY_DIR_NAME);
209
+ if (!(await fs.pathExists(registryDir))) {
210
+ return { isRegistry: false, officialPath: null, communityPath: null };
211
+ }
212
+ const officialAbs = path.join(registryDir, REGISTRY_OFFICIAL_INDEX);
213
+ const communityAbs = path.join(registryDir, REGISTRY_COMMUNITY_INDEX);
214
+ const schemaAbs = path.join(registryDir, REGISTRY_SCHEMA_FILE);
215
+ const [hasOfficial, hasCommunity, hasSchema] = await Promise.all([
216
+ fs.pathExists(officialAbs),
217
+ fs.pathExists(communityAbs),
218
+ fs.pathExists(schemaAbs),
219
+ ]);
220
+ if (!hasOfficial && !hasCommunity && !hasSchema) {
221
+ return { isRegistry: false, officialPath: null, communityPath: null };
222
+ }
223
+ return {
224
+ isRegistry: true,
225
+ officialPath: hasOfficial ? officialAbs : null,
226
+ communityPath: hasCommunity ? communityAbs : null,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Normalize one raw registry-index entry (conforming to
232
+ * `registry/registry-schema.yaml`) into the SAME plugin-choice object shape the
233
+ * wizard's multiselect (30.6a/6b) already consumes, plus the registry-only
234
+ * pointer fields the two-level install needs (`repository`, `moduleDefinition`,
235
+ * `approvedSha`, `approvedTag`, `version`, `trustTier`).
236
+ *
237
+ * @param {Object} entry - raw YAML module entry
238
+ * @param {{ official: boolean }} ctx - which index the entry came from
239
+ * @returns {Object} normalized plugin-choice object
240
+ */
241
+ function normalizeRegistryEntry(entry, { official }) {
242
+ const trustTier = entry.trust_tier || (official ? 'bmad-certified' : 'unverified');
243
+ const type = entry.type || (official ? 'bmad-org' : 'community');
244
+ return {
245
+ code: entry.code || entry.name,
246
+ name: entry.name,
247
+ displayName: entry.display_name || entry.name,
248
+ description: entry.description || '',
249
+ type,
250
+ trustTier,
251
+ category: entry.category || null,
252
+ subcategory: entry.subcategory || null,
253
+ // Registry-only pointer fields (two-level install).
254
+ repository: entry.repository || null,
255
+ moduleDefinition: entry.module_definition || null,
256
+ approvedSha: entry.approved_sha || null,
257
+ approvedTag: entry.approved_tag || null,
258
+ version: entry.version || null,
259
+ builtIn: entry.built_in === true,
260
+ isExternal: true,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Parse one registry index file (`official.yaml` / `community-index.yaml`) into
266
+ * a normalized entry list. A missing/empty `modules:` key yields `[]`; a YAML
267
+ * syntax error or a non-list `modules:` throws a clean `CustomMarketplaceError`
268
+ * (AC4 malformed-index fail-clean). Individual entries that lack a usable
269
+ * `name` or `repository` pointer are SKIPPED (they are not installable and must
270
+ * not sink an otherwise-valid registry).
271
+ *
272
+ * @param {string} indexPath - absolute path to the index file
273
+ * @param {{ official: boolean }} ctx
274
+ * @returns {Promise<Array<Object>>} normalized entries
275
+ * @throws {CustomMarketplaceError}
276
+ */
277
+ async function parseRegistryIndex(indexPath, { official }) {
278
+ const label = path.basename(indexPath);
279
+ let raw;
280
+ try {
281
+ raw = await fs.readFile(indexPath, 'utf8');
282
+ } catch (err) {
283
+ throw new CustomMarketplaceError(`Could not read registry index "${label}": ${err.message}`, { cause: err });
284
+ }
285
+ let doc;
286
+ try {
287
+ doc = yaml.load(raw);
288
+ } catch (err) {
289
+ throw new CustomMarketplaceError(
290
+ `Registry index "${label}" is not valid YAML: ${err.message}`,
291
+ { cause: err }
292
+ );
293
+ }
294
+ if (doc === null || doc === undefined) return [];
295
+ if (typeof doc !== 'object' || Array.isArray(doc)) {
296
+ throw new CustomMarketplaceError(`Registry index "${label}" is not a mapping with a "modules" list.`);
297
+ }
298
+ const modules = doc.modules;
299
+ if (modules === undefined || modules === null) return [];
300
+ if (!Array.isArray(modules)) {
301
+ throw new CustomMarketplaceError(`Registry index "${label}" has a "modules" key that is not a list.`);
302
+ }
303
+ const out = [];
304
+ for (const entry of modules) {
305
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
306
+ if (typeof entry.name !== 'string' || !entry.name.trim()) continue; // not addressable — skip
307
+ if (typeof entry.repository !== 'string' || !entry.repository.trim()) continue; // no pointer — not installable
308
+ out.push(normalizeRegistryEntry(entry, { official }));
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /**
314
+ * Build the `registry`-mode enumeration result: parse the official + community
315
+ * indexes, merge, and de-dupe by `name` (official — bmad-certified — wins over
316
+ * a same-named community entry).
317
+ *
318
+ * @param {string} rootDir
319
+ * @param {string|null} sourceUrl
320
+ * @param {{ officialPath: string|null, communityPath: string|null }} layout
321
+ * @returns {Promise<{ mode: 'registry', plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
322
+ */
323
+ async function enumerateRegistrySource(rootDir, sourceUrl, layout) {
324
+ const official = layout.officialPath ? await parseRegistryIndex(layout.officialPath, { official: true }) : [];
325
+ const community = layout.communityPath ? await parseRegistryIndex(layout.communityPath, { official: false }) : [];
326
+
327
+ // Official entries are appended first so they win the de-dupe on a name
328
+ // collision (a bmad-certified module must never be shadowed by an unverified
329
+ // community entry of the same name).
330
+ const byName = new Map();
331
+ for (const p of [...official, ...community]) {
332
+ if (!byName.has(p.name)) byName.set(p.name, p);
333
+ }
334
+ return { mode: 'registry', plugins: [...byName.values()], rootDir, sourceUrl };
335
+ }
336
+
153
337
  /**
154
338
  * Enumerate the plugins declared by a custom marketplace source.
155
339
  *
@@ -204,6 +388,31 @@ async function enumerateCustomSource(source) {
204
388
  return { mode: 'discovery', plugins, rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
205
389
  }
206
390
 
391
+ // AC1 precedence: discovery (above) wins; else a `registry/` layout →
392
+ // `registry` mode; else `direct` (below). The registry index is a
393
+ // higher-level pointer format (each entry names a separate module repo),
394
+ // so it is enumerated distinctly from a single-repo direct source.
395
+ let registryLayout;
396
+ try {
397
+ registryLayout = await detectRegistryLayout(resolved.rootDir);
398
+ } catch (err) {
399
+ throw new CustomMarketplaceError(
400
+ `Could not inspect "${source}" for a registry layout: ${err.message}`,
401
+ { cause: err }
402
+ );
403
+ }
404
+ if (registryLayout.isRegistry) {
405
+ try {
406
+ return await enumerateRegistrySource(resolved.rootDir, resolved.sourceUrl, registryLayout);
407
+ } catch (err) {
408
+ if (err instanceof CustomMarketplaceError) throw err;
409
+ throw new CustomMarketplaceError(
410
+ `Could not read the registry index from "${source}": ${err.message}`,
411
+ { cause: err }
412
+ );
413
+ }
414
+ }
415
+
207
416
  // FR240 — "direct" mode (no marketplace.json at the source root): treat the
208
417
  // whole source as a single module so the wizard's multiselect still has
209
418
  // exactly one, well-described choice. 30.6b decides how a direct-mode
@@ -244,17 +453,17 @@ const CUSTOM_MARKETPLACE_STAGE_DIR_NAME = '.ma-agents-custom-marketplace-stage';
244
453
  * @param {string} rootDir - Absolute path to the direct-mode source root.
245
454
  * @returns {Promise<string[]>} Directory names (not paths) that contain a SKILL.md.
246
455
  */
247
- async function scanDirectModeSkillDirs(rootDir) {
456
+ async function scanSkillDirs(baseDir) {
248
457
  let entries;
249
458
  try {
250
- entries = await fs.readdir(rootDir, { withFileTypes: true });
459
+ entries = await fs.readdir(baseDir, { withFileTypes: true });
251
460
  } catch {
252
461
  return [];
253
462
  }
254
463
  const skillDirs = [];
255
464
  for (const entry of entries) {
256
465
  if (!entry.isDirectory()) continue;
257
- const skillMd = path.join(rootDir, entry.name, 'SKILL.md');
466
+ const skillMd = path.join(baseDir, entry.name, 'SKILL.md');
258
467
  if (await fs.pathExists(skillMd)) {
259
468
  skillDirs.push(entry.name);
260
469
  }
@@ -262,6 +471,12 @@ async function scanDirectModeSkillDirs(rootDir) {
262
471
  return skillDirs;
263
472
  }
264
473
 
474
+ /** Story 30.6b direct-mode scan (thin alias of {@link scanSkillDirs} at the
475
+ * source root) — kept as a named export for the 30.6b surface. */
476
+ async function scanDirectModeSkillDirs(rootDir) {
477
+ return scanSkillDirs(rootDir);
478
+ }
479
+
265
480
  /**
266
481
  * Resolve every selected plugin's installable skill/module layout via
267
482
  * bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
@@ -336,14 +551,25 @@ async function resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selec
336
551
  * @returns {Array<Object>} The flattened list of resolved modules (all plugins).
337
552
  * @throws {CustomMarketplaceError} on any violation above.
338
553
  */
339
- function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '' } = {}) {
554
+ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '', mode = null } = {}) {
340
555
  const allModules = resolvedByPlugin.flatMap((r) => r.resolved || []);
341
556
 
342
557
  if (allModules.length === 0) {
558
+ // AC7 — a direct-mode source that resolves to nothing is the exact
559
+ // failure a mis-pointed registry URL used to produce. Name the likely
560
+ // cause and note that registry indexes are now supported (so re-running
561
+ // will auto-detect them), instead of a bare "zero modules" abort.
562
+ const directHint =
563
+ mode === 'direct'
564
+ ? ' The source has no `.claude-plugin/marketplace.json` and no `module.yaml`/skills ' +
565
+ 'directories at its root. If it is a BMAD registry index (a `registry/` directory ' +
566
+ 'with `official.yaml`/`community-index.yaml`), that format is now supported — re-run ' +
567
+ 'and it will be detected as a `registry` source.'
568
+ : '';
343
569
  throw new CustomMarketplaceError(
344
570
  `Custom marketplace selection from "${source}" resolved to zero installable modules ` +
345
571
  `— bmad-method's PluginResolver could not locate module.yaml/skills for any selected ` +
346
- `plugin. Nothing would be installed; aborting before staging (FR238).`
572
+ `plugin. Nothing would be installed; aborting before staging (FR238).${directHint}`
347
573
  );
348
574
  }
349
575
 
@@ -392,17 +618,19 @@ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = ''
392
618
  * @throws {CustomMarketplaceError} on a drifted surface, an unmatched selection, or a failed
393
619
  * FR238 pre-flight check (see assertResolutionNonEmptyAndUniqueLeaves).
394
620
  */
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
-
621
+ /**
622
+ * Prepare a clean, empty stage directory under `projectRoot`. Safety pattern
623
+ * mirrors lib/bmad.js#stagePlugin (Story 22.6/22.8): never follow a pre-existing
624
+ * stage path that is a symlink, and wipe any stale stage from a prior failed run
625
+ * before copying fresh content. Shared by both the discovery/direct
626
+ * (`stageCustomMarketplaceSubset`) and registry (`stageRegistrySubset`) paths.
627
+ *
628
+ * @param {string} projectRoot - already-resolved absolute project root
629
+ * @returns {string} absolute stage path (created empty)
630
+ * @throws {CustomMarketplaceError}
631
+ */
632
+ function prepareCleanStageDir(projectRoot) {
401
633
  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
634
  if (fs.existsSync(stagePath)) {
407
635
  const stageLstat = fs.lstatSync(stagePath);
408
636
  if (stageLstat.isSymbolicLink()) {
@@ -418,6 +646,16 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
418
646
  }
419
647
  }
420
648
  fs.mkdirpSync(stagePath);
649
+ return stagePath;
650
+ }
651
+
652
+ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl, plugins, selectedCodes, projectRoot }) {
653
+ projectRoot = path.resolve(projectRoot);
654
+
655
+ const resolvedByPlugin = await resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selectedCodes });
656
+ const allModules = assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source, mode });
657
+
658
+ const stagePath = prepareCleanStageDir(projectRoot);
421
659
 
422
660
  // Copy every resolved file, preserving its path relative to rootDir, so
423
661
  // bmad-method's own re-resolution against the stage (a fresh 'local' type
@@ -459,6 +697,222 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
459
697
  return { stagePath, customSourceArg, resolvedModules: allModules };
460
698
  }
461
699
 
700
+ // ─── Registry-index mode — two-level stage + install ───────────────────────
701
+
702
+ /**
703
+ * Resolve one selected registry entry into a staged rawPlugin + resolved
704
+ * modules. Two-level: clone the entry's `repository` (via bmad-method's own
705
+ * `resolveSource`/`cloneRepo` — ambient git creds, GIT_TERMINAL_PROMPT=0, no
706
+ * token handling — FR241), locate the module at `module_definition` (fallback:
707
+ * the repo root, i.e. PluginResolver over the whole clone), scan for skill
708
+ * directories, and resolve via `resolvePlugin`.
709
+ *
710
+ * A community entry is cloned at its `approved_tag` (the tag the registry
711
+ * publishes for the approved release — it points at `approved_sha`). We pin to
712
+ * the TAG rather than the raw SHA because bmad-method's `cloneRepo()` drives
713
+ * `git clone --branch <ref>` / `git fetch origin <ref>`, which accept a tag or
714
+ * branch but NOT a bare commit SHA. The `approved_sha` is still recorded on the
715
+ * persisted selection (see lib/bmad.js#installCustomMarketplaceSubset) as the
716
+ * immutable record of what the registry approved.
717
+ *
718
+ * @param {Object} plugin - a normalized registry entry (from enumerateCustomSource)
719
+ * @param {(input: string, opts: Object) => Promise<Object>} resolveSourceFn
720
+ * @param {(repoPath: string, rawPlugin: Object, url: string|null, local: string|null) => Promise<Array>} resolvePluginFn
721
+ * @returns {Promise<{ plugin: Object, rawPlugin: Object, resolved: Array<Object>, entryRootDir: string }>}
722
+ * @throws {CustomMarketplaceError}
723
+ */
724
+ async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
725
+ if (!plugin.repository) {
726
+ throw new CustomMarketplaceError(
727
+ `Registry entry '${plugin.code}' has no repository pointer — cannot install it.`
728
+ );
729
+ }
730
+
731
+ // Pin ref (tag/branch) via the `@<ref>` SOURCE-STRING SUFFIX that
732
+ // bmad-method's resolveSource() parses (custom-module-manager.js:
733
+ // "Extract optional @<tag-or-branch> suffix"). resolveSource has NO
734
+ // pinOverride/option for this — the ref MUST ride on the input string. A raw
735
+ // commit SHA is unsupported (`git clone --branch` can't take one), so we pin
736
+ // to `approved_tag` (the registry publishes it pointing at `approved_sha`);
737
+ // `approved_sha` is still recorded verbatim on the persisted selection.
738
+ const pinRef = plugin.approvedTag || null;
739
+ // The ref must match resolveSource's own ref rule (/^[\w.\-+/]+$/, no ':')
740
+ // or we omit it and clone the default branch rather than build a malformed
741
+ // source string.
742
+ const safePinRef = pinRef && /^[\w.\-+/]+$/.test(pinRef) && !pinRef.includes(':') ? pinRef : null;
743
+ const cloneInput = safePinRef ? `${plugin.repository}@${safePinRef}` : plugin.repository;
744
+
745
+ let resolvedSource;
746
+ try {
747
+ resolvedSource = await resolveSourceFn(cloneInput, {
748
+ skipInstall: true,
749
+ silent: true,
750
+ });
751
+ } catch (err) {
752
+ throw new CustomMarketplaceError(
753
+ `Could not clone registry entry '${plugin.code}' from "${plugin.repository}"` +
754
+ `${safePinRef ? ` @ ${safePinRef}` : ''}: ${err.message}`,
755
+ { cause: err }
756
+ );
757
+ }
758
+
759
+ const entryRootDir = resolvedSource.rootDir;
760
+ const entrySourceUrl = resolvedSource.sourceUrl || null;
761
+
762
+ // Locate the module within the clone. `module_definition` is a POSIX,
763
+ // repo-relative path to the module's module.yaml; its directory is the
764
+ // module root. Absent → fall back to the repo root (PluginResolver over the
765
+ // whole clone), matching the AC3 fallback.
766
+ let relModuleDirPosix = '.';
767
+ if (plugin.moduleDefinition) {
768
+ const defPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
769
+ const moduleYamlAbs = path.join(entryRootDir, ...defPosix.split('/'));
770
+ if (!(await fs.pathExists(moduleYamlAbs))) {
771
+ throw new CustomMarketplaceError(
772
+ `Registry entry '${plugin.code}': module_definition "${plugin.moduleDefinition}" ` +
773
+ `was not found inside ${plugin.repository}. The registry pointer is stale or the ` +
774
+ `repository layout changed; aborting before staging (FR238).`
775
+ );
776
+ }
777
+ relModuleDirPosix = path.posix.dirname(defPosix);
778
+ }
779
+
780
+ const moduleBaseAbs =
781
+ relModuleDirPosix === '.' ? entryRootDir : path.join(entryRootDir, ...relModuleDirPosix.split('/'));
782
+ const skillNames = await scanSkillDirs(moduleBaseAbs);
783
+ const skillRelPaths = skillNames.map((n) => (relModuleDirPosix === '.' ? n : `${relModuleDirPosix}/${n}`));
784
+
785
+ const rawPlugin = {
786
+ name: plugin.code,
787
+ description: plugin.description || '',
788
+ version: plugin.version || null,
789
+ source: relModuleDirPosix,
790
+ // Empty when the module dir has no SKILL.md subdirs — resolvePlugin then
791
+ // returns [] and the FR238 guard aborts cleanly (zero installable modules).
792
+ skills: skillRelPaths,
793
+ };
794
+
795
+ const localPath = entrySourceUrl ? null : entryRootDir;
796
+ const resolved = await resolvePluginFn(entryRootDir, rawPlugin, entrySourceUrl, localPath);
797
+
798
+ return { plugin, rawPlugin, resolved, entryRootDir };
799
+ }
800
+
801
+ /**
802
+ * Registry-mode counterpart of {@link stageCustomMarketplaceSubset}. For each
803
+ * selected registry entry it clones the entry's `repository`, resolves the
804
+ * pointed module, and stages the resolved content into the SAME project-local
805
+ * stage directory the discovery/direct path uses — but namespaced under the
806
+ * entry's `code/` so modules pulled from DIFFERENT repositories can never
807
+ * collide on a shared relative path. Enforces the same FR238 non-empty +
808
+ * unique-leaf guard, then returns the same `{ stagePath, customSourceArg,
809
+ * resolvedModules }` shape so lib/bmad.js#installCustomMarketplaceSubset installs
810
+ * it identically via `buildBmadArgs` + `--custom-source`.
811
+ *
812
+ * @param {Object} ctx
813
+ * @param {string} ctx.source - Original registry source string (messages only).
814
+ * @param {Array<Object>} ctx.plugins - Full enumerated registry entry list.
815
+ * @param {string[]} ctx.selectedCodes
816
+ * @param {string} ctx.projectRoot
817
+ * @param {Object} [ctx.deps] - Injectable seams (tests): { resolveSource, resolvePlugin }.
818
+ * @returns {Promise<{ stagePath: string, customSourceArg: string, resolvedModules: Array<Object> }>}
819
+ * @throws {CustomMarketplaceError}
820
+ */
821
+ async function stageRegistrySubset({ source, plugins, selectedCodes, projectRoot, deps = {} }) {
822
+ projectRoot = path.resolve(projectRoot);
823
+ const { mgr } = assertCustomModuleManagerSurface();
824
+ const resolveSourceFn = deps.resolveSource || ((input, opts) => mgr.resolveSource(input, opts));
825
+ const resolvePluginFn =
826
+ deps.resolvePlugin || ((repoPath, rawPlugin, url, local) => mgr.resolvePlugin(repoPath, rawPlugin, url, local));
827
+
828
+ const selectedSet = new Set(selectedCodes);
829
+ const selected = (plugins || []).filter((p) => selectedSet.has(p.code));
830
+ if (selected.length === 0) {
831
+ throw new CustomMarketplaceError(
832
+ `None of the selected codes (${[...selectedSet].join(', ') || '(none)'}) matched a ` +
833
+ `registry entry — nothing to stage.`
834
+ );
835
+ }
836
+
837
+ // Each entry is staged under its own `code/` namespace, so two selected
838
+ // entries sharing a code would silently overwrite each other. enumerate
839
+ // de-dupes by `name`, but a malformed registry could give two differently-
840
+ // named entries the same `code` — reject that here rather than stage a
841
+ // corrupt subset (FR238 fail-clean).
842
+ const seenCodes = new Set();
843
+ for (const p of selected) {
844
+ if (seenCodes.has(p.code)) {
845
+ throw new CustomMarketplaceError(
846
+ `Two selected registry entries share the code '${p.code}' — codes must be unique ` +
847
+ `to install. Aborting before staging (FR238).`
848
+ );
849
+ }
850
+ seenCodes.add(p.code);
851
+ }
852
+
853
+ // Resolve every selected entry (each from its own clone) BEFORE touching the
854
+ // stage dir, so an unreachable/stale entry aborts clean with nothing staged.
855
+ const entries = [];
856
+ for (const plugin of selected) {
857
+ entries.push(await resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn));
858
+ }
859
+
860
+ const allModules = assertResolutionNonEmptyAndUniqueLeaves(
861
+ entries.map(({ plugin, rawPlugin, resolved }) => ({ plugin, rawPlugin, resolved })),
862
+ { source, mode: 'registry' }
863
+ );
864
+
865
+ const stagePath = prepareCleanStageDir(projectRoot);
866
+
867
+ // Copy each entry's resolved content under a `code/` namespace so entries
868
+ // cloned from different repos never overwrite each other, and rewrite the
869
+ // staged rawPlugin's skill/source paths to match that namespace so
870
+ // bmad-method's re-resolution against the stage reproduces the identical
871
+ // module (skills at code/<rel>, module.yaml at their common parent).
872
+ const stagedPlugins = [];
873
+ try {
874
+ for (const { plugin, resolved, entryRootDir } of entries) {
875
+ const prefix = plugin.code;
876
+ const relFromEntry = (absSrc) => toPosix(path.relative(entryRootDir, absSrc));
877
+ const copyUnderPrefix = (absSrc) => {
878
+ const dest = path.join(stagePath, prefix, ...relFromEntry(absSrc).split('/'));
879
+ fs.copySync(absSrc, dest, { overwrite: true, dereference: true });
880
+ };
881
+
882
+ const stagedSkills = [];
883
+ for (const mod of resolved) {
884
+ for (const skillPath of mod.skillPaths) {
885
+ copyUnderPrefix(skillPath);
886
+ stagedSkills.push(`${prefix}/${relFromEntry(skillPath)}`);
887
+ }
888
+ if (mod.moduleYamlPath) copyUnderPrefix(mod.moduleYamlPath);
889
+ if (mod.moduleHelpCsvPath) copyUnderPrefix(mod.moduleHelpCsvPath);
890
+ }
891
+
892
+ stagedPlugins.push({
893
+ name: plugin.code,
894
+ description: plugin.description || '',
895
+ version: plugin.version || null,
896
+ source: prefix,
897
+ skills: stagedSkills,
898
+ });
899
+ }
900
+ } catch (err) {
901
+ try { fs.removeSync(stagePath); } catch { /* swallow — primary error wins */ }
902
+ throw new CustomMarketplaceError(`Staging the registry subset failed: ${err.message}`, { cause: err });
903
+ }
904
+
905
+ const marketplaceDir = path.join(stagePath, '.claude-plugin');
906
+ fs.mkdirpSync(marketplaceDir);
907
+ fs.writeFileSync(
908
+ path.join(marketplaceDir, 'marketplace.json'),
909
+ JSON.stringify({ name: 'ma-agents-custom-registry-subset', plugins: stagedPlugins }, null, 2)
910
+ );
911
+
912
+ const customSourceArg = `./${CUSTOM_MARKETPLACE_STAGE_DIR_NAME}`;
913
+ return { stagePath, customSourceArg, resolvedModules: allModules };
914
+ }
915
+
462
916
  // ─── Story 30.7 — persist + re-enumerate/reconcile on update ───────────────
463
917
 
464
918
  /**
@@ -499,9 +953,21 @@ module.exports = {
499
953
  // Story 30.6b — prune + stage the selected subset
500
954
  CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
501
955
  scanDirectModeSkillDirs,
956
+ scanSkillDirs,
502
957
  resolveSelectedPlugins,
503
958
  assertResolutionNonEmptyAndUniqueLeaves,
504
959
  stageCustomMarketplaceSubset,
960
+ // Registry-index mode (this story)
961
+ REGISTRY_DIR_NAME,
962
+ REGISTRY_OFFICIAL_INDEX,
963
+ REGISTRY_COMMUNITY_INDEX,
964
+ registryTrustLabel,
965
+ detectRegistryLayout,
966
+ parseRegistryIndex,
967
+ normalizeRegistryEntry,
968
+ enumerateRegistrySource,
969
+ resolveRegistryEntry,
970
+ stageRegistrySubset,
505
971
  // Story 30.7 — persist + reconcile on update
506
972
  reconcileCustomMarketplaceSelection,
507
973
  };
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.1",
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",
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",