ma-agents 3.18.0-beta.1 → 3.18.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/README.md CHANGED
@@ -277,6 +277,21 @@ Both messages end by stating that nothing was read or written on your behalf.
277
277
  These are stated here, in the shipped documentation, rather than only in the
278
278
  repository's changelog — which is **not** part of the npm package.
279
279
 
280
+ - **Versions 3.7.0 through 3.17.1 could silently delete hand-authored skills.**
281
+ When a tool's skills directory moved between versions, the installer removed
282
+ the old directory recursively once the new one had content, destroying any
283
+ skill you had written there yourself and reporting it as a successful
284
+ cleanup. This affected `.github/copilot/skills`, `.github/skills`,
285
+ `.roo/skills` and `.kilocode/skills`. **It is fixed in this version** — the
286
+ sweep now removes only what ma-agents installed and preserves the directory
287
+ if anything else remains — but deletions already made are not recoverable
288
+ from ma-agents: no backup was written and nothing but the directory name was
289
+ recorded. Check your version control if you kept such skills.
290
+ - **A file placed inside a skill directory ma-agents installed is still removed
291
+ with it.** The sweep's unit is the skill directory, not the file. If you have
292
+ edited a shipped skill in place, that edit is lost when the directory is
293
+ swept. Keep your own skills in their own directories, which are preserved.
294
+
280
295
  - **Every claim in this release about behaviour with a Confluence MCP *present*
281
296
  is mock-verified, not live.** No authorised Confluence MCP was available to
282
297
  the implementation or to any review, so the MCP-present branches were
package/bin/cli.js CHANGED
@@ -157,9 +157,13 @@ ${chalk.bold('Install options:')}
157
157
  ${chalk.cyan('--yes')} Skip all prompts, use defaults (for CI/CD)
158
158
  ${chalk.cyan('--agent <name>')} Target a specific agent (skip agent selection)
159
159
  ${chalk.cyan('--bmad-modules [csv]')} Control BMAD module selection.
160
- Bare: open a multiselect prompt
160
+ Bare: open a multiselect prompt (offers every
161
+ bundled module, incl. gds and wds)
161
162
  With CSV: install exactly those (e.g. tea,bmb)
162
- Omitted: install every non-retired module (default)
163
+ Omitted: install bmm,tea,bmb,cis (default). The
164
+ design-studio modules gds and wds are
165
+ bundled but not installed by default —
166
+ select them to install them.
163
167
  'bmm' is always force-included.
164
168
  ${chalk.cyan('--bmad-custom-source <ref>')} Enumerate extensions from ONE custom
165
169
  marketplace repo (local path or Git URL containing
@@ -347,7 +351,7 @@ function parseFlags(args) {
347
351
  // --bmad-modules — controls BMad module selection. Optional-value flag:
348
352
  // bare → ask the user via multiselect prompt
349
353
  // with CSV → install exactly those (bmm force-prepended, unknowns rejected)
350
- // not present → install all non-retired modules (no prompt)
354
+ // not present → install FR123's default set, bmm+tea+bmb+cis (no prompt)
351
355
  // Force-prepending of `bmm` and validation of unknown ids is handled
352
356
  // centrally by lib/bmad.resolveBmadModules.
353
357
  let bmadModulesFlag = '';
@@ -3671,18 +3675,30 @@ async function collectSprintManagementLocation(concern, existingRecord) {
3671
3675
  *
3672
3676
  * Three branches:
3673
3677
  * 1. `--bmad-modules <csv>` → install exactly those (bmm force-prepended,
3674
- * unknowns rejected with a clear error)
3675
- * 2. `--bmad-modules` bare → multiselect prompt; bmm is required and
3676
- * pre-checked, others default-checked so a no-op confirmation matches
3677
- * the no-flag default
3678
- * 3. (no flag) → install every non-retired module from the
3679
- * cache manifest, no prompt — restores the originally-intended
3680
- * behavior that the hardcoded ['bmm'] literal had been suppressing
3678
+ * unknowns rejected with a clear error). An explicit selection always
3679
+ * wins, including one that names `gds` or `wds`.
3680
+ * 2. `--bmad-modules` bare → multiselect prompt; EVERY bundled module is
3681
+ * offered, bmm is required and pre-checked, and the remaining initial
3682
+ * check state is the FR123 default set — so a no-op confirmation lands
3683
+ * on exactly the same modules as branch 3.
3684
+ * 3. (no flag) → no prompt. In order: a persisted selection
3685
+ * from a prior run (bug 27.13), else the modules already present under
3686
+ * `_bmad/`, else `bmad.getDefaultBmadModules()` — FR123's amended
3687
+ * default set, bmm + tea + bmb + cis.
3688
+ *
3689
+ * WHY BRANCH 3 IS NO LONGER "EVERY NON-RETIRED MODULE" (FR123, amended
3690
+ * 2026-08-02): that default is what put a five-folder `design-artifacts/`
3691
+ * tree at the root of a Copilot+Cline project that had never asked for UX
3692
+ * tooling — `wds` installed unasked and materialised its `directories:`
3693
+ * block. `gds` and `wds` are still BUNDLED and still installable offline;
3694
+ * they are simply no longer installed without being asked for. Bundling and
3695
+ * installing are different decisions made at different times, and only the
3696
+ * second one moved.
3681
3697
  *
3682
3698
  * `bmm` is force-included in every path because the BMad core agents
3683
3699
  * (analyst, PM, dev, architect, ...) live there and downstream skills
3684
- * break without them. Retired modules (currently only `wds`) are filtered
3685
- * out unconditionally.
3700
+ * break without them. Retired modules (none at v6.10.0) are filtered out
3701
+ * unconditionally.
3686
3702
  */
3687
3703
  async function selectBmadModules({ bmadModulesFlag, bmadModulesPrompt, projectRoot } = {}) {
3688
3704
  const installable = bmad.getInstallableBmadModules().filter(m => !m.retired);
@@ -3712,15 +3728,22 @@ async function selectBmadModules({ bmadModulesFlag, bmadModulesPrompt, projectRo
3712
3728
 
3713
3729
  // Branch 2: bare --bmad-modules → multiselect prompt.
3714
3730
  if (bmadModulesPrompt) {
3731
+ // FR123: every bundled module is OFFERED — this prompt is the documented
3732
+ // route by which `gds`/`wds` remain reachable offline — but the initial
3733
+ // check state is the default INSTALL set, so pressing Enter without
3734
+ // touching anything lands on the same modules as the no-flag branch below.
3735
+ // Checking them all here would silently reintroduce the very "installs
3736
+ // everything" default this change removes.
3737
+ const defaultIds = new Set(bmad.getDefaultBmadModules(installable));
3715
3738
  const choices = installable.map(m => {
3716
3739
  const isCore = m.id === 'bmm';
3717
3740
  const requiredTag = isCore ? chalk.yellow(' (required)') : '';
3718
3741
  return {
3719
3742
  title: chalk.white(m.id) + requiredTag + chalk.gray(` — ${m.description}`),
3720
3743
  value: m.id,
3721
- // Default-check everything so hitting Enter matches the no-flag
3722
- // "install all" default. Users uncheck what they want to skip.
3723
- selected: true,
3744
+ // bmm is force-included downstream regardless; pre-checking it keeps
3745
+ // the prompt honest about that.
3746
+ selected: isCore || defaultIds.has(m.id),
3724
3747
  };
3725
3748
  });
3726
3749
 
@@ -3740,22 +3763,52 @@ async function selectBmadModules({ bmadModulesFlag, bmadModulesPrompt, projectRo
3740
3763
  return persist(bmad.resolveBmadModules({ requested: chosenModules, available: installable }));
3741
3764
  }
3742
3765
 
3743
- // Branch 3: no flag → use the persisted selection from a prior run if present
3744
- // (bug 27.13 — a previous deselection must survive); otherwise fall back to
3745
- // the original "install every available module" default. The persisted set is
3746
- // intersected with the currently-installable set so a since-retired module
3747
- // does not resurface, and bmm is force-included via resolveBmadModules.
3766
+ // Branch 3: no flag. Three fallbacks, most-specific first.
3767
+ const availableIds = new Set(installable.map(m => m.id));
3768
+
3769
+ // 3a — a persisted selection from a prior run (bug 27.13): a previous
3770
+ // deselection must survive. Intersected with the currently-installable set so
3771
+ // a since-retired module does not resurface; bmm is force-included via
3772
+ // resolveBmadModules.
3748
3773
  const persisted = getBmadModules(root);
3749
3774
  if (persisted && persisted.length > 0) {
3750
- const availableIds = new Set(installable.map(m => m.id));
3751
3775
  const stillInstallable = persisted.filter(id => availableIds.has(id));
3752
3776
  try {
3753
3777
  return persist(bmad.resolveBmadModules({ requested: stillInstallable, available: installable }));
3754
3778
  } catch {
3755
- // If the persisted set somehow fails validation, fall through to default.
3779
+ // If the persisted set somehow fails validation, fall through.
3756
3780
  }
3757
3781
  }
3758
- return persist(installable.map(m => m.id));
3782
+
3783
+ // 3b — NON-DESTRUCTIVE MIGRATION. Narrowing the default (FR123, amended
3784
+ // 2026-08-02) is behaviour-visible to projects that already installed under
3785
+ // the old every-module default, and an update run passes this same resolved
3786
+ // list to `bmad.updateBmad`. A project that already has `wds` under `_bmad/`
3787
+ // must not have it dropped out of its own module list just because the
3788
+ // default moved — narrowing applies to NEW installs, not to existing ones.
3789
+ //
3790
+ // 3a covers projects installed since bug 27.13 landed persistence; this
3791
+ // covers the ones installed before it, where `.ma-agents.json` carries no
3792
+ // `bmadModules` field at all and disk is the only record of what was chosen.
3793
+ // A fresh project has no `_bmad/`, so this is inert for new installs — which
3794
+ // is exactly the "existing installs unchanged, only NEW installs get the
3795
+ // narrower default" posture NFR49 / Story 30.4 established for Demerzel.
3796
+ // Persisting the result also migrates the project onto 27.13's durable
3797
+ // record, so this disk probe is needed at most once per project.
3798
+ const alreadyInstalled = bmad.getInstalledBmadModules(root).filter(id => availableIds.has(id));
3799
+ if (alreadyInstalled.length > 0) {
3800
+ try {
3801
+ return persist(bmad.resolveBmadModules({ requested: alreadyInstalled, available: installable }));
3802
+ } catch {
3803
+ // Fall through to the default set.
3804
+ }
3805
+ }
3806
+
3807
+ // 3c — a genuinely fresh install: FR123's amended default set.
3808
+ return persist(bmad.resolveBmadModules({
3809
+ requested: bmad.getDefaultBmadModules(installable),
3810
+ available: installable,
3811
+ }));
3759
3812
  }
3760
3813
 
3761
3814
  // --- Custom marketplace source (Story 30.6a) ---
@@ -4408,10 +4461,10 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
4408
4461
  // stage the plugin and invoke the installer in one pass.
4409
4462
  // v6.6.0: --tools none is rejected for fresh installs; skip BMAD entirely when no IDE tools selected.
4410
4463
  if (bmadTools.length > 0) {
4411
- // Resolve module set once per BMAD invocation. Default is "install
4412
- // every non-retired module"; users opt into a specific subset with
4413
- // --bmad-modules <csv> or open a multiselect prompt with --bmad-modules
4414
- // (bare).
4464
+ // Resolve module set once per BMAD invocation. Default (FR123, amended
4465
+ // 2026-08-02) is bmm+tea+bmb+cis; the bundled design-studio modules gds
4466
+ // and wds install only when asked for, via --bmad-modules <csv> or the
4467
+ // multiselect that bare --bmad-modules opens.
4415
4468
  const bmadModules = await selectBmadModules({
4416
4469
  bmadModulesFlag,
4417
4470
  bmadModulesPrompt,
@@ -294,11 +294,20 @@ All 11 BMAD agents have enforcement via `critical_actions` in `.customize.yaml`
294
294
  **Mechanism:**
295
295
  ```yaml
296
296
  critical_actions:
297
- 1: "Read the skills MANIFEST at {project-root}/_bmad/skills/MANIFEST.yaml"
297
+ 1: "Read the skills MANIFEST at {project-root}/_bmad/skills/<agent-slug>/MANIFEST.yaml if it exists"
298
298
  2: "For each skill marked always_load: true, read the skill file completely"
299
299
  3: "Follow all skill directives during this session"
300
300
  ```
301
301
 
302
+ > **Note (2026-09-12):** this snippet previously showed a bare
303
+ > `{project-root}/_bmad/skills/MANIFEST.yaml`. That path is not what Story 8.3
304
+ > specified — the manifest is per-agent-host, at
305
+ > `{project-root}/_bmad/skills/<agent-slug>/MANIFEST.yaml`, where `<agent-slug>`
306
+ > matches the `skillsDir` in `lib/agents.js` (`sre`, `devops`, `cyber`, `sqa`).
307
+ > The bare form leaked from here into `ma-agent-sqa`'s Critical Actions block and
308
+ > survived there until it was corrected. Keep the slug segment in any copy of this
309
+ > snippet.
310
+
302
311
  **Deployment:** Extension module at `_bmad/extensions/ma-agents-skills/` deployed during BMAD customization pipeline (Stage 3 in `bmad.js`).
303
312
 
304
313
  **Coverage:**
package/lib/agents.js CHANGED
@@ -242,8 +242,27 @@ const agents = [
242
242
  version: '1.0.0',
243
243
  category: 'ide',
244
244
  description: 'Google Deepmind Antigravity Agent',
245
- skillsDir: '.antigravity/skills',
246
- getProjectPath: () => path.join(process.cwd(), '.antigravity', 'skills'),
245
+ // Bug `antigravity-path-divergence`: this used to read `.antigravity/skills`,
246
+ // a path ma-agents invented from its own per-tool naming convention. Upstream
247
+ // bmad-method maps the SAME tool to `.agent/skills`
248
+ // (node_modules/bmad-method/tools/installer/ide/platform-codes.yaml, platform
249
+ // `antigravity`, in a file whose header records the paths as "verified against
250
+ // each tool's primary docs"; its global counterpart `~/.gemini/antigravity/skills`
251
+ // shows the entry was researched rather than guessed). Because ma-agents
252
+ // delegates part of the install to that installer, a single run produced TWO
253
+ // trees — and `.agent/skills`, the one Antigravity actually reads, was named by
254
+ // neither this registry nor lib/bmad.js, so every ma-agents post-deploy patch
255
+ // (the on-prem phase prefix among them) silently skipped it. Measured in a real
256
+ // on-prem project: .claude/.agents/.cline each carried the prefix on 6 personas,
257
+ // .agent/skills on 0.
258
+ // Upstream is the authority on which directory a tool reads, so we align —
259
+ // exactly as copilot/roo-code/kilocode were aligned to `.agents/skills` when
260
+ // bmad-method 6.5.0 moved them (see the copilot entry above). This is a
261
+ // per-tool decision on per-tool evidence: the other three divergences in the
262
+ // table are recorded in PLATFORM_PATH_DIVERGENCES below, deliberately unchanged.
263
+ // Existing installs migrate via OBSOLETE_TOOL_SKILL_DIRS in lib/installer.js.
264
+ skillsDir: '.agent/skills',
265
+ getProjectPath: () => path.join(process.cwd(), '.agent', 'skills'),
247
266
  getGlobalPath: () => {
248
267
  const platform = os.platform();
249
268
  if (platform === 'win32') {
@@ -356,6 +375,64 @@ const agents = [
356
375
  // Add them here in a dedicated future story if/when demand appears.
357
376
  ];
358
377
 
378
+ /**
379
+ * Declared, deliberate divergences between this registry's project `skillsDir`
380
+ * and bmad-method's `installer.target_dir` for the SAME tool
381
+ * (`node_modules/bmad-method/tools/installer/ide/platform-codes.yaml`).
382
+ * Keyed by bmad platform code (see `getBmadPlatformCode`).
383
+ *
384
+ * Why this exists (bug `antigravity-path-divergence`): the two tables had drifted
385
+ * apart for four tools and nothing compared them outside the five flagships, so a
386
+ * whole second skill tree appeared in real projects with no owner and no patcher.
387
+ * `test/routing-parity.test.js` (section 5) now enumerates EVERY tool in BOTH
388
+ * tables and fails on any divergence that is not declared here — and on any
389
+ * declaration whose recorded paths have gone stale, so an entry cannot rot into
390
+ * cover for a different divergence.
391
+ *
392
+ * Scope note — PROJECT paths only. `getGlobalPath()` follows a per-OS
393
+ * application-data convention that differs from upstream's `global_target_dir`
394
+ * for EVERY tool, claude-code included (`%APPDATA%/Claude/skills` vs
395
+ * `~/.claude/skills`). That is a whole-table convention difference, not a
396
+ * per-tool divergence, and is deliberately out of scope here.
397
+ *
398
+ * To add an entry you must record BOTH live paths and a real reason. To remove
399
+ * one, align the paths — the test fails on a declaration whose paths now agree.
400
+ */
401
+ const PLATFORM_PATH_DIVERGENCES = Object.freeze({
402
+ gemini: Object.freeze({
403
+ maSkillsDir: '.gemini/skills',
404
+ bmadTargetDir: '.agents/skills',
405
+ reason:
406
+ "Upstream's `gemini` platform is the Gemini CLI, which reads the shared " +
407
+ '`.agents/skills` tree; this registry entry targets Google Gemini Code Assist ' +
408
+ '(the IDE extension — note its instruction file `.gemini/gemini.md`), a ' +
409
+ 'different product sharing the same code. Contained: upstream writes ' +
410
+ '`.agents/skills`, which is already an IDE skillsDir in this registry, already ' +
411
+ 'a seed in lib/bmad.js TOOL_SKILL_DIRS, and already reconciled by bug 27.16 — ' +
412
+ 'so no tree goes unpatched. Realigning is a compatibility call for its own story.'
413
+ }),
414
+ cursor: Object.freeze({
415
+ maSkillsDir: '.cursor/skills',
416
+ bmadTargetDir: '.agents/skills',
417
+ reason:
418
+ 'Cursor reads its own `.cursor/` workspace directory (this registry also stamps ' +
419
+ '`.cursor/cursor.md` there); upstream routes it to the shared `.agents/skills` ' +
420
+ 'cross-tool tree instead. Contained for the same reason as `gemini`: upstream ' +
421
+ "'s target is already a known, patched and reconciled tree. Kept deliberately " +
422
+ 'so this bug fix changes the write path of exactly one tool, on that tool’s evidence.'
423
+ }),
424
+ opencode: Object.freeze({
425
+ maSkillsDir: '.opencode/skills',
426
+ bmadTargetDir: '.agents/skills',
427
+ reason:
428
+ 'OpenCode is wired here through `opencode.json::instructions[]` plus AGENTS.md ' +
429
+ '(Story 21.4) against its own `.opencode/` tree; upstream routes it to the shared ' +
430
+ '`.agents/skills`. Contained for the same reason as `gemini` and `cursor`: ' +
431
+ "upstream's target is already a known, patched and reconciled tree. Realignment " +
432
+ 'would also have to move the instruction wiring, so it belongs in its own story.'
433
+ })
434
+ });
435
+
359
436
  function getAgent(agentId) {
360
437
  return agents.find(a => a.id === agentId);
361
438
  }
@@ -383,5 +460,6 @@ module.exports = {
383
460
  getAgent,
384
461
  getAllAgents,
385
462
  getAgentsByCategory,
386
- getBmadPlatformCode
463
+ getBmadPlatformCode,
464
+ PLATFORM_PATH_DIVERGENCES
387
465
  };
@@ -43,7 +43,7 @@ You must fully embody this agent's persona and follow all activation instruction
43
43
  | 6 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
44
44
 
45
45
  ## Critical Actions
46
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/cyber/MANIFEST.yaml
47
- 2. For each skill marked always_load: true, read the skill file completely
46
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/cyber/MANIFEST.yaml if it exists
47
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
48
48
  3. If _bmad-output/project-context.md exists, read it completely
49
49
  4. Follow all skill directives and project-context rules during this session
@@ -43,7 +43,7 @@ You must fully embody this agent's persona and follow all activation instruction
43
43
  | 6 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
44
44
 
45
45
  ## Critical Actions
46
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/devops/MANIFEST.yaml
47
- 2. For each skill marked always_load: true, read the skill file completely
46
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/devops/MANIFEST.yaml if it exists
47
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
48
48
  3. If _bmad-output/project-context.md exists, read it completely
49
49
  4. Follow all skill directives and project-context rules during this session
@@ -53,7 +53,7 @@ You must fully embody this agent's persona and follow all activation instruction
53
53
  | 14 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
54
54
 
55
55
  ## Critical Actions
56
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/MANIFEST.yaml
57
- 2. For each skill marked always_load: true, read the skill file completely
56
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/sqa/MANIFEST.yaml if it exists
57
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
58
58
  3. If _bmad-output/project-context.md exists, read it completely
59
59
  4. Follow all skill directives and project-context rules during this session
@@ -43,7 +43,7 @@ You must fully embody this agent's persona and follow all activation instruction
43
43
  | 6 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
44
44
 
45
45
  ## Critical Actions
46
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/sre/MANIFEST.yaml
47
- 2. For each skill marked always_load: true, read the skill file completely
46
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/sre/MANIFEST.yaml if it exists
47
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
48
48
  3. If _bmad-output/project-context.md exists, read it completely
49
49
  4. Follow all skill directives and project-context rules during this session
@@ -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.18.0-beta.1",
40
+ "version": "3.18.0-beta.2",
41
41
  "author": {
42
42
  "name": "Alon Mayaffit"
43
43
  },
@@ -43,7 +43,7 @@ You must fully embody this agent's persona and follow all activation instruction
43
43
  | 6 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
44
44
 
45
45
  ## Critical Actions
46
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/cyber/MANIFEST.yaml
47
- 2. For each skill marked always_load: true, read the skill file completely
46
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/cyber/MANIFEST.yaml if it exists
47
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
48
48
  3. If _bmad-output/project-context.md exists, read it completely
49
49
  4. Follow all skill directives and project-context rules during this session
@@ -43,7 +43,7 @@ You must fully embody this agent's persona and follow all activation instruction
43
43
  | 6 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
44
44
 
45
45
  ## Critical Actions
46
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/devops/MANIFEST.yaml
47
- 2. For each skill marked always_load: true, read the skill file completely
46
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/devops/MANIFEST.yaml if it exists
47
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
48
48
  3. If _bmad-output/project-context.md exists, read it completely
49
49
  4. Follow all skill directives and project-context rules during this session
@@ -53,7 +53,7 @@ You must fully embody this agent's persona and follow all activation instruction
53
53
  | 14 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
54
54
 
55
55
  ## Critical Actions
56
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/MANIFEST.yaml
57
- 2. For each skill marked always_load: true, read the skill file completely
56
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/sqa/MANIFEST.yaml if it exists
57
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
58
58
  3. If _bmad-output/project-context.md exists, read it completely
59
59
  4. Follow all skill directives and project-context rules during this session
@@ -43,7 +43,7 @@ You must fully embody this agent's persona and follow all activation instruction
43
43
  | 6 | DA | Dismiss Agent | "dismiss", "exit", "quit" | _(built-in)_ |
44
44
 
45
45
  ## Critical Actions
46
- 1. Read the skills MANIFEST at {project-root}/_bmad/skills/sre/MANIFEST.yaml
47
- 2. For each skill marked always_load: true, read the skill file completely
46
+ 1. Read the skills MANIFEST at {project-root}/_bmad/skills/sre/MANIFEST.yaml if it exists
47
+ 2. For each skill marked always_load: true, read the skill file completely (only if that MANIFEST was read; if it was not, skip this step)
48
48
  3. If _bmad-output/project-context.md exists, read it completely
49
49
  4. Follow all skill directives and project-context rules during this session
package/lib/bmad.js CHANGED
@@ -527,6 +527,69 @@ const BMAD_MODULE_DESCRIPTIONS = Object.freeze({
527
527
  wds: 'Whiteport Design Studio — strategic UX and design-first planning',
528
528
  });
529
529
 
530
+ /**
531
+ * FR123, as amended 2026-08-02 — the DEFAULT BMAD install set.
532
+ *
533
+ * "the default install set is bmm plus the non-design bundled modules —
534
+ * tea, bmb, cis — from the bundled cache manifest. The two design-studio
535
+ * modules gds (Game Design Studio) and wds (Whiteport Design Studio) are
536
+ * bundled but not installed by default."
537
+ *
538
+ * BUNDLING AND INSTALLING ARE DIFFERENT THINGS and this constant only speaks
539
+ * about the second one. `gds` and `wds` stay in `lib/bmad-cache/`, stay in
540
+ * `cache-manifest.json`, stay in `KNOWN_BUNDLED_MODULES`, and stay offered by
541
+ * the `--bmad-modules` multiselect — a `--bmad-modules wds` install still
542
+ * resolves entirely from the bundled cache with no network access (NFR2 /
543
+ * NFR50 / NFR57 are untouched). What changes is only that they no longer
544
+ * arrive uninvited.
545
+ *
546
+ * WHY THESE TWO AND NOT OTHERS: they are the only bundled modules that
547
+ * declare a `directories:` block scaffolding `design-artifacts/` at the
548
+ * project root, which is how a Copilot+Cline user with no interest in UX
549
+ * tooling ended up with an unexplained five-folder `design-artifacts/` tree
550
+ * (bug B2). Upstream itself declares `default_selected: false` on every
551
+ * bundled module and `true` only on `bmm`, so narrowing here moves ma-agents
552
+ * TOWARDS upstream's stated intent rather than away from it.
553
+ *
554
+ * DELIBERATELY A LITERAL, NOT DERIVED FROM THE MANIFEST. The previous
555
+ * behaviour — "every non-retired key of the cache manifest" — is precisely
556
+ * what made the set grow silently every time a module was bundled. Naming the
557
+ * members means the next bundled module has to be added here on purpose.
558
+ */
559
+ const DEFAULT_BMAD_MODULE_IDS = Object.freeze(['bmm', 'tea', 'bmb', 'cis']);
560
+
561
+ /**
562
+ * The counterpart to DEFAULT_BMAD_MODULE_IDS: bundled, fully installable, but
563
+ * only when explicitly asked for. Exported so callers (and tests) can state
564
+ * "bundled but not default" without re-deriving it by subtraction.
565
+ */
566
+ const NON_DEFAULT_BMAD_MODULE_IDS = Object.freeze(['gds', 'wds']);
567
+
568
+ /**
569
+ * The FR123 default set, intersected with what this bundle can actually
570
+ * install.
571
+ *
572
+ * The intersection is not ceremony: `getInstallableBmadModules()` degrades to
573
+ * `[bmm]` alone when the cache manifest is missing or unreadable, and a
574
+ * default set containing `tea` would then be handed to `resolveBmadModules`,
575
+ * which throws on unknown ids. A broken cache must degrade to a minimal
576
+ * install, not to a crash in the module-selection step.
577
+ *
578
+ * Order follows DEFAULT_BMAD_MODULE_IDS, so the resolved `--modules` value is
579
+ * deterministic (NFR21) regardless of manifest key order.
580
+ *
581
+ * @param {Array<{id: string}>|string[]} [available] - installable modules;
582
+ * defaults to the non-retired installable set.
583
+ * @returns {string[]} module ids, `bmm` first
584
+ */
585
+ function getDefaultBmadModules(available) {
586
+ const pool = Array.isArray(available)
587
+ ? available
588
+ : getInstallableBmadModules().filter(m => !m.retired);
589
+ const ids = new Set(pool.map(m => (m && typeof m === 'object' ? m.id : m)));
590
+ return DEFAULT_BMAD_MODULE_IDS.filter(id => ids.has(id));
591
+ }
592
+
530
593
  /**
531
594
  * Return the BMAD modules this ma-agents bundle can install. `bmm` (the
532
595
  * BMad core) is always first because it ships inside the `bmad-method`
@@ -567,7 +630,18 @@ function getInstallableBmadModules() {
567
630
  /**
568
631
  * Detect which BMad modules are already installed under a given project
569
632
  * root by checking for `_bmad/<id>/` directories. Used by the install
570
- * wizard's update path to pre-check the user's existing module set.
633
+ * wizard's update path to pre-check the user's existing module set, and by
634
+ * `selectBmadModules` branch 3b as the non-destructive migration probe for
635
+ * projects that predate bug 27.13's persisted selection.
636
+ *
637
+ * DIRECTORIES, not merely entries. `fs.existsSync` answers yes for a regular
638
+ * file, so a stray `_bmad/wds` FILE was enough to make this report wds
639
+ * installed — and because branch 3b PERSISTS what it finds, that misreading
640
+ * became a durable "explicit user selection" the project could never shed.
641
+ * `prePopulateBmadCache` checks `stat.isDirectory()` in the analogous place
642
+ * (the cache-source walk); this matches it. `statSync` is wrapped because the
643
+ * entry can disappear between listing and stat and because a broken symlink
644
+ * throws ENOENT — both mean "not an installed module".
571
645
  *
572
646
  * @param {string} projectRoot - Absolute or relative project root
573
647
  * @returns {string[]} Module ids that have a corresponding `_bmad/<id>/` dir
@@ -577,7 +651,13 @@ function getInstalledBmadModules(projectRoot) {
577
651
  const bmadDir = path.join(projectRoot, BMAD_DIR);
578
652
  if (!fs.existsSync(bmadDir)) return [];
579
653
  const installable = getInstallableBmadModules().map(m => m.id);
580
- return installable.filter(id => fs.existsSync(path.join(bmadDir, id)));
654
+ return installable.filter(id => {
655
+ try {
656
+ return fs.statSync(path.join(bmadDir, id)).isDirectory();
657
+ } catch {
658
+ return false;
659
+ }
660
+ });
581
661
  }
582
662
 
583
663
  /**
@@ -1271,23 +1351,166 @@ const PERSONA_SKILL_MAP = Object.freeze({
1271
1351
  'bmm-analyst': 'bmad-agent-analyst',
1272
1352
  'bmm-tech-writer': 'bmad-agent-tech-writer',
1273
1353
  'bmm-ux-designer': 'bmad-agent-ux-designer',
1354
+ // INERT — these two never deploy. `bmad-agent-sm` and `bmad-agent-qa` were
1355
+ // retired upstream (merged into Amelia) and ma-agents itself deletes them on
1356
+ // every install via RETIRED_SKILL_IDS (lib/installer.js:2224-2229). Both
1357
+ // `bmm-sm.customize.yaml` and `bmm-qa.customize.yaml` nevertheless author an
1358
+ // `on_prem_phase_prefix`, so an authored behaviour is routed to a directory
1359
+ // nothing writes: measured on a real on-prem install, six of these eight
1360
+ // entries carry the prefix and these two are absent from all four trees.
1361
+ // Left AS IS deliberately — repointing `bmm-qa` at `ma-agent-sqa` (the
1362
+ // persona that file actually describes: Gadi, trigger `skill:ma-agent-sqa`)
1363
+ // would start injecting into a shipped persona that has never carried it,
1364
+ // which is a product decision, not a drive-by on an unrelated path fix.
1365
+ // Filed as bug-on-prem-phase-prefix-is-authored-for-two-personas-that-are-
1366
+ // never-deployed, with both readings and the measurement.
1274
1367
  'bmm-sm': 'bmad-agent-sm',
1275
1368
  'bmm-qa': 'bmad-agent-qa',
1276
1369
  });
1277
1370
 
1278
- // Tool skill-dir roots. Kept in sync with `lib/agents.js` canonical
1279
- // `skillsDir` values — duplicated here (not imported) to avoid pulling the
1280
- // agents module into install-time code paths that don't need it. The five
1281
- // IDE tools listed are the only targets that receive `bmad-agent-*` persona
1282
- // skills post-22.6 (verified 2026-04-23 via scratch install).
1283
- // bmad-method 6.5.0 moved copilot, roo, and kilo to the shared .agents/skills/
1284
- // directory. Deduplicated here — F1a walks each unique dir once.
1371
+ // Tool skill-dir roots declared by `lib/agents.js` (category `ide`), deduplicated
1372
+ // — duplicated here rather than imported to avoid pulling the agents module into
1373
+ // install-time code paths that don't need it. `test/routing-parity.test.js` 5.4
1374
+ // asserts set equality against the IDE registry, so drift fails at build time.
1375
+ //
1376
+ // Bug `antigravity-path-divergence`: this list is a SEED, no longer the patch
1377
+ // target set. A hard-coded list can only ever name the trees ma-agents knows
1378
+ // about, and the defect was precisely a tree it did not — bmad-method wrote
1379
+ // Antigravity's skills to `.agent/skills` while this registry said
1380
+ // `.antigravity/skills`, so the on-prem phase-prefix pass walked straight past a
1381
+ // populated 166-skill tree and nothing failed. `discoverDeployedSkillTrees()`
1382
+ // below derives the real target set from what is on disk; the seed only survives
1383
+ // so a future registry entry outside the dot-container convention is still honoured.
1285
1384
  const TOOL_SKILL_DIRS = Object.freeze([
1286
1385
  path.join('.claude', 'skills'),
1386
+ path.join('.gemini', 'skills'),
1287
1387
  path.join('.agents', 'skills'), // copilot, roo-code, kilocode (bmad-method 6.5.0)
1288
1388
  path.join('.cline', 'skills'),
1389
+ path.join('.cursor', 'skills'),
1390
+ path.join('.agent', 'skills'), // antigravity (aligned to bmad-method upstream)
1391
+ path.join('.opencode', 'skills'),
1289
1392
  ]);
1290
1393
 
1394
+ // Directories never descended into while scanning for deployed skill trees.
1395
+ const DEPLOYED_TREE_SCAN_SKIP = Object.freeze(['.git', 'node_modules']);
1396
+
1397
+ // Skill directory names whose presence marks a `<container>/skills` directory as
1398
+ // a DEPLOYED persona tree rather than an ordinary folder called "skills". Both
1399
+ // persona families are listed: the `bmad-agent-*` set BMAD deploys and the
1400
+ // `ma-agent-*` set this package ships, so a tree carrying either is discovered.
1401
+ const DEPLOYED_TREE_PERSONA_MARKERS = Object.freeze([
1402
+ ...Object.values(PERSONA_SKILL_MAP),
1403
+ 'ma-agent-cyber',
1404
+ 'ma-agent-devops',
1405
+ 'ma-agent-sre',
1406
+ 'ma-agent-sqa',
1407
+ ]);
1408
+
1409
+ /**
1410
+ * Derive the set of deployed skill trees present under `projectRoot` FROM DISK.
1411
+ *
1412
+ * A deployed tool skill tree is a directory `<container>/skills` (or
1413
+ * `<container>/<sub>/skills`, e.g. `.github/copilot/skills`) that contains at
1414
+ * least one persona skill directory with a `SKILL.md`. Any writer can create
1415
+ * one — ma-agents' own standalone loop, bmad-method's PluginResolver, or a tool
1416
+ * we have never registered — and post-deploy patches must reach all of them.
1417
+ *
1418
+ * The walk is deliberately confined to DOT-PREFIXED containers. Every
1419
+ * `target_dir` in bmad-method's `platform-codes.yaml` is dot-prefixed, so this
1420
+ * covers the whole upstream surface; it also guarantees the patcher can never
1421
+ * reach a SOURCE checkout's own skill directories (`lib/bmad-extension/skills/`,
1422
+ * `skills/`), which are shipped artifacts with pinned digests, not deployments.
1423
+ *
1424
+ * @param {string} projectRoot Absolute project root to scan
1425
+ * @param {string[]} [personaDirNames] Skill directory names that mark a tree as
1426
+ * a deployment target. Defaults to
1427
+ * DEPLOYED_TREE_PERSONA_MARKERS (both the
1428
+ * `bmad-agent-*` and `ma-agent-*` families);
1429
+ * callers may narrow it to the personas they
1430
+ * actually intend to patch.
1431
+ *
1432
+ * Known limit: the level-1 scan uses `Dirent.isDirectory()`, which is false for
1433
+ * a symlink. A SYMLINKED, UNREGISTERED tool container is therefore not
1434
+ * discovered; registered ones still are, because the seed pass stats (and so
1435
+ * follows) their paths.
1436
+ * @returns {string[]} Sorted tree paths relative to `projectRoot`, deduplicated
1437
+ * case-insensitively on win32 (where `.Claude/skills` and
1438
+ * `.claude/skills` are one physical directory) and
1439
+ * case-sensitively everywhere else
1440
+ */
1441
+ function discoverDeployedSkillTrees(projectRoot, personaDirNames = DEPLOYED_TREE_PERSONA_MARKERS) {
1442
+ if (!projectRoot || typeof projectRoot !== 'string') return [];
1443
+ if (!Array.isArray(personaDirNames) || personaDirNames.length === 0) return [];
1444
+
1445
+ const isDeployedTree = (relDir) => {
1446
+ const abs = path.join(projectRoot, relDir);
1447
+ try {
1448
+ if (!fs.statSync(abs).isDirectory()) return false;
1449
+ } catch {
1450
+ return false;
1451
+ }
1452
+ // `isFile()`, not `existsSync()`: a DIRECTORY named `SKILL.md` would
1453
+ // otherwise mark the tree as a deployment target, and every consumer
1454
+ // then fails on EISDIR when it tries to read it. The read is guarded
1455
+ // (the error is caught and warned), so this is not a crash — but the
1456
+ // warning is misleading and the tree was never a deployment at all.
1457
+ return personaDirNames.some(name => {
1458
+ try {
1459
+ return fs.statSync(path.join(abs, name, 'SKILL.md')).isFile();
1460
+ } catch {
1461
+ return false;
1462
+ }
1463
+ });
1464
+ };
1465
+
1466
+ const subdirs = (rel) => {
1467
+ try {
1468
+ return fs.readdirSync(rel ? path.join(projectRoot, rel) : projectRoot, { withFileTypes: true })
1469
+ .filter(e => e.isDirectory())
1470
+ .map(e => e.name);
1471
+ } catch {
1472
+ return []; // unreadable dir — never fatal for a post-install pass
1473
+ }
1474
+ };
1475
+
1476
+ // Keyed by the case-folded path on case-insensitive filesystems, holding the
1477
+ // FIRST spelling seen. On Windows `.Claude/skills` and `.claude/skills` are
1478
+ // ONE physical directory: the seed pass stats the registry spelling and
1479
+ // succeeds, then the on-disk scan reports whatever casing is actually on
1480
+ // disk, so a plain Set enumerates the same tree twice. The second pass is
1481
+ // idempotent (the marker block is replaced byte-for-byte), so this was never
1482
+ // a correctness bug — but it double-counts in every caller's result list.
1483
+ // Seeding runs first, so the registry's canonical spelling wins.
1484
+ // Confined to win32 deliberately: macOS is case-insensitive by default but
1485
+ // can be formatted case-sensitive, and we have no evidence to change
1486
+ // behaviour there.
1487
+ const found = new Map();
1488
+ const caseKey = (rel) => (process.platform === 'win32' ? rel.toLowerCase() : rel);
1489
+ const addTree = (rel) => {
1490
+ const key = caseKey(rel);
1491
+ if (!found.has(key)) found.set(key, rel);
1492
+ };
1493
+
1494
+ // 1. Registry seed — honours any declared dir outside the dot convention.
1495
+ for (const rel of TOOL_SKILL_DIRS) {
1496
+ if (isDeployedTree(rel)) addTree(rel);
1497
+ }
1498
+
1499
+ // 2. On-disk scan of dot-prefixed containers, one level of nesting deep.
1500
+ for (const lvl1 of subdirs('')) {
1501
+ if (!lvl1.startsWith('.') || DEPLOYED_TREE_SCAN_SKIP.includes(lvl1)) continue;
1502
+ const direct = path.join(lvl1, 'skills');
1503
+ if (isDeployedTree(direct)) addTree(direct);
1504
+ for (const lvl2 of subdirs(lvl1)) {
1505
+ if (lvl2 === 'skills' || DEPLOYED_TREE_SCAN_SKIP.includes(lvl2)) continue;
1506
+ const nested = path.join(lvl1, lvl2, 'skills');
1507
+ if (isDeployedTree(nested)) addTree(nested);
1508
+ }
1509
+ }
1510
+
1511
+ return [...found.values()].sort();
1512
+ }
1513
+
1291
1514
  /**
1292
1515
  * Build the marker-wrapped block that is injected into a persona SKILL.md.
1293
1516
  * Kept as a single-source helper so the test assertions and the production
@@ -1420,10 +1643,16 @@ async function applyOnPremPhasePrefixToDeployedSkills(
1420
1643
  }
1421
1644
  if (personaPrefix.size === 0) return [];
1422
1645
 
1646
+ // Bug `antigravity-path-divergence` — the target set is derived FROM DISK,
1647
+ // not from TOOL_SKILL_DIRS. Walking a hard-coded registry list is exactly
1648
+ // how a populated `.agent/skills` tree went unpatched in an on-prem project
1649
+ // while `.claude`, `.agents` and `.cline` were patched: our registry had
1650
+ // never heard of it. Discovery is keyed on the personas we actually have a
1651
+ // prefix for, so a tree with none of them is not a target at all.
1423
1652
  const results = [];
1424
- for (const toolRel of TOOL_SKILL_DIRS) {
1653
+ const targetTrees = discoverDeployedSkillTrees(projectRoot, [...personaPrefix.keys()]);
1654
+ for (const toolRel of targetTrees) {
1425
1655
  const toolSkillsDir = path.join(projectRoot, toolRel);
1426
- if (!fs.existsSync(toolSkillsDir)) continue; // tool not installed
1427
1656
 
1428
1657
  for (const [skillDirName, prefix] of personaPrefix) {
1429
1658
  const skillDir = path.join(toolSkillsDir, skillDirName);
@@ -2767,10 +2996,18 @@ function getCandidateSkillDirNames(customizeFilename) {
2767
2996
 
2768
2997
  /**
2769
2998
  * Enumerate the IDE/BMAD tool skill directories that may contain deployed
2770
- * persona skills. Reads from `lib/agents.js` so the list stays in lockstep
2771
- * with the installer's own fan-out (Story 22.10). Returns absolute paths to
2772
- * directories that actually exist on disk; non-existent tool dirs are
2773
- * silently skipped (the tool was not selected for install).
2999
+ * persona skills. Returns absolute paths to directories that actually exist on
3000
+ * disk; non-existent tool dirs are silently skipped (the tool was not selected
3001
+ * for install).
3002
+ *
3003
+ * Two sources, unioned — bug `antigravity-path-divergence`:
3004
+ * 1. `lib/agents.js`, so the list stays in lockstep with the installer's own
3005
+ * fan-out (Story 22.10), including the `_bmad/skills/*` BMAD-persona roots
3006
+ * that the dot-container scan deliberately does not reach.
3007
+ * 2. `discoverDeployedSkillTrees()`, so a tree written by another pipeline that
3008
+ * our registry has never heard of is patched too. This is the same blind
3009
+ * spot that left a populated `.agent/skills` unpatched: a registry-only
3010
+ * derivation can only ever find the directories we already named.
2774
3011
  *
2775
3012
  * @param {string} projectRoot - Absolute path to the project root
2776
3013
  * @returns {string[]} Absolute skills-dir paths that exist
@@ -2785,6 +3022,9 @@ function getDeployedSkillsRoots(projectRoot) {
2785
3022
  roots.add(abs);
2786
3023
  }
2787
3024
  }
3025
+ for (const rel of discoverDeployedSkillTrees(projectRoot)) {
3026
+ roots.add(path.resolve(projectRoot, rel));
3027
+ }
2788
3028
  return [...roots];
2789
3029
  }
2790
3030
 
@@ -3479,6 +3719,9 @@ module.exports = {
3479
3719
  buildOnPremPrefixBlock,
3480
3720
  PERSONA_SKILL_MAP,
3481
3721
  TOOL_SKILL_DIRS,
3722
+ // Bug `antigravity-path-divergence` — disk-derived patch-target set
3723
+ discoverDeployedSkillTrees,
3724
+ DEPLOYED_TREE_PERSONA_MARKERS,
3482
3725
  ON_PREM_PREFIX_MARKER_BEGIN,
3483
3726
  ON_PREM_PREFIX_MARKER_END,
3484
3727
  // Offline-safe recompile helpers (exported for testing — see bug
@@ -3498,6 +3741,11 @@ module.exports = {
3498
3741
  getInstallableBmadModules,
3499
3742
  getInstalledBmadModules,
3500
3743
  resolveBmadModules,
3744
+ // FR123 (amended 2026-08-02) — the default INSTALL set, distinct from the
3745
+ // bundled set. See the long note on DEFAULT_BMAD_MODULE_IDS.
3746
+ DEFAULT_BMAD_MODULE_IDS,
3747
+ NON_DEFAULT_BMAD_MODULE_IDS,
3748
+ getDefaultBmadModules,
3501
3749
  // 3.12.2 — offline-fallback marker for bmad-method's cloneExternalModule
3502
3750
  ensureCacheMarker,
3503
3751
  };
package/lib/installer.js CHANGED
@@ -2261,9 +2261,28 @@ const OBSOLETE_TOOL_SKILL_DIRS = Object.freeze([
2261
2261
  // tool-specific paths to the shared .agents/skills/ directory.
2262
2262
  { obsolete: '.github/skills', replacedBy: '.agents/skills', sinceVersion: '3.8.1' },
2263
2263
  { obsolete: '.roo/skills', replacedBy: '.agents/skills', sinceVersion: '3.8.1' },
2264
- { obsolete: '.kilocode/skills', replacedBy: '.agents/skills', sinceVersion: '3.8.1' }
2264
+ { obsolete: '.kilocode/skills', replacedBy: '.agents/skills', sinceVersion: '3.8.1' },
2265
+ // Bug `antigravity-path-divergence`: ma-agents declared `.antigravity/skills`
2266
+ // while bmad-method writes Antigravity's skills to `.agent/skills`
2267
+ // (platform-codes.yaml, platform `antigravity`). A single install therefore
2268
+ // produced two trees, and the one the tool actually reads was patched by
2269
+ // nobody. lib/agents.js is now aligned to upstream; this sweeps the tree
2270
+ // ma-agents itself created on the old path — and only once `.agent/skills`
2271
+ // has content, per migrateObsoleteToolDirs' preserve-on-empty rule.
2272
+ // APPEND-ONLY: entries above are positional in test/obsolete-tool-dirs.test.js
2273
+ // (results[0] must stay the copilot entry).
2274
+ { obsolete: '.antigravity/skills', replacedBy: '.agent/skills', sinceVersion: '3.18.0' }
2265
2275
  ]);
2266
2276
 
2277
+ // Story 31.16 — the two bookkeeping files ma-agents itself writes into a skills
2278
+ // directory: `.ma-agents.json` (the per-install record, `writeManifest`) and
2279
+ // `MANIFEST.yaml` (derived from it, `generateSkillsManifest`). The obsolete-dir
2280
+ // sweep excludes these from its residue calculation, because neither is user
2281
+ // content and treating them as residue would make every manifested tree preserve
2282
+ // forever. Deliberately NOT exported: a test that took its expectation from this
2283
+ // constant would be asserting the implementation against itself.
2284
+ const OBSOLETE_SWEEP_MANIFEST_FILES = new Set([MANIFEST_FILE, 'MANIFEST.yaml']);
2285
+
2267
2286
  async function migrateRetiredSkills(options = {}) {
2268
2287
  const { scope = 'project', customPath = '' } = options;
2269
2288
  const results = [];
@@ -2360,11 +2379,20 @@ async function migrateRetiredSkills(options = {}) {
2360
2379
  * legacy directory exists under `projectRoot`. Behaviour per entry:
2361
2380
  *
2362
2381
  * - Obsolete dir absent → no-op (idempotent)
2363
- * - Obsolete dir present AND
2364
- * replacedBy dir has content → remove obsolete dir, log info message
2365
2382
  * - Obsolete dir present BUT
2366
2383
  * replacedBy dir is empty/gone → preserve obsolete dir, log warning
2367
2384
  * (user may have local edits; safer to keep)
2385
+ * - Obsolete dir present AND replacedBy dir has content → Story 31.16 decides
2386
+ * per child, using the obsolete dir's OWN `.ma-agents.json::skills` as the
2387
+ * record of what ma-agents installed there:
2388
+ * owned = manifest skill ids (each id IS a directory basename)
2389
+ * residue = readdir(obsolete) − owned − {.ma-agents.json, MANIFEST.yaml}
2390
+ * residue empty → remove the whole dir → action 'removed'
2391
+ * residue non-empty → remove owned children, KEEP → action 'preserved-partial'
2392
+ * the dir, and name the residue
2393
+ * No manifest, an unparseable one, or `skills: {}` all yield an empty
2394
+ * `owned`, so the whole directory is residue and is preserved. Unknown
2395
+ * provenance fails safe; nothing is special-cased.
2368
2396
  *
2369
2397
  * @param {string} projectRoot — absolute path to the project root (process.cwd() in production)
2370
2398
  * @returns {Promise<Array<{entry, action, obsoletePath, replacedByPath, error}>>}
@@ -2391,13 +2419,101 @@ async function migrateObsoleteToolDirs(projectRoot) {
2391
2419
  }
2392
2420
 
2393
2421
  if (replacedByHasContent) {
2394
- // Safe to remove — skills have been migrated to the new location.
2395
- await fs.remove(obsoletePath);
2396
- action = 'removed';
2397
- console.log(chalk.green(
2398
- ` - Removed legacy tool skills directory ${entry.obsolete} ` +
2399
- `(moved to ${entry.replacedBy} in v${entry.sinceVersion})`
2400
- ));
2422
+ // Story 31.16 — the sweep is manifest-aware. Removing the whole
2423
+ // directory here destroyed hand-authored skills that ma-agents never
2424
+ // installed and never migrated anywhere (bug
2425
+ // `obsolete-tool-dir-sweep-deletes-user-authored-skills`).
2426
+ //
2427
+ // The directory's own `.ma-agents.json::skills` is ma-agents' record of
2428
+ // exactly what it installed there, and a manifest key IS the on-disk
2429
+ // directory basename (`performInstall` installs to
2430
+ // `path.join(installPath, skillId)`). It is a POSITIVE record only:
2431
+ // manifest-recorded implies ma-agents installed it, but the converse
2432
+ // does NOT hold — many real trees carry no per-tree manifest at all,
2433
+ // because bmad-method's PluginResolver also writes skills into these
2434
+ // trees and writes no manifest. So anything unrecorded is content of
2435
+ // unknown provenance and is preserved. This deliberately
2436
+ // over-preserves: a stale directory costs the user the `rm -rf` we
2437
+ // already print, a deleted hand-written skill costs them the file.
2438
+ const obsoleteManifest = readManifest(obsoletePath);
2439
+ const owned = obsoleteManifest && obsoleteManifest.skills
2440
+ && typeof obsoleteManifest.skills === 'object'
2441
+ ? Object.keys(obsoleteManifest.skills)
2442
+ : [];
2443
+ const ownedSet = new Set(owned);
2444
+
2445
+ // The two manifest files are ma-agents' own bookkeeping. They are
2446
+ // EXCLUDED from residue rather than counted as owned: counting them as
2447
+ // residue would make every manifested tree preserve forever and kill
2448
+ // the cleanup outright. They are deleted only as part of a
2449
+ // whole-directory removal — on the preserve path they are left
2450
+ // byte-unchanged, so a second run recomputes the same `owned` set and
2451
+ // behaves identically to the first.
2452
+ //
2453
+ // `entries` is the single source of truth for what is actually on
2454
+ // disk here, and BOTH halves of the decision below are taken against
2455
+ // it. A manifest key is file CONTENT, never a path: it is only ever
2456
+ // matched against this listing, never joined onto `obsoletePath` and
2457
+ // trusted. See the removal loop below for why that matters.
2458
+ const entries = fs.readdirSync(obsoletePath);
2459
+ const residue = entries
2460
+ .filter(name => !ownedSet.has(name) && !OBSOLETE_SWEEP_MANIFEST_FILES.has(name));
2461
+
2462
+ if (residue.length === 0) {
2463
+ // Everything present is ma-agents' own — safe to remove, skills have
2464
+ // been migrated to the new location.
2465
+ await fs.remove(obsoletePath);
2466
+ action = 'removed';
2467
+ console.log(chalk.green(
2468
+ ` - Removed legacy tool skills directory ${entry.obsolete} ` +
2469
+ `(moved to ${entry.replacedBy} in v${entry.sinceVersion})`
2470
+ ));
2471
+ } else {
2472
+ // Something here is not ours. Remove only what the manifest records
2473
+ // AND what the directory listing actually contains, and keep the
2474
+ // directory, naming what caused the preservation.
2475
+ //
2476
+ // `entries.includes(id)` is not an optimisation — it is the safety
2477
+ // property. Joining a raw manifest key onto `obsoletePath` and
2478
+ // removing the result trusts file content as a filesystem path, and
2479
+ // two concrete destruction paths follow from that:
2480
+ //
2481
+ // 1. CASE. `residue` is computed with case-SENSITIVE set
2482
+ // membership, but `fs.remove` resolves case-INSENSITIVELY on
2483
+ // NTFS and APFS. An on-disk `Code-Review` against a manifest
2484
+ // key `code-review` is therefore reported as residue ("items
2485
+ // ma-agents did not install"), the directory is declared
2486
+ // preserved — and the user's directory is deleted in the same
2487
+ // breath. `entries.includes` is case-sensitive, so the two
2488
+ // halves of the decision can no longer disagree.
2489
+ //
2490
+ // 2. CONTAINMENT. A manifest key is not validated to be a
2491
+ // basename. A `skills` map holding `../../../victim` escapes
2492
+ // the obsolete directory entirely and deletes an unrelated
2493
+ // path under the project root — while still taking THIS
2494
+ // branch, because a key matching nothing in `readdir` counts
2495
+ // as neither owned-present nor residue. A name that came out
2496
+ // of `readdir(obsoletePath)` can only ever be a real, direct
2497
+ // child of that directory, so no traversal survives the
2498
+ // filter. This function's stated premise is that provenance is
2499
+ // unknown and must fail safe; "the manifest is normally
2500
+ // ma-agents-written" is not a defence inside it.
2501
+ //
2502
+ // A recorded id with no directory on disk is now an explicit no-op
2503
+ // — it is filtered out rather than being an incidentally harmless
2504
+ // `fs.remove` of a non-existent path — which keeps this idempotent.
2505
+ for (const skillId of owned.filter(id => entries.includes(id))) {
2506
+ await fs.remove(path.join(obsoletePath, skillId));
2507
+ }
2508
+ action = 'preserved-partial';
2509
+ console.log(chalk.yellow(
2510
+ ` ! Legacy tool skills directory ${entry.obsolete} was preserved ` +
2511
+ `because it contains ${residue.length} item(s) ma-agents did not install: ` +
2512
+ `${residue.join(', ')}. ` +
2513
+ `Remove it manually once you have confirmed there is nothing to keep: ` +
2514
+ `rm -rf ${entry.obsolete}`
2515
+ ));
2516
+ }
2401
2517
  } else {
2402
2518
  // Replacement is absent or empty — do not silently destroy user content.
2403
2519
  action = 'preserved';
@@ -2695,6 +2811,12 @@ module.exports = {
2695
2811
  getStatus,
2696
2812
  readManifest,
2697
2813
  ensureManifest,
2814
+ // Story 31.16 — exported so the obsolete-tool-dir tests can build their
2815
+ // fixtures through the REAL manifest writer instead of hand-writing the
2816
+ // `.ma-agents.json` shape. Behaviour unchanged; this is an export, not a new
2817
+ // code path. `readManifest` and `ensureManifest` were already exported for the
2818
+ // same reason.
2819
+ writeManifest,
2698
2820
  getInstalledSkillInfo,
2699
2821
  getManifestAgents,
2700
2822
  compareSemver,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ma-agents",
3
- "version": "3.18.0-beta.1",
3
+ "version": "3.18.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": {