ma-agents 3.13.0 → 3.13.2-skillname.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/installer.js CHANGED
@@ -166,7 +166,12 @@ function composeInstructionBlock({ profile, projectRoot } = {}) {
166
166
  composed += '\n\n' + onprem.replace(/\s+$/, '');
167
167
  }
168
168
 
169
- return composed + '\n';
169
+ // Bug 27.10 — the source templates are authored with CRLF line endings, but the
170
+ // segment seams and terminator above are hand-written LF. That mix yields a block
171
+ // whose body lines are CRLF while the joins are LF. Normalize to pure LF once here
172
+ // so the composed block is EOL-consistent regardless of how templates were checked
173
+ // out on Windows, and so downstream marker-merge (which assumes LF) stays stable.
174
+ return composed.replace(/\r\n/g, '\n') + '\n';
170
175
  }
171
176
 
172
177
  /**
@@ -245,6 +250,17 @@ function resolveBmadOutputDirs(projectRoot) {
245
250
  const CLAUDE_CODE_HOOK_ID = 'ma-agents-verify-manifest';
246
251
  const CLAUDE_CODE_SETTINGS_FILE = '.claude/settings.json';
247
252
 
253
+ // Bug 27.8 — the SessionStart hook must point at a path that exists in a real
254
+ // (npx/node_modules) install, not at the package's own `lib/` which is never
255
+ // copied into the target. The dependency-free hook script is copied into the
256
+ // target's `.claude/hooks/` and the command resolves it via $CLAUDE_PROJECT_DIR
257
+ // (the target project root). The legacy `lib/hooks/...` command string is kept
258
+ // here only so existing installs get it removed/migrated on the next run.
259
+ const CLAUDE_CODE_HOOK_SOURCE = path.join(__dirname, 'hooks', 'claude-code', 'verify-manifest.js');
260
+ const CLAUDE_CODE_HOOK_TARGET_REL = '.claude/hooks/verify-manifest.js';
261
+ const CLAUDE_CODE_HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR/${CLAUDE_CODE_HOOK_TARGET_REL}"`;
262
+ const CLAUDE_CODE_HOOK_LEGACY_COMMAND = `node "$CLAUDE_PROJECT_DIR/lib/hooks/claude-code/verify-manifest.js"`;
263
+
248
264
  function getPackageVersion() {
249
265
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
250
266
  return pkg.version;
@@ -397,13 +413,91 @@ function ensurePluginStageIgnored(projectRoot) {
397
413
  );
398
414
  }
399
415
 
416
+ // Bug 27.11 — the installer writes the `_bmad/` module tree which is
417
+ // "Installer-managed. Regenerated on every install — treat as read-only."
418
+ // Leaving it tracked guarantees churn and merge conflicts on every reinstall.
419
+ // This sister-policy to PLUGIN_STAGE_PATTERNS ensures `_bmad/` is gitignored.
420
+ //
421
+ // IMPORTANT: this targets ONLY the regenerated `_bmad/` tree. The
422
+ // `_bmad-output/` directory holds tracked project knowledge and is
423
+ // deliberately kept OUT of .gitignore (see ensureBmadOutputTracked) — the
424
+ // patterns below never match it because git treats `_bmad/` (trailing-slash
425
+ // anchored) and `_bmad-output` as distinct entries.
426
+ const BMAD_DIR_NAME = '_bmad';
427
+ const BMAD_DIR_PATTERNS = [
428
+ BMAD_DIR_NAME, // _bmad
429
+ `${BMAD_DIR_NAME}/`, // _bmad/
430
+ `/${BMAD_DIR_NAME}`, // /_bmad
431
+ `/${BMAD_DIR_NAME}/`, // /_bmad/
432
+ ];
433
+ const BMAD_DIR_CANONICAL_ENTRY = `${BMAD_DIR_NAME}/`;
434
+
435
+ /**
436
+ * Ensure `_bmad/` is present in the target project's `.gitignore`. Mirrors the
437
+ * append-only, idempotent, EOL-preserving contract of `ensurePluginStageIgnored`.
438
+ *
439
+ * - If `.gitignore` is absent, create it with the single canonical entry.
440
+ * - If any active (non-comment) line already matches any BMAD_DIR_PATTERNS
441
+ * variant, do nothing.
442
+ * - Otherwise, append `BMAD_DIR_CANONICAL_ENTRY` preserving existing EOL style.
443
+ *
444
+ * Defensive: no-op on falsy/non-string projectRoot, matching the sister helper.
445
+ *
446
+ * @param {string} projectRoot - Absolute path to the target project root.
447
+ */
448
+ function ensureBmadDirIgnored(projectRoot) {
449
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
450
+ return;
451
+ }
452
+
453
+ const gitignorePath = path.join(projectRoot, '.gitignore');
454
+
455
+ let content = '';
456
+ let fileExists = true;
457
+ try {
458
+ content = fs.readFileSync(gitignorePath, 'utf-8');
459
+ } catch (err) {
460
+ if (err.code !== 'ENOENT') throw err;
461
+ fileExists = false;
462
+ }
463
+
464
+ if (fileExists) {
465
+ const alreadyPresent = content.split(/\r?\n/).some(rawLine => {
466
+ const line = rawLine.trim();
467
+ if (!line || line.startsWith('#')) return false; // skip blanks & comments
468
+ return BMAD_DIR_PATTERNS.includes(line);
469
+ });
470
+ if (alreadyPresent) return; // idempotent no-op
471
+ }
472
+
473
+ const usesCrlf = fileExists && /\r\n/.test(content);
474
+ const eol = usesCrlf ? '\r\n' : '\n';
475
+
476
+ let next = content;
477
+ if (next && !next.endsWith('\n') && !next.endsWith('\r\n')) {
478
+ next += eol; // ensure the append lands on its own line
479
+ }
480
+ next += BMAD_DIR_CANONICAL_ENTRY + eol;
481
+
482
+ fs.writeFileSync(gitignorePath, next, 'utf-8');
483
+ console.log(
484
+ chalk.green(
485
+ `${BMAD_DIR_CANONICAL_ENTRY} added to .gitignore (installer-managed BMAD tree — regenerated on every install)`
486
+ )
487
+ );
488
+ }
489
+
400
490
  function ensureManifest(installPath, agentId, scope) {
401
491
  let manifest = readManifest(installPath);
402
492
  if (!manifest) {
493
+ // Bug 27.9 — when bootstrapped before any agent is known (e.g. profile
494
+ // bootstrap via setProfile → ensureManifest(root, null, 'project')), agentId
495
+ // is null. Mirror the migrate-branch guard so we never write the malformed
496
+ // `agent: null` / `agents: [null]` shape; an empty agents array is valid.
403
497
  manifest = {
404
498
  manifestVersion: MANIFEST_VERSION,
405
- agent: agentId,
406
- agents: [agentId],
499
+ agent: agentId || null,
500
+ agents: agentId ? [agentId] : [],
407
501
  scope: scope,
408
502
  skills: {}
409
503
  };
@@ -1228,10 +1322,14 @@ async function performInstall(skillId, skill, agent, installPath) {
1228
1322
  const frontmatterRegex = /^---\s*\n[\s\S]*?\n---\s*\n/;
1229
1323
  content = content.replace(frontmatterRegex, '');
1230
1324
 
1231
- // Inject YAML frontmatter from skill.json (single source of truth)
1325
+ // Inject YAML frontmatter. The Agent Skills spec (and strict validators such
1326
+ // as cline) require the frontmatter `name` to be the kebab-case skill id that
1327
+ // matches the SKILL.md's containing directory — NOT the human-readable display
1328
+ // name. We therefore inject `skillId` (the directory basename) here and keep
1329
+ // `skill.name` from skill.json as the display name used by listings/wizard UI.
1232
1330
  const frontmatter = [
1233
1331
  '---',
1234
- `name: ${skill.name}`,
1332
+ `name: ${skillId}`,
1235
1333
  `description: ${skill.description}`,
1236
1334
  '---',
1237
1335
  ''
@@ -1265,6 +1363,116 @@ async function performInstall(skillId, skill, agent, installPath) {
1265
1363
  return true;
1266
1364
  }
1267
1365
 
1366
+ // --- Bug 27.16 — .agents/skills pipeline-consistency reconciliation ----------
1367
+ //
1368
+ // Two pipelines write skills: ma-agents' own standalone loop (installSkill →
1369
+ // performInstall + generateSkillsManifest — the ONLY writer of MANIFEST.yaml),
1370
+ // and bmad-method's PluginResolver (via --custom-source) which routes
1371
+ // copilot/roo/kilo to the shared `.agents/skills/` dir. The standalone loop only
1372
+ // targets `.agents/skills` when a copilot/kilocode/roo-code agent is selected.
1373
+ //
1374
+ // When the user selects only claude-code/cline/opencode, BMAD still writes the
1375
+ // BMAD-family skills into `.agents/skills` (175 of them in the Workshop repro)
1376
+ // but the standalone catalog and MANIFEST.yaml are never written there — leaving
1377
+ // a dir that looks installed but is silently partial AND missing the
1378
+ // MANIFEST.yaml the stamped instruction block points agents at.
1379
+ //
1380
+ // This pass makes `.agents/skills` pipeline-consistent: if BMAD populated it but
1381
+ // no `.agents`-targeting agent was selected (so the standalone loop skipped it),
1382
+ // install the full standalone catalog there and generate MANIFEST.yaml. The set
1383
+ // of `.agents`-targeting agent ids is derived from agents whose project skills
1384
+ // dir resolves to `.agents/skills`.
1385
+
1386
+ const AGENTS_SHARED_SKILLS_DIRNAME = '.agents';
1387
+
1388
+ /**
1389
+ * Return the list of agent ids whose project-level skills dir is the shared
1390
+ * `.agents/skills/` directory (copilot, kilocode, roo-code). Derived from the
1391
+ * agent registry's `skillsDir` so a future registry change surfaces here.
1392
+ */
1393
+ function getAgentsSharedSkillAgentIds() {
1394
+ return listAgents()
1395
+ .filter(a => a.skillsDir === path.join(AGENTS_SHARED_SKILLS_DIRNAME, 'skills') ||
1396
+ a.skillsDir === `${AGENTS_SHARED_SKILLS_DIRNAME}/skills`)
1397
+ .map(a => a.id);
1398
+ }
1399
+
1400
+ /**
1401
+ * Bug 27.16 — reconcile a BMAD-populated `.agents/skills` directory that the
1402
+ * standalone loop never targeted. No-op unless ALL of:
1403
+ * - scope === 'project'
1404
+ * - `<projectRoot>/.agents/skills` exists on disk (BMAD created it)
1405
+ * - no `.agents`-targeting agent was among the selected agents
1406
+ * - the dir has no MANIFEST.yaml (the standalone-loop signature)
1407
+ *
1408
+ * When triggered, installs the full standalone catalog into `.agents/skills`
1409
+ * (using a representative `.agents`-targeting agent for template resolution),
1410
+ * records the skills into that dir's `.ma-agents.json`, and generates
1411
+ * MANIFEST.yaml — so the dir is never BMAD-only without a manifest.
1412
+ *
1413
+ * Idempotent: once MANIFEST.yaml exists the early-return fires.
1414
+ *
1415
+ * @param {string} projectRoot
1416
+ * @param {string[]} selectedAgentIds - agent ids the user selected this run
1417
+ * @param {string} scope - 'project' | 'global'
1418
+ * @returns {Promise<{reconciled: boolean, installed: number}>}
1419
+ */
1420
+ async function reconcileAgentsSharedSkills(projectRoot, selectedAgentIds = [], scope = 'project') {
1421
+ const result = { reconciled: false, installed: 0 };
1422
+ if (scope !== 'project') return result;
1423
+ if (typeof projectRoot !== 'string' || !projectRoot) return result;
1424
+
1425
+ const sharedDir = path.join(projectRoot, AGENTS_SHARED_SKILLS_DIRNAME, 'skills');
1426
+ if (!fs.existsSync(sharedDir)) return result; // BMAD never populated it
1427
+
1428
+ const sharedAgentIds = getAgentsSharedSkillAgentIds();
1429
+ const selectedSet = new Set(selectedAgentIds || []);
1430
+ const anySharedSelected = sharedAgentIds.some(id => selectedSet.has(id));
1431
+ if (anySharedSelected) return result; // standalone loop already targeted it
1432
+
1433
+ // If a MANIFEST.yaml already exists, the dir is not standalone-orphaned.
1434
+ if (fs.existsSync(path.join(sharedDir, 'MANIFEST.yaml'))) return result;
1435
+
1436
+ // Pick a representative `.agents`-targeting agent for template resolution.
1437
+ const repAgent = sharedAgentIds.map(id => getAgent(id)).find(Boolean);
1438
+ if (!repAgent) return result; // registry has no such agent — nothing to do
1439
+
1440
+ console.log(chalk.yellow(
1441
+ ` Note: ${sharedDir} was populated by BMAD but carries no MANIFEST.yaml ` +
1442
+ `(no .agents-targeting agent selected) — backfilling the standalone catalog (bug 27.16).`
1443
+ ));
1444
+
1445
+ const skills = listSkills();
1446
+ const manifest = ensureManifest(sharedDir, repAgent.id, scope);
1447
+ const now = new Date().toISOString();
1448
+
1449
+ for (const skill of skills) {
1450
+ try {
1451
+ const ok = await performInstall(skill.id, skill, repAgent, sharedDir);
1452
+ if (!ok) continue;
1453
+ const existing = manifest.skills[skill.id];
1454
+ manifest.skills[skill.id] = {
1455
+ version: skill.version,
1456
+ installedAt: existing ? existing.installedAt : now,
1457
+ updatedAt: now,
1458
+ installerVersion: getPackageVersion(),
1459
+ agentVersion: repAgent.version
1460
+ };
1461
+ result.installed++;
1462
+ } catch (err) {
1463
+ console.log(chalk.yellow(` x ${skill.id} skipped in .agents/skills backfill: ${err.message}`));
1464
+ }
1465
+ }
1466
+
1467
+ writeManifest(sharedDir, manifest);
1468
+ await generateSkillsManifest(sharedDir);
1469
+ result.reconciled = true;
1470
+ console.log(chalk.green(
1471
+ ` Backfilled ${result.installed} standalone skill(s) + MANIFEST.yaml into ${sharedDir}`
1472
+ ));
1473
+ return result;
1474
+ }
1475
+
1268
1476
  // --- Install with upgrade detection ---
1269
1477
 
1270
1478
  async function installSkill(skillId, agentIds, customPath = '', scope = 'project', options = {}) {
@@ -1882,7 +2090,12 @@ function getStatus(agentIds, customPath = '', scope = 'project') {
1882
2090
 
1883
2091
  for (const { path: checkPath, scope: checkScope } of pathsToCheck) {
1884
2092
  const manifest = readManifest(checkPath);
1885
- if (!manifest || !manifest.skills || Object.keys(manifest.skills).length === 0) {
2093
+ // Bug 27.12 never treat the PROFILE-ONLY project-root manifest as a
2094
+ // skill inventory. The per-agent skills manifests are authoritative; a
2095
+ // manifest carrying the `project-root` scope discriminator is a
2096
+ // profile/settings store and must be skipped here.
2097
+ if (!manifest || manifest.scope === 'project-root' ||
2098
+ !manifest.skills || Object.keys(manifest.skills).length === 0) {
1886
2099
  continue;
1887
2100
  }
1888
2101
 
@@ -1924,27 +2137,49 @@ async function deployClaudeCodeHook(projectRoot) {
1924
2137
  settings.hooks.SessionStart = [];
1925
2138
  }
1926
2139
 
1927
- // Check if our hook is already present
1928
- const hookCommand = `node "$CLAUDE_PROJECT_DIR/lib/hooks/claude-code/verify-manifest.js"`;
2140
+ // Bug 27.8 copy the self-contained hook into the target so the command
2141
+ // resolves in any install (npx/node_modules/dev-repo). The legacy command
2142
+ // string (which pointed at the never-copied package `lib/`) is migrated to
2143
+ // the new command on every run.
2144
+ const hookTargetPath = path.join(projectRoot, CLAUDE_CODE_HOOK_TARGET_REL);
2145
+ await fs.ensureDir(path.dirname(hookTargetPath));
2146
+ await fs.copy(CLAUDE_CODE_HOOK_SOURCE, hookTargetPath, { overwrite: true });
2147
+
2148
+ const hookCommand = CLAUDE_CODE_HOOK_COMMAND;
2149
+ let migrated = false;
2150
+ for (const group of settings.hooks.SessionStart) {
2151
+ if (!group.hooks) continue;
2152
+ for (const h of group.hooks) {
2153
+ if (h.command === CLAUDE_CODE_HOOK_LEGACY_COMMAND || h._id === CLAUDE_CODE_HOOK_ID) {
2154
+ h.command = hookCommand;
2155
+ h._id = CLAUDE_CODE_HOOK_ID;
2156
+ migrated = true;
2157
+ }
2158
+ }
2159
+ }
2160
+
1929
2161
  const alreadyInstalled = settings.hooks.SessionStart.some(group =>
1930
2162
  group.hooks && group.hooks.some(h => h.command === hookCommand)
1931
2163
  );
1932
2164
 
1933
- if (alreadyInstalled) {
1934
- return; // Already deployed
2165
+ if (!alreadyInstalled) {
2166
+ settings.hooks.SessionStart.push({
2167
+ matcher: 'startup',
2168
+ hooks: [
2169
+ {
2170
+ type: 'command',
2171
+ command: hookCommand,
2172
+ _id: CLAUDE_CODE_HOOK_ID
2173
+ }
2174
+ ]
2175
+ });
2176
+ } else if (!migrated) {
2177
+ // Hook entry already current and file copied — still ensure settings exist.
2178
+ await fs.ensureDir(path.dirname(settingsPath));
2179
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
2180
+ return;
1935
2181
  }
1936
2182
 
1937
- settings.hooks.SessionStart.push({
1938
- matcher: 'startup',
1939
- hooks: [
1940
- {
1941
- type: 'command',
1942
- command: hookCommand,
1943
- _id: CLAUDE_CODE_HOOK_ID
1944
- }
1945
- ]
1946
- });
1947
-
1948
2183
  await fs.ensureDir(path.dirname(settingsPath));
1949
2184
  await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
1950
2185
  console.log(chalk.cyan(' + Deployed Claude Code verify-manifest hook'));
@@ -1956,6 +2191,15 @@ async function deployClaudeCodeHook(projectRoot) {
1956
2191
  async function removeClaudeCodeHook(projectRoot) {
1957
2192
  const settingsPath = path.join(projectRoot, CLAUDE_CODE_SETTINGS_FILE);
1958
2193
 
2194
+ // Bug 27.8 — remove the copied hook file first, before any settings.json
2195
+ // early-return, so a missing/corrupt settings.json never orphans the file.
2196
+ const hookTargetPath = path.join(projectRoot, CLAUDE_CODE_HOOK_TARGET_REL);
2197
+ try {
2198
+ await fs.remove(hookTargetPath);
2199
+ } catch {
2200
+ // best-effort cleanup — never fail uninstall over a missing file
2201
+ }
2202
+
1959
2203
  if (!fs.existsSync(settingsPath)) return;
1960
2204
 
1961
2205
  let settings;
@@ -1967,10 +2211,15 @@ async function removeClaudeCodeHook(projectRoot) {
1967
2211
 
1968
2212
  if (!settings.hooks || !settings.hooks.SessionStart) return;
1969
2213
 
1970
- const hookCommand = `node "$CLAUDE_PROJECT_DIR/lib/hooks/claude-code/verify-manifest.js"`;
2214
+ // Match both the current command and the legacy package-relative command so
2215
+ // older installs get cleaned up too.
1971
2216
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter(group => {
1972
2217
  if (!group.hooks) return true;
1973
- group.hooks = group.hooks.filter(h => h.command !== hookCommand && h._id !== CLAUDE_CODE_HOOK_ID);
2218
+ group.hooks = group.hooks.filter(h =>
2219
+ h.command !== CLAUDE_CODE_HOOK_COMMAND &&
2220
+ h.command !== CLAUDE_CODE_HOOK_LEGACY_COMMAND &&
2221
+ h._id !== CLAUDE_CODE_HOOK_ID
2222
+ );
1974
2223
  return group.hooks.length > 0;
1975
2224
  });
1976
2225
 
@@ -1997,6 +2246,7 @@ module.exports = {
1997
2246
  listSkills,
1998
2247
  listAgents,
1999
2248
  installSkill,
2249
+ performInstall,
2000
2250
  uninstallSkill,
2001
2251
  getStatus,
2002
2252
  readManifest,
@@ -2009,6 +2259,9 @@ module.exports = {
2009
2259
  removeClaudeCodeHook,
2010
2260
  ensureBmadOutputTracked,
2011
2261
  ensurePluginStageIgnored,
2262
+ ensureBmadDirIgnored,
2263
+ reconcileAgentsSharedSkills,
2264
+ getAgentsSharedSkillAgentIds,
2012
2265
  generateSkillsManifest,
2013
2266
  generateProjectContext,
2014
2267
  generateRepoLayoutSection,
package/lib/profile.js CHANGED
@@ -23,6 +23,29 @@ const path = require('path');
23
23
  const MANIFEST_FILE = '.ma-agents.json';
24
24
  const VALID_PROFILES = ['on-prem', 'standard'];
25
25
 
26
+ // Bug 27.12 — the project-root .ma-agents.json is a PROFILE/settings store, not
27
+ // a skill inventory. Per-agent skills dirs (e.g. .claude/skills/.ma-agents.json)
28
+ // own the installed-skill inventory and remain the authoritative source read by
29
+ // getStatus/uninstall/reconcile. The root bootstrap therefore emits ONLY the
30
+ // fields it owns (manifestVersion + a scope discriminator) and NOT a `skills`/
31
+ // `agents` shape it would never back-fill — so the root file can no longer
32
+ // masquerade as an empty skill inventory (`skills: {}`).
33
+ const ROOT_MANIFEST_VERSION = '1.2.0';
34
+ const ROOT_SCOPE = 'project-root';
35
+
36
+ /**
37
+ * Bootstrap a fresh PROFILE-ONLY root manifest object (not persisted here).
38
+ * Intentionally omits `skills` and `agents`: those belong to per-agent skills
39
+ * manifests. The `scope: 'project-root'` discriminator makes the file's role
40
+ * explicit for any reader.
41
+ */
42
+ function bootstrapRootManifest() {
43
+ return {
44
+ manifestVersion: ROOT_MANIFEST_VERSION,
45
+ scope: ROOT_SCOPE,
46
+ };
47
+ }
48
+
26
49
  /**
27
50
  * Reads the persisted profile from .ma-agents.json at the given project root.
28
51
  * Returns undefined if the file does not exist, is malformed, or the "profile"
@@ -73,10 +96,12 @@ function setProfile(projectRoot, value) {
73
96
  }
74
97
 
75
98
  if (!manifest || typeof manifest !== 'object') {
76
- // Bootstrap via the standard schema (mirrors lib/installer.js ensureManifest shape).
77
- // Require lazily to avoid potential circular-import pitfalls at module load.
78
- const { ensureManifest } = require('./installer');
79
- manifest = ensureManifest(projectRoot, null, 'project');
99
+ // Bug 27.12 bootstrap a PROFILE-ONLY root manifest. Previously this used
100
+ // ensureManifest(root, null, 'project'), which stamped an empty `skills: {}`
101
+ // / `agents: []` shape the root file never owns or back-fills, making it
102
+ // look like an (empty) skill inventory. The per-agent skills manifests are
103
+ // the authoritative inventory; the root file stores profile/settings only.
104
+ manifest = bootstrapRootManifest();
80
105
  }
81
106
 
82
107
  // Migrate 1.1.0 (or missing version) → 1.2.0, since the "profile" field is 1.2.0-only.
@@ -127,4 +152,70 @@ function clearProfile(projectRoot) {
127
152
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
128
153
  }
129
154
 
130
- module.exports = { getProfile, setProfile, resolveProfile, clearProfile };
155
+ /**
156
+ * Bug 27.13 — durable BMAD-module selection.
157
+ *
158
+ * Reads the persisted BMAD module set from .ma-agents.json (the `bmadModules`
159
+ * field). Returns undefined when the file is absent/malformed or the field is
160
+ * missing, so callers can distinguish "never persisted" (→ install default set)
161
+ * from an explicit empty selection. A persisted value is always returned as an
162
+ * array of strings.
163
+ */
164
+ function getBmadModules(projectRoot) {
165
+ const manifestPath = path.join(projectRoot, MANIFEST_FILE);
166
+ if (!fs.existsSync(manifestPath)) return undefined;
167
+ let manifest;
168
+ try {
169
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
170
+ } catch {
171
+ return undefined;
172
+ }
173
+ if (!manifest || typeof manifest !== 'object') return undefined;
174
+ if (!Object.prototype.hasOwnProperty.call(manifest, 'bmadModules')) return undefined;
175
+ const value = manifest.bmadModules;
176
+ if (!Array.isArray(value)) return undefined;
177
+ // Normalize to a clean, de-duplicated array of non-empty strings.
178
+ return [...new Set(value.filter(m => typeof m === 'string' && m.length > 0))];
179
+ }
180
+
181
+ /**
182
+ * Bug 27.13 — persists the selected BMAD module set into .ma-agents.json,
183
+ * preserving all other fields. Creates the file via the standard ensureManifest
184
+ * bootstrap path if absent (mirroring setProfile). The choice then survives
185
+ * subsequent installs/reconfigures that do not re-pass --bmad-modules.
186
+ *
187
+ * @param {string} projectRoot
188
+ * @param {string[]} modules - module ids to persist (e.g. ['bmm','tea','bmb'])
189
+ */
190
+ function setBmadModules(projectRoot, modules) {
191
+ if (!Array.isArray(modules)) {
192
+ throw new Error(`setBmadModules expects an array of module ids (got ${JSON.stringify(modules)})`);
193
+ }
194
+ const cleaned = [...new Set(modules.filter(m => typeof m === 'string' && m.length > 0))];
195
+
196
+ const manifestPath = path.join(projectRoot, MANIFEST_FILE);
197
+ let manifest;
198
+ if (fs.existsSync(manifestPath)) {
199
+ try {
200
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
201
+ } catch {
202
+ manifest = null;
203
+ }
204
+ }
205
+ if (!manifest || typeof manifest !== 'object') {
206
+ // Bug 27.12 — profile-only root bootstrap (see setProfile / bootstrapRootManifest).
207
+ manifest = bootstrapRootManifest();
208
+ }
209
+
210
+ manifest.bmadModules = cleaned;
211
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
212
+ }
213
+
214
+ module.exports = {
215
+ getProfile,
216
+ setProfile,
217
+ resolveProfile,
218
+ clearProfile,
219
+ getBmadModules,
220
+ setBmadModules,
221
+ };
@@ -1,25 +1,25 @@
1
- ## Git Workflow (ALL file-changing tasks)
2
-
3
- These rules apply to EVERY task that modifies a tracked file. There is no
4
- "small fix exemption" — a one-line edit follows the same workflow as a feature.
5
-
6
- - **Worktree first.** Before changing any file, create a fresh isolated worktree
7
- on a new feature branch: `git worktree add ../<project>-<branch> -b <branch>`.
8
- The scripted form is `skills/git-workflow-skill/scripts/start-feature.sh`. All
9
- file-writing happens inside that worktree — never in the main working tree.
10
- - **Never commit directly to the trunk.** Do NOT commit to `main`, `dev`,
11
- `master`, or whatever the trunk branch is. Commits land only on your feature
12
- branch inside the worktree.
13
- - **Conventional Commits.** Every commit message uses a Conventional Commits
14
- prefix. The allowed prefixes are: `feat`, `fix`, `chore`, `docs`, `refactor`,
15
- `test`, `ci`.
16
- - **PR is the only path into the trunk.** Integrate exclusively by opening a pull
17
- request with `gh pr create`. Never merge directly and never auto-merge a PR
18
- without explicit human approval.
19
- - **Self-review before opening the PR.** Run the `code-review` skill on your own
20
- changes and address its findings before you open the PR.
21
- - **Clean up after merge.** Once the PR is merged, remove the worktree with
22
- `git worktree remove` and delete the feature branch.
23
-
24
- Full procedure: see `skills/git-workflow-skill/SKILL.md`, and the helper scripts
25
- `scripts/start-feature.sh` / `scripts/finish-feature.sh`.
1
+ ## Git Workflow (ALL file-changing tasks)
2
+
3
+ These rules apply to EVERY task that modifies a tracked file. There is no
4
+ "small fix exemption" — a one-line edit follows the same workflow as a feature.
5
+
6
+ - **Worktree first.** Before changing any file, create a fresh isolated worktree
7
+ on a new feature branch: `git worktree add ../<project>-<branch> -b <branch>`.
8
+ The scripted form is `skills/git-workflow-skill/scripts/start-feature.sh`. All
9
+ file-writing happens inside that worktree — never in the main working tree.
10
+ - **Never commit directly to the trunk.** Do NOT commit to `main`, `dev`,
11
+ `master`, or whatever the trunk branch is. Commits land only on your feature
12
+ branch inside the worktree.
13
+ - **Conventional Commits.** Every commit message uses a Conventional Commits
14
+ prefix. The allowed prefixes are: `feat`, `fix`, `chore`, `docs`, `refactor`,
15
+ `test`, `ci`.
16
+ - **PR is the only path into the trunk.** Integrate exclusively by opening a pull
17
+ request with `gh pr create`. Never merge directly and never auto-merge a PR
18
+ without explicit human approval.
19
+ - **Self-review before opening the PR.** Run the `code-review` skill on your own
20
+ changes and address its findings before you open the PR.
21
+ - **Clean up after merge.** Once the PR is merged, remove the worktree with
22
+ `git worktree remove` and delete the feature branch.
23
+
24
+ Full procedure: see `skills/git-workflow-skill/SKILL.md`, and the helper scripts
25
+ `scripts/start-feature.sh` / `scripts/finish-feature.sh`.