ma-agents 3.5.6 → 3.6.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.
Files changed (53) hide show
  1. package/.ma-agents.json +10 -0
  2. package/AGENTS.md +97 -0
  3. package/MANIFEST.yaml +3 -0
  4. package/README.md +17 -0
  5. package/_bmad-output/implementation-artifacts/21-10-profile-reconfigure.md +30 -6
  6. package/_bmad-output/implementation-artifacts/21-11-profile-uninstall.md +2 -1
  7. package/_bmad-output/implementation-artifacts/21-2-universal-instruction-block-expansion.md +217 -62
  8. package/_bmad-output/implementation-artifacts/21-3-roomodes-template-bmad-modes.md +196 -73
  9. package/_bmad-output/implementation-artifacts/21-4-agents-md-template-opencode.md +242 -53
  10. package/_bmad-output/implementation-artifacts/21-5-clinerules-template-extension.md +180 -41
  11. package/_bmad-output/implementation-artifacts/21-6-onprem-layered-guardrails.md +250 -75
  12. package/_bmad-output/implementation-artifacts/21-7-bmad-persona-phase-prefix.md +221 -89
  13. package/_bmad-output/implementation-artifacts/21-8-vllm-reference-doc-readme.md +121 -63
  14. package/_bmad-output/implementation-artifacts/21-9-tests-validation.md +332 -61
  15. package/_bmad-output/implementation-artifacts/bug-bmad-recompile-fails-on-airgapped-network.md +112 -0
  16. package/_bmad-output/implementation-artifacts/sprint-status.yaml +3 -2
  17. package/bin/cli.js +59 -0
  18. package/docs/deployment/vllm-nemotron.md +130 -0
  19. package/lib/agents.js +17 -2
  20. package/lib/bmad-customize/bmm-analyst.customize.yaml +8 -0
  21. package/lib/bmad-customize/bmm-architect.customize.yaml +2 -0
  22. package/lib/bmad-customize/bmm-dev.customize.yaml +2 -0
  23. package/lib/bmad-customize/bmm-pm.customize.yaml +2 -0
  24. package/lib/bmad-customize/bmm-qa.customize.yaml +2 -0
  25. package/lib/bmad-customize/bmm-quick-flow-solo-dev.customize.yaml +8 -0
  26. package/lib/bmad-customize/bmm-sm.customize.yaml +2 -0
  27. package/lib/bmad-customize/bmm-tech-writer.customize.yaml +2 -0
  28. package/lib/bmad-customize/bmm-ux-designer.customize.yaml +2 -0
  29. package/lib/bmad.js +293 -1
  30. package/lib/installer.js +617 -43
  31. package/lib/merge/roomodes.js +125 -0
  32. package/lib/profile.js +25 -2
  33. package/lib/reconfigure.js +334 -0
  34. package/lib/templates/agents-md.template.md +67 -0
  35. package/lib/templates/clinerules.template.md +13 -0
  36. package/lib/templates/instruction-block-onprem.template.md +86 -0
  37. package/lib/templates/instruction-block-universal.template.md +29 -0
  38. package/lib/templates/roomodes.template.yaml +96 -0
  39. package/lib/uninstall.js +314 -0
  40. package/package.json +4 -3
  41. package/test/agents-md.test.js +398 -0
  42. package/test/bmad-extension.test.js +2 -2
  43. package/test/bmad-persona-phase-prefix.test.js +271 -0
  44. package/test/clinerules.test.js +339 -0
  45. package/test/instruction-block.test.js +388 -0
  46. package/test/integration-verification.test.js +2 -2
  47. package/test/migration-validation.test.js +2 -2
  48. package/test/offline-recompile.test.js +237 -0
  49. package/test/onprem-injection.test.js +425 -32
  50. package/test/onprem-layer.test.js +419 -0
  51. package/test/reconfigure.test.js +436 -0
  52. package/test/roomodes.test.js +343 -0
  53. package/test/uninstall.test.js +402 -0
package/lib/bmad.js CHANGED
@@ -4,6 +4,7 @@ const os = require('os');
4
4
  const { execSync } = require('child_process');
5
5
  const chalk = require('chalk');
6
6
  const yaml = require('yaml');
7
+ const jsYaml = require('js-yaml');
7
8
  const { withExperimentalWarningSuppressed } = require('./warning-filter');
8
9
 
9
10
  /**
@@ -61,6 +62,166 @@ function isBmadInstalled(projectRoot = process.cwd()) {
61
62
  return fs.existsSync(path.join(projectRoot, BMAD_DIR));
62
63
  }
63
64
 
65
+ /**
66
+ * Returns true when the operator has explicitly declared offline mode via env.
67
+ *
68
+ * Use `MA_AGENTS_OFFLINE=1` (or `true`) on air-gapped hosts so the installer
69
+ * skips network-dependent diagnostics and emits actionable guidance instead
70
+ * of bubbling a raw upstream `git fetch` failure.
71
+ *
72
+ * @returns {boolean}
73
+ */
74
+ function isOfflineModeDeclared() {
75
+ const flag = String(process.env.MA_AGENTS_OFFLINE || '').trim().toLowerCase();
76
+ return flag === '1' || flag === 'true' || flag === 'yes';
77
+ }
78
+
79
+ /**
80
+ * Heuristic: does the caught recompile error look like a network failure?
81
+ *
82
+ * bmad-method 6.2.2 always shells out to `git fetch` / `git clone` / `npm install`
83
+ * against external module repos. On an air-gapped host those commands fail with
84
+ * recognizable DNS/routing/git errors. We match on common substrings rather than
85
+ * exit codes because execSync collapses everything into a single Error.message.
86
+ *
87
+ * @param {Error} error - The error thrown from runCommand() during recompile
88
+ * @returns {boolean}
89
+ */
90
+ function looksLikeOfflineFailure(error) {
91
+ if (!error || !error.message) {
92
+ return false;
93
+ }
94
+ const haystack = `${error.message} ${error.stderr || ''} ${error.stdout || ''}`.toLowerCase();
95
+ const needles = [
96
+ 'enotfound',
97
+ 'getaddrinfo',
98
+ 'could not resolve host',
99
+ 'could not resolve',
100
+ 'network is unreachable',
101
+ 'connection refused',
102
+ 'connection timed out',
103
+ 'etimedout',
104
+ 'econnrefused',
105
+ 'econnreset',
106
+ 'unable to access',
107
+ 'failed to connect',
108
+ 'fatal: unable to',
109
+ 'git fetch',
110
+ 'git clone',
111
+ // Intentionally NOT matching bare 'proxy' / 'ssl' / 'certificate' —
112
+ // those words appear in benign error messages (e.g. YAML errors that
113
+ // reference certificate-generation skills) and would cause false
114
+ // positives that push non-network failures through the offline path.
115
+ ];
116
+ return needles.some(n => haystack.includes(n));
117
+ }
118
+
119
+ /**
120
+ * Inspect the vendored BMAD cache (post pre-population) and report which
121
+ * modules are present. Used to classify recompile failures: when the cache is
122
+ * intact we can safely downgrade an offline-mode failure to a warning.
123
+ *
124
+ * @param {string} [cacheRoot] - Override the cache dir (primarily for tests)
125
+ * @returns {{ present: string[], missing: string[], intact: boolean, cacheDir: string }}
126
+ */
127
+ function inspectBmadCache(cacheRoot) {
128
+ const cacheDir = cacheRoot || path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
129
+ const manifestPath = path.join(__dirname, 'bmad-cache', 'cache-manifest.json');
130
+
131
+ let expected = [];
132
+ try {
133
+ if (fs.existsSync(manifestPath)) {
134
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
135
+ expected = Object.keys(manifest.modules || {});
136
+ }
137
+ } catch {
138
+ // If the manifest is unreadable, treat the cache as not-intact so the
139
+ // caller surfaces a loud error rather than silently proceeding.
140
+ expected = [];
141
+ }
142
+
143
+ const present = [];
144
+ const missing = [];
145
+ for (const mod of expected) {
146
+ if (fs.existsSync(path.join(cacheDir, mod))) {
147
+ present.push(mod);
148
+ } else {
149
+ missing.push(mod);
150
+ }
151
+ }
152
+
153
+ return {
154
+ present,
155
+ missing,
156
+ intact: expected.length > 0 && missing.length === 0,
157
+ cacheDir,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Decide what to do when the BMAD recompile step throws.
163
+ *
164
+ * Classifies the failure and returns a structured diagnosis so the caller can:
165
+ * (a) 'warn' — downgrade to a yellow diagnostic and proceed
166
+ * (offline + cache intact)
167
+ * (b) 'error' — print an actionable red error listing missing modules
168
+ * and the remediation command (offline + cache incomplete)
169
+ * (c) 'rethrow' — the failure does not look offline-related; historically
170
+ * the caller in applyCustomizations() prints red and
171
+ * continues (we preserve that behaviour to avoid breaking
172
+ * the install pipeline when a non-network error happens
173
+ * mid-pipeline). Callers that want harder failure semantics
174
+ * can throw on `action === 'rethrow'` themselves.
175
+ *
176
+ * Keeping this pure/sync makes it trivial to unit-test without spawning
177
+ * subprocesses.
178
+ *
179
+ * Note on cache validity: we only verify module *directory presence*, not
180
+ * structural integrity. prePopulateBmadCache() runs immediately before
181
+ * recompile and performs structural repair (see its structurallyBroken
182
+ * check), so by the time this classifier is consulted the cache is either
183
+ * present-and-repaired or absent.
184
+ *
185
+ * @param {Error} error - The error thrown from runCommand()
186
+ * @param {Object} [opts]
187
+ * @param {Function} [opts.cacheInspector] - Override for testing
188
+ * @returns {{ action: 'warn' | 'error' | 'rethrow', message: string, missing?: string[] }}
189
+ */
190
+ function classifyRecompileFailure(error, opts = {}) {
191
+ const declaredOffline = isOfflineModeDeclared();
192
+ const looksOffline = looksLikeOfflineFailure(error);
193
+ const inspector = opts.cacheInspector || inspectBmadCache;
194
+
195
+ if (!declaredOffline && !looksOffline) {
196
+ return {
197
+ action: 'rethrow',
198
+ message: error && error.message ? error.message : 'unknown error',
199
+ };
200
+ }
201
+
202
+ const cache = inspector();
203
+
204
+ if (cache.intact) {
205
+ return {
206
+ action: 'warn',
207
+ message:
208
+ 'BMAD recompile reported a network-related failure, but the vendored cache is intact — ' +
209
+ `proceeding with cached modules (${cache.present.join(', ')}). ` +
210
+ 'Set MA_AGENTS_OFFLINE=1 on air-gapped hosts to silence this diagnostic.',
211
+ };
212
+ }
213
+
214
+ return {
215
+ action: 'error',
216
+ missing: cache.missing,
217
+ message:
218
+ 'BMAD recompile failed on an air-gapped host and the vendored cache is incomplete. ' +
219
+ `Missing modules: ${cache.missing.join(', ') || '(cache manifest unreadable)'}. ` +
220
+ 'Remediation: on a connected host run `npm run build:bmad-cache` then re-run the installer; ' +
221
+ `or copy a populated cache into ${cache.cacheDir}.`,
222
+ };
223
+ }
224
+
64
225
  /**
65
226
  * Build a complete bmad-method CLI command from an install context object.
66
227
  * Replaces ad-hoc string concatenation with a single authoritative builder.
@@ -314,6 +475,112 @@ async function updateBmad(modules = ['bmm', 'bmb'], tools = [], projectRoot = pr
314
475
  }
315
476
  }
316
477
 
478
+ /**
479
+ * Strip `phase` and `on_prem_phase_prefix` top-level keys from all deployed
480
+ * customize.yaml files in `configTargetDir`. Called for EVERY profile so that
481
+ * standard-profile deployments never contain the extension keys (NFR44).
482
+ *
483
+ * @param {string} configTargetDir Path to _bmad/_config/agents/
484
+ */
485
+ async function stripOnPremKeys(configTargetDir) {
486
+ if (!fs.existsSync(configTargetDir)) return;
487
+ const files = (await fs.readdir(configTargetDir)).filter(f => f.endsWith('.customize.yaml'));
488
+ for (const file of files) {
489
+ const filePath = path.join(configTargetDir, file);
490
+ let raw;
491
+ try {
492
+ raw = await fs.readFile(filePath, 'utf8');
493
+ } catch {
494
+ continue;
495
+ }
496
+ let doc;
497
+ try {
498
+ doc = jsYaml.load(raw);
499
+ } catch {
500
+ continue;
501
+ }
502
+ if (!doc || typeof doc !== 'object') continue;
503
+ if (!Object.prototype.hasOwnProperty.call(doc, 'phase') &&
504
+ !Object.prototype.hasOwnProperty.call(doc, 'on_prem_phase_prefix')) {
505
+ continue; // Nothing to strip — pre-Epic-21 file, leave untouched
506
+ }
507
+ delete doc.phase;
508
+ delete doc.on_prem_phase_prefix;
509
+ await fs.writeFile(filePath, jsYaml.dump(doc, { lineWidth: -1 }), 'utf8');
510
+ }
511
+ }
512
+
513
+ /**
514
+ * For on-prem profile: inject `on_prem_phase_prefix` as `critical_actions[0]`
515
+ * into each deployed customize.yaml that carries the extension keys, then strip
516
+ * those keys from the deployed file. Idempotent: the prefix is inserted once
517
+ * (the extension keys are removed afterwards so a second run is a no-op on the
518
+ * deployed file — `stripOnPremKeys` covers the second pass).
519
+ *
520
+ * Must be called AFTER the STAGE:CUSTOMIZE copy AND after `stripOnPremKeys`
521
+ * has already been called (or will be called immediately after on the same
522
+ * set of files). In practice we call stripOnPremKeys once at the end for all
523
+ * profiles, and applyOnPremPhasePrefix writes the merged file atomically.
524
+ *
525
+ * @param {string} customizeSourceDir lib/bmad-customize/ (source of truth)
526
+ * @param {string} configTargetDir _bmad/_config/agents/ (deployed location)
527
+ */
528
+ async function applyOnPremPhasePrefix(customizeSourceDir, configTargetDir) {
529
+ if (!fs.existsSync(configTargetDir)) return;
530
+ const files = (await fs.readdir(customizeSourceDir)).filter(f => f.endsWith('.customize.yaml'));
531
+ for (const file of files) {
532
+ const sourceFile = path.join(customizeSourceDir, file);
533
+ const targetFile = path.join(configTargetDir, file);
534
+ if (!fs.existsSync(targetFile)) continue;
535
+ let sourceDoc;
536
+ try {
537
+ sourceDoc = jsYaml.load(await fs.readFile(sourceFile, 'utf8'));
538
+ } catch {
539
+ continue;
540
+ }
541
+ if (!sourceDoc || typeof sourceDoc !== 'object') continue;
542
+ const prefix = sourceDoc.on_prem_phase_prefix;
543
+ if (!prefix || typeof prefix !== 'string' || !prefix.trim()) continue;
544
+ // Read the deployed file (may already be stripped if stripOnPremKeys ran first,
545
+ // or may still have the keys if we run in combined mode)
546
+ let deployedDoc;
547
+ try {
548
+ deployedDoc = jsYaml.load(await fs.readFile(targetFile, 'utf8'));
549
+ } catch {
550
+ continue;
551
+ }
552
+ if (!deployedDoc || typeof deployedDoc !== 'object') continue;
553
+ // Build final critical_actions: [prefix, ...original entries]
554
+ const originalActions = Array.isArray(deployedDoc.critical_actions)
555
+ ? deployedDoc.critical_actions
556
+ : [];
557
+ deployedDoc.critical_actions = [prefix, ...originalActions];
558
+ // Remove extension keys (idempotency: they were in the copy, we strip them now)
559
+ delete deployedDoc.phase;
560
+ delete deployedDoc.on_prem_phase_prefix;
561
+ await fs.writeFile(targetFile, jsYaml.dump(deployedDoc, { lineWidth: -1 }), 'utf8');
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Exported for testing without running the full installer.
567
+ * Applies post-deploy YAML rewriting for phase-aware persona prefixes.
568
+ *
569
+ * - Always strips `phase` and `on_prem_phase_prefix` from deployed files (NFR44).
570
+ * - When profile === 'on-prem', additionally injects the prefix as critical_actions[0].
571
+ *
572
+ * @param {string} customizeSourceDir lib/bmad-customize/
573
+ * @param {string} configTargetDir _bmad/_config/agents/
574
+ * @param {string} profile 'on-prem' | 'standard' | undefined
575
+ */
576
+ async function applyPersonaPhasePrefix(customizeSourceDir, configTargetDir, profile) {
577
+ if (profile === 'on-prem') {
578
+ await applyOnPremPhasePrefix(customizeSourceDir, configTargetDir);
579
+ } else {
580
+ await stripOnPremKeys(configTargetDir);
581
+ }
582
+ }
583
+
317
584
  async function applyCustomizations(projectRoot = process.cwd(), modules = ['bmm', 'bmb'], tools = [], selectedAgentIds = [], force = false, { userName = '', commLang = '', docLang = '', outputFolder = '' } = {}) {
318
585
  const sourceDir = path.join(__dirname, 'bmad-customizations');
319
586
  const workflowSourceDir = path.join(__dirname, 'bmad-workflows');
@@ -384,7 +651,20 @@ async function applyCustomizations(projectRoot = process.cwd(), modules = ['bmm'
384
651
  try {
385
652
  runCommand(command, { cwd: projectRoot, env: buildChildSpawnEnv() });
386
653
  } catch (error) {
387
- console.error(chalk.red(` BMAD recompile failed: ${error.message}`));
654
+ // Air-gapped / offline classification (bug-bmad-recompile-fails-on-airgapped-network).
655
+ // bmad-method 6.2.2 unconditionally performs `git fetch` / `git clone` / `npm install`
656
+ // against external module repos; on a disconnected host those calls throw and we used
657
+ // to swallow the error with a one-line red banner. Instead: classify the failure,
658
+ // downgrade to a warning when the vendored cache is intact, or surface an actionable
659
+ // error listing the missing modules and the remediation command.
660
+ const diagnosis = classifyRecompileFailure(error);
661
+ if (diagnosis.action === 'warn') {
662
+ console.warn(chalk.yellow(` BMAD recompile warning (offline): ${diagnosis.message}`));
663
+ } else if (diagnosis.action === 'error') {
664
+ console.error(chalk.red(` BMAD recompile failed: ${diagnosis.message}`));
665
+ } else {
666
+ console.error(chalk.red(` BMAD recompile failed: ${diagnosis.message}`));
667
+ }
388
668
  }
389
669
 
390
670
  // STAGE:EXTENSION — Deploy BMAD extension module (AFTER recompile — extensions/ survives recompile)
@@ -407,6 +687,8 @@ async function applyCustomizations(projectRoot = process.cwd(), modules = ['bmm'
407
687
  // These add critical_actions to the 8 built-in BMM agents (pm, architect, dev, qa,
408
688
  // sm, tech-writer, ux-designer, bmad-master) so they load project skills at startup.
409
689
  // Runs AFTER --custom-content (extension module) and BEFORE quick-update.
690
+ // Post-deploy: phase:/on_prem_phase_prefix: keys are stripped for standard profile;
691
+ // for on-prem profile the prefix is injected as critical_actions[0] instead.
410
692
  const customizeSource = path.join(__dirname, 'bmad-customize');
411
693
  if (fs.existsSync(customizeSource)) {
412
694
  await fs.ensureDir(configTargetDir);
@@ -415,6 +697,8 @@ async function applyCustomizations(projectRoot = process.cwd(), modules = ['bmm'
415
697
  await fs.copy(path.join(customizeSource, file), path.join(configTargetDir, file));
416
698
  console.log(chalk.cyan(` + Applied built-in customization: ${file}`));
417
699
  }
700
+ const profile = require('./profile').getProfile(projectRoot) ?? 'standard';
701
+ await applyPersonaPhasePrefix(customizeSource, configTargetDir, profile);
418
702
  }
419
703
 
420
704
  // STAGE:WORKFLOWS — Apply workflows (AFTER recompile so they persist)
@@ -1203,4 +1487,12 @@ module.exports = {
1203
1487
  cleanupLegacyArtifacts,
1204
1488
  parseCustomizeYaml,
1205
1489
  registerMlWorkflows,
1490
+ // Story 21.7 — phase-aware persona prefix (exported for testing)
1491
+ applyPersonaPhasePrefix,
1492
+ // Offline-safe recompile helpers (exported for testing — see bug
1493
+ // bug-bmad-recompile-fails-on-airgapped-network)
1494
+ isOfflineModeDeclared,
1495
+ looksLikeOfflineFailure,
1496
+ inspectBmadCache,
1497
+ classifyRecompileFailure,
1206
1498
  };