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

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.
@@ -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.2",
40
+ "version": "3.17.0-beta.4",
41
41
  "author": {
42
42
  "name": "Alon Mayaffit"
43
43
  },
package/lib/bmad.js CHANGED
@@ -1719,12 +1719,19 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1719
1719
  let stagePath;
1720
1720
  let customSourceArg;
1721
1721
  let resolvedModules;
1722
+ // Registry mode is best-effort across entries: `installedCodes` is the subset
1723
+ // that actually resolved, `skipped` names the ones that did not (with reasons).
1724
+ // discovery/direct are atomic → installedCodes = the selection, no skips.
1725
+ let installedCodes = selection.selectedCodes;
1726
+ let skipped = [];
1722
1727
  try {
1723
1728
  if (selection.mode === 'registry') {
1724
1729
  // Registry-index mode (this story): two-level clone+resolve+stage of
1725
1730
  // each selected entry's own repository. Shares the same stage dir +
1726
1731
  // install path as discovery/direct; only the staging step differs.
1727
- ({ stagePath, customSourceArg, resolvedModules } = await stageRegistryFn({
1732
+ // Best-effort: a stale/broken entry is skipped (loudly), not fatal —
1733
+ // it does not block the entries that do resolve.
1734
+ ({ stagePath, customSourceArg, resolvedModules, installedCodes, skipped } = await stageRegistryFn({
1728
1735
  source: selection.source,
1729
1736
  plugins: selection.plugins,
1730
1737
  selectedCodes: selection.selectedCodes,
@@ -1747,6 +1754,11 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1747
1754
  return false;
1748
1755
  }
1749
1756
 
1757
+ // Defensive: a stager that predates the best-effort contract (or a partial
1758
+ // mock) may omit these — fall back to treating the whole selection as installed.
1759
+ if (!Array.isArray(installedCodes)) installedCodes = selection.selectedCodes;
1760
+ if (!Array.isArray(skipped)) skipped = [];
1761
+
1750
1762
  const command = buildBmadArgs({
1751
1763
  projectRoot,
1752
1764
  modules,
@@ -1755,7 +1767,7 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1755
1767
  customSource: customSourceArg,
1756
1768
  });
1757
1769
 
1758
- console.log(chalk.cyan(` Installing custom marketplace subset (${selection.selectedCodes.join(', ')})...`));
1770
+ console.log(chalk.cyan(` Installing custom marketplace subset (${(installedCodes || []).join(', ')})...`));
1759
1771
  console.log(chalk.gray(` Running: ${command}`));
1760
1772
 
1761
1773
  try {
@@ -1768,6 +1780,27 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1768
1780
  // for diagnostics on any post-invoke failure" policy (AC 8, 30.6b).
1769
1781
  cleanupStageFn(stagePath);
1770
1782
 
1783
+ // LOUD best-effort report: any selected registry entry that could not be
1784
+ // installed (unreachable repo, stale approved_tag, no skills) is named
1785
+ // here with its reason — never silently omitted (FR238).
1786
+ if (skipped && skipped.length > 0) {
1787
+ console.log(
1788
+ chalk.yellow(
1789
+ `\n ${skipped.length} selected extension${skipped.length === 1 ? ' was' : 's were'} NOT installed ` +
1790
+ `(the rest installed fine):`
1791
+ )
1792
+ );
1793
+ for (const s of skipped) {
1794
+ console.log(chalk.yellow(` - ${s.code}: ${s.reason}`));
1795
+ }
1796
+ console.log(
1797
+ chalk.gray(
1798
+ ' These are typically stale/broken registry entries (e.g. an approved_tag that no longer ' +
1799
+ 'exists upstream). Report them to the marketplace maintainer, or re-run without them.'
1800
+ )
1801
+ );
1802
+ }
1803
+
1771
1804
  // Story 30.7 (FR242) — persist source + selection (+ SHA pin) now that
1772
1805
  // the subset is actually installed. `resolvedModules[].cloneSha` is
1773
1806
  // only stamped for Git-URL sources (see custom-module-manager.js
@@ -1776,19 +1809,22 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
1776
1809
  // failure is logged as a warning only — it must never turn an
1777
1810
  // otherwise-successful install into a reported failure.
1778
1811
  try {
1812
+ // Persist only what ACTUALLY installed (`installedCodes`), not the
1813
+ // raw selection — otherwise a skipped/broken entry would be recorded
1814
+ // as installed and re-attempted (and re-reported) on every update.
1779
1815
  const persistEntry = {
1780
1816
  source: selection.source,
1781
1817
  sourceUrl: selection.sourceUrl ?? null,
1782
1818
  mode: selection.mode,
1783
- selectedCodes: selection.selectedCodes,
1819
+ selectedCodes: installedCodes,
1784
1820
  };
1785
1821
  if (selection.mode === 'registry') {
1786
- // AC5 — record each selected entry's registry-provided
1822
+ // AC5 — record each INSTALLED entry's registry-provided
1787
1823
  // `approved_sha` (the immutable pin the registry declares), plus
1788
1824
  // its `approved_tag`. `sha` (the top-level field) mirrors the
1789
1825
  // first available approved_sha for back-compat with readers that
1790
1826
  // only look at `sha`.
1791
- const selectedSet = new Set(selection.selectedCodes);
1827
+ const selectedSet = new Set(installedCodes);
1792
1828
  const registryEntries = (selection.plugins || [])
1793
1829
  .filter((p) => selectedSet.has(p.code))
1794
1830
  .map((p) => ({ code: p.code, approvedSha: p.approvedSha ?? null, approvedTag: p.approvedTag ?? null }));
@@ -478,6 +478,64 @@ async function scanDirectModeSkillDirs(rootDir) {
478
478
  return scanSkillDirs(rootDir);
479
479
  }
480
480
 
481
+ /**
482
+ * Recursively collect every directory that contains a `SKILL.md`, returned as
483
+ * POSIX paths RELATIVE to `rootDirAbs`. Unlike {@link scanSkillDirs} (DIRECT
484
+ * children only), this walks the whole subtree: real BMAD modules nest skills
485
+ * under `agents/`, `workflows/<phase>/…`, etc. A dir that IS a skill (has
486
+ * SKILL.md) is recorded and not descended into (skills don't nest in skills);
487
+ * `node_modules`/`.git` are skipped.
488
+ */
489
+ async function findSkillDirsRecursive(baseDirAbs, rootDirAbs) {
490
+ const found = [];
491
+ async function walk(dirAbs) {
492
+ if (await fs.pathExists(path.join(dirAbs, 'SKILL.md'))) {
493
+ found.push(toPosix(path.relative(rootDirAbs, dirAbs)));
494
+ return;
495
+ }
496
+ let entries;
497
+ try {
498
+ entries = await fs.readdir(dirAbs, { withFileTypes: true });
499
+ } catch {
500
+ return;
501
+ }
502
+ for (const entry of entries) {
503
+ if (!entry.isDirectory()) continue;
504
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
505
+ await walk(path.join(dirAbs, entry.name));
506
+ }
507
+ }
508
+ await walk(baseDirAbs);
509
+ return found.sort();
510
+ }
511
+
512
+ /**
513
+ * Determine which directory to scan for a registry entry's skills, given the
514
+ * cloned repo root and the entry's `module_definition` (repo-relative path to
515
+ * its module.yaml). Skills are NOT always beside module.yaml:
516
+ * - module.yaml at the skills' parent (`skills/module.yaml`,
517
+ * `src/core-skills/module.yaml`, `src/module.yaml`) → scan THAT dir.
518
+ * - module.yaml inside a `<name>-setup/assets/module.yaml` (bmad-method's
519
+ * PluginResolver "setup skill" layout, e.g. suno-band-manager) → the
520
+ * module's skills are SIBLINGS of the -setup skill, so scan the setup
521
+ * skill's PARENT.
522
+ * Returns a POSIX path relative to the repo root ('.' = repo root). No
523
+ * module_definition → the repo root (direct-style scan).
524
+ */
525
+ async function resolveRegistrySkillsRoot(entryRootDir, moduleDefPosix) {
526
+ if (!moduleDefPosix) return '.';
527
+ const mdDirPosix = path.posix.dirname(moduleDefPosix);
528
+ if (path.posix.basename(mdDirPosix) === 'assets') {
529
+ const setupSkillPosix = path.posix.dirname(mdDirPosix);
530
+ const setupSkillAbs = path.join(entryRootDir, ...setupSkillPosix.split('/'));
531
+ if (setupSkillPosix && (await fs.pathExists(path.join(setupSkillAbs, 'SKILL.md')))) {
532
+ const parentPosix = path.posix.dirname(setupSkillPosix);
533
+ return parentPosix === '' || parentPosix === '.' ? '.' : parentPosix;
534
+ }
535
+ }
536
+ return mdDirPosix === '' || mdDirPosix === '.' ? '.' : mdDirPosix;
537
+ }
538
+
481
539
  /**
482
540
  * Resolve every selected plugin's installable skill/module layout via
483
541
  * bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
@@ -784,10 +842,10 @@ async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
784
842
  // repo-relative path to the module's module.yaml; its directory is the
785
843
  // module root. Absent → fall back to the repo root (PluginResolver over the
786
844
  // whole clone), matching the AC3 fallback.
787
- let relModuleDirPosix = '.';
845
+ let moduleDefPosix = null;
788
846
  if (plugin.moduleDefinition) {
789
- const defPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
790
- const moduleYamlAbs = path.join(entryRootDir, ...defPosix.split('/'));
847
+ moduleDefPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
848
+ const moduleYamlAbs = path.join(entryRootDir, ...moduleDefPosix.split('/'));
791
849
  if (!(await fs.pathExists(moduleYamlAbs))) {
792
850
  throw new CustomMarketplaceError(
793
851
  `Registry entry '${plugin.code}': module_definition "${plugin.moduleDefinition}" ` +
@@ -795,27 +853,33 @@ async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
795
853
  `repository layout changed; aborting before staging (FR238).`
796
854
  );
797
855
  }
798
- relModuleDirPosix = path.posix.dirname(defPosix);
799
856
  }
800
857
 
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}`));
858
+ // Skills are NOT necessarily beside module.yaml — resolve the module's real
859
+ // skills root (handles the setup-skill/assets layout and modules whose
860
+ // module.yaml sits at the skills' parent) and scan it RECURSIVELY. A shallow
861
+ // scan of module.yaml's own dir silently missed nested skills (gds/bmm) and
862
+ // the setup-skill layout entirely (suno), dropping those modules.
863
+ const skillsRootPosix = await resolveRegistrySkillsRoot(entryRootDir, moduleDefPosix);
864
+ const skillsRootAbs =
865
+ skillsRootPosix === '.' ? entryRootDir : path.join(entryRootDir, ...skillsRootPosix.split('/'));
866
+ const skillRelPaths = await findSkillDirsRecursive(skillsRootAbs, entryRootDir);
805
867
  diag('custom-source: registry entry resolved', {
806
868
  code: plugin.code,
807
- moduleDir: relModuleDirPosix,
808
- skillDirsFound: skillNames.length,
809
- skills: skillNames,
869
+ moduleDef: moduleDefPosix,
870
+ skillsRoot: skillsRootPosix,
871
+ skillDirsFound: skillRelPaths.length,
872
+ skills: skillRelPaths,
810
873
  });
811
874
 
812
875
  const rawPlugin = {
813
876
  name: plugin.code,
814
877
  description: plugin.description || '',
815
878
  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).
879
+ source: skillsRootPosix,
880
+ // Skill dirs relative to the repo root (what PluginResolver.resolve joins
881
+ // against repoPath). Empty → resolvePlugin returns [] and the selected
882
+ // entry surfaces in the per-entry guard below (never a silent drop).
819
883
  skills: skillRelPaths,
820
884
  };
821
885
 
@@ -877,11 +941,45 @@ async function stageRegistrySubset({ source, plugins, selectedCodes, projectRoot
877
941
  seenCodes.add(p.code);
878
942
  }
879
943
 
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.
944
+ // Resolve each selected entry INDEPENDENTLY (each from its own clone) BEFORE
945
+ // touching the stage dir. A community registry legitimately contains
946
+ // individually-broken/stale entries — an unreachable repo, a nonexistent
947
+ // approved_tag (e.g. the marketplace pinning wds@v0.1.0 when that tag does
948
+ // not exist), or a module with no skills. One bad entry must NOT nuke the
949
+ // whole multi-select. Best-effort: keep the resolvable entries, collect the
950
+ // skipped ones (with reasons), install the good ones, and let the caller
951
+ // report the skips LOUDLY. FR238 = no SILENT partial install — NOT
952
+ // all-or-nothing.
882
953
  const entries = [];
954
+ const skipped = [];
883
955
  for (const plugin of selected) {
884
- entries.push(await resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn));
956
+ let entry;
957
+ try {
958
+ entry = await resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn);
959
+ } catch (err) {
960
+ diag('custom-source: registry entry skipped (resolve failed)', { code: plugin.code, error: err.message });
961
+ skipped.push({ code: plugin.code, reason: err.message });
962
+ continue;
963
+ }
964
+ if (!entry.resolved || entry.resolved.length === 0) {
965
+ const reason =
966
+ 'resolved to zero installable modules — no SKILL.md directories found ' +
967
+ '(stale registry pointer, changed repo layout, or a module with no skills)';
968
+ diag('custom-source: registry entry skipped (zero modules)', { code: plugin.code, repository: plugin.repository });
969
+ skipped.push({ code: plugin.code, reason });
970
+ continue;
971
+ }
972
+ entries.push(entry);
973
+ }
974
+
975
+ // Hard-fail ONLY when nothing at all resolved — otherwise proceed with the
976
+ // resolvable subset and surface `skipped` to the caller for a loud report.
977
+ if (entries.length === 0) {
978
+ const detail = skipped.map((s) => `${s.code} (${s.reason})`).join('; ');
979
+ throw new CustomMarketplaceError(
980
+ `None of the ${selected.length} selected registry ${selected.length === 1 ? 'entry' : 'entries'} could be installed — ${detail}. ` +
981
+ `Nothing was staged (FR238).`
982
+ );
885
983
  }
886
984
 
887
985
  const allModules = assertResolutionNonEmptyAndUniqueLeaves(
@@ -937,7 +1035,13 @@ async function stageRegistrySubset({ source, plugins, selectedCodes, projectRoot
937
1035
  );
938
1036
 
939
1037
  const customSourceArg = `./${CUSTOM_MARKETPLACE_STAGE_DIR_NAME}`;
940
- return { stagePath, customSourceArg, resolvedModules: allModules };
1038
+ return {
1039
+ stagePath,
1040
+ customSourceArg,
1041
+ resolvedModules: allModules,
1042
+ installedCodes: entries.map((e) => e.plugin.code),
1043
+ skipped,
1044
+ };
941
1045
  }
942
1046
 
943
1047
  // ─── Story 30.7 — persist + re-enumerate/reconcile on update ───────────────
@@ -981,6 +1085,8 @@ module.exports = {
981
1085
  CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
982
1086
  scanDirectModeSkillDirs,
983
1087
  scanSkillDirs,
1088
+ findSkillDirsRecursive,
1089
+ resolveRegistrySkillsRoot,
984
1090
  resolveSelectedPlugins,
985
1091
  assertResolutionNonEmptyAndUniqueLeaves,
986
1092
  stageCustomMarketplaceSubset,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ma-agents",
3
- "version": "3.17.0-beta.2",
3
+ "version": "3.17.0-beta.4",
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": {