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
@@ -1,48 +1,441 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Story 21.9 — End-to-end installer harness (scaffolding; PENDING implementation).
3
+ * Story 21.9 — End-to-end NFR44/NFR46/NFR47 integration tests.
4
4
  *
5
- * When Story 21.9 is picked up, this file becomes the byte-for-byte fixture-diff
6
- * harness described in ACs #5 and #6 of the story. Today it is a no-op that exits
7
- * 0 so `npm test` stays green.
8
- *
9
- * Scaffolding committed in corrective-plan step 7 (PR #40 approximate).
10
- * Fixtures live under `test/fixtures/` see `test/fixtures/README.md`.
5
+ * AC coverage:
6
+ * (a) NFR44 — standard profile: no /no_think, str_replace_editor, ~/.claude/ anywhere
7
+ * (b) NFR44 — on-prem profile: the three literals ARE present in instruction files
8
+ * (c) NFR46 — two consecutive on-prem installs → byte-identical marker-block content
9
+ * (d) slug-collision bmad-dev overwritten; my-custom-mode preserved
10
+ * (e) NFR47 — .roomodes fileRegex patterns enforce BMAD mode file restrictions
11
+ * (f) AC #6 — profile round-trip: standard → on-prem → standard CLAUDE.md identical
12
+ * (g) persona — on-prem: bmm-pm critical_actions[0] contains /no_think (applyPersonaPhasePrefix)
13
+ * (h) docs — vllm deployment doc NOT stamped into target projects
11
14
  */
12
15
  'use strict';
13
16
 
14
- const fs = require('fs');
17
+ const assert = require('assert');
15
18
  const path = require('path');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const yaml = require('js-yaml');
16
22
 
17
- const FIXTURES_DIR = path.join(__dirname, 'fixtures');
18
- const STANDARD_BASELINE = path.join(FIXTURES_DIR, 'standard-profile-baseline');
19
- const ONPREM_BASELINE = path.join(FIXTURES_DIR, 'onprem-profile-baseline');
23
+ let passed = 0;
24
+ let failed = 0;
25
+ const errors = [];
20
26
 
21
- function baselineIsPopulated(dir) {
22
- if (!fs.existsSync(dir)) return false;
23
- const entries = fs.readdirSync(dir).filter(f => f !== '.gitkeep');
24
- return entries.length > 0;
27
+ async function test(name, fn) {
28
+ try {
29
+ await fn();
30
+ console.log(` \u2713 ${name}`);
31
+ passed++;
32
+ } catch (err) {
33
+ console.error(` \u2717 ${name}: ${err.stack || err.message}`);
34
+ failed++;
35
+ errors.push({ name, error: err.message });
36
+ }
25
37
  }
26
38
 
27
- const standardReady = baselineIsPopulated(STANDARD_BASELINE);
28
- const onPremReady = baselineIsPopulated(ONPREM_BASELINE);
39
+ // ── Imports ──────────────────────────────────────────────────────────────────
40
+ const {
41
+ _testUpdateAgentInstructions: updateAgentInstructions,
42
+ stampExtraInstructionTemplates,
43
+ composeInstructionBlock,
44
+ } = require('../lib/installer');
45
+ const { setProfile, getProfile } = require('../lib/profile');
46
+ const { applyPersonaPhasePrefix } = require('../lib/bmad');
47
+ const agents = require('../lib/agents');
29
48
 
30
- if (!standardReady && !onPremReady) {
31
- console.log('[onprem-injection.test] PENDING — baseline fixtures not yet captured.');
32
- console.log(' See test/fixtures/README.md for the Story 21.9 regeneration procedure.');
33
- console.log(' Exiting 0 (scaffold only).');
34
- process.exit(0);
49
+ // ── Helpers ───────────────────────────────────────────────────────────────────
50
+ function mktemp() {
51
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-21-9-'));
35
52
  }
36
53
 
37
- // Story 21.9 implementation will replace everything below this line with the real harness.
38
- // Until then, refuse to silently pass if only one baseline is populated — that's a red flag.
39
- if (standardReady !== onPremReady) {
40
- console.error('[onprem-injection.test] FAIL — inconsistent fixture state: one profile baseline is populated and the other is not.');
41
- console.error(' Either finish regenerating both, or remove the populated one. See test/fixtures/README.md.');
42
- process.exit(1);
54
+ function cleanup(dir) {
55
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
56
+ }
57
+
58
+ /** Extract the MA-AGENTS-START…MA-AGENTS-END block (inclusive) from text. */
59
+ function extractMarkerBlock(text) {
60
+ const start = text.indexOf('<!-- MA-AGENTS-START -->');
61
+ const end = text.indexOf('<!-- MA-AGENTS-END -->');
62
+ if (start < 0 || end < 0) return null;
63
+ return text.slice(start, end + '<!-- MA-AGENTS-END -->'.length);
64
+ }
65
+
66
+ /**
67
+ * Run a minimal install: inject claude-code instruction block + roo-code
68
+ * (which has extraInstructionTemplates that produce .roomodes).
69
+ * cwd is temporarily changed to projectRoot so relative-path resolution inside
70
+ * installer helpers stays consistent with the real CLI invocation.
71
+ */
72
+ async function runMinimalInstall(projectRoot, opts = {}) {
73
+ const cwdOriginal = process.cwd();
74
+ process.chdir(projectRoot);
75
+ try {
76
+ const claudeAgent = agents.getAgent('claude-code');
77
+ if (claudeAgent) {
78
+ await updateAgentInstructions(claudeAgent, projectRoot, opts);
79
+ }
80
+ const rooAgent = agents.getAgent('roo-code');
81
+ if (rooAgent) {
82
+ await updateAgentInstructions(rooAgent, projectRoot, opts);
83
+ if (Array.isArray(rooAgent.extraInstructionTemplates) && rooAgent.extraInstructionTemplates.length > 0) {
84
+ await stampExtraInstructionTemplates(rooAgent, projectRoot, opts);
85
+ }
86
+ }
87
+ } finally {
88
+ process.chdir(cwdOriginal);
89
+ }
90
+ }
91
+
92
+ // ── Test cases ────────────────────────────────────────────────────────────────
93
+
94
+ console.log('\n story 21.9 — end-to-end NFR44/NFR46/NFR47 integration tests\n');
95
+
96
+ async function runAll() {
97
+ // ── (a) Standard profile: no on-prem strings ─────────────────────────────
98
+
99
+ await test('standard profile: generated files contain no /no_think, str_replace_editor, or ~/.claude/', async () => {
100
+ const dir = mktemp();
101
+ try {
102
+ // Standard is the default — no setProfile call needed (--yes resolves to standard)
103
+ setProfile(dir, 'standard');
104
+ await runMinimalInstall(dir, { yesMode: true });
105
+
106
+ const claudeMd = path.join(dir, '.claude', 'CLAUDE.md');
107
+ assert.ok(fs.existsSync(claudeMd), 'CLAUDE.md must be created by standard install');
108
+ const content = fs.readFileSync(claudeMd, 'utf-8');
109
+
110
+ assert.ok(!content.includes('/no_think'), 'CLAUDE.md must not contain /no_think in standard profile (NFR44)');
111
+ assert.ok(!content.includes('str_replace_editor'), 'CLAUDE.md must not contain str_replace_editor in standard profile (NFR44)');
112
+ assert.ok(!content.includes('~/.claude/'), 'CLAUDE.md must not contain ~/.claude/ in standard profile (NFR44)');
113
+
114
+ // Also check .roomodes if it was created
115
+ const roomodes = path.join(dir, '.roomodes');
116
+ if (fs.existsSync(roomodes)) {
117
+ const rm = fs.readFileSync(roomodes, 'utf-8');
118
+ assert.ok(!rm.includes('/no_think'), '.roomodes must not contain /no_think in standard profile (NFR44)');
119
+ assert.ok(!rm.includes('str_replace_editor'), '.roomodes must not contain str_replace_editor in standard profile (NFR44)');
120
+ assert.ok(!rm.includes('~/.claude/'), '.roomodes must not contain ~/.claude/ in standard profile (NFR44)');
121
+ }
122
+ } finally { cleanup(dir); }
123
+ });
124
+
125
+ // ── (b) On-prem profile: expected on-prem strings present ─────────────────
126
+
127
+ await test('on-prem profile: instruction files contain on-prem guardrail strings', async () => {
128
+ const dir = mktemp();
129
+ try {
130
+ setProfile(dir, 'on-prem');
131
+ await runMinimalInstall(dir, { yesMode: true });
132
+
133
+ const claudeMd = path.join(dir, '.claude', 'CLAUDE.md');
134
+ assert.ok(fs.existsSync(claudeMd), 'CLAUDE.md must be created by on-prem install');
135
+ const content = fs.readFileSync(claudeMd, 'utf-8');
136
+
137
+ assert.ok(content.includes('/no_think'), 'CLAUDE.md must contain /no_think in on-prem profile');
138
+ assert.ok(content.includes('str_replace_editor'), 'CLAUDE.md must contain str_replace_editor in on-prem profile');
139
+ assert.ok(content.includes('~/.claude/'), 'CLAUDE.md must contain ~/.claude/ prohibition in on-prem profile');
140
+ } finally { cleanup(dir); }
141
+ });
142
+
143
+ // ── (c) NFR46: Idempotency — two consecutive installs produce byte-identical marker blocks ──
144
+
145
+ await test('NFR46: two consecutive installs with same profile produce byte-identical marker-block content', async () => {
146
+ const dir = mktemp();
147
+ try {
148
+ setProfile(dir, 'on-prem');
149
+ await runMinimalInstall(dir, { yesMode: true });
150
+
151
+ const claudeMd = path.join(dir, '.claude', 'CLAUDE.md');
152
+ assert.ok(fs.existsSync(claudeMd), 'CLAUDE.md created on first install');
153
+ const content1 = fs.readFileSync(claudeMd, 'utf-8');
154
+ const block1 = extractMarkerBlock(content1);
155
+ assert.ok(block1 !== null, 'marker block must be present after first install');
156
+
157
+ // Second install
158
+ await runMinimalInstall(dir, { yesMode: true });
159
+ const content2 = fs.readFileSync(claudeMd, 'utf-8');
160
+ const block2 = extractMarkerBlock(content2);
161
+ assert.ok(block2 !== null, 'marker block must be present after second install');
162
+
163
+ assert.strictEqual(block2, block1, 'marker-block content must be byte-identical across two consecutive installs (NFR46)');
164
+
165
+ // Also verify .roomodes if present
166
+ const roomodes = path.join(dir, '.roomodes');
167
+ if (fs.existsSync(roomodes)) {
168
+ const rm1 = fs.readFileSync(roomodes, 'utf-8');
169
+ await runMinimalInstall(dir, { yesMode: true });
170
+ const rm2 = fs.readFileSync(roomodes, 'utf-8');
171
+ assert.strictEqual(rm2, rm1, '.roomodes must be byte-identical across consecutive installs (NFR46)');
172
+ }
173
+ } finally { cleanup(dir); }
174
+ });
175
+
176
+ // ── (d) .roomodes slug-collision: ma-agents slugs overwrite; user slugs preserved ──
177
+
178
+ await test('slug-collision: ma-agents slugs overwrite; user slugs preserved', async () => {
179
+ const dir = mktemp();
180
+ try {
181
+ setProfile(dir, 'standard');
182
+
183
+ // Seed a .roomodes with one colliding slug and one non-colliding slug
184
+ const existingRoomodes = yaml.dump({
185
+ customModes: [
186
+ {
187
+ slug: 'bmad-dev',
188
+ name: 'User Custom Dev (should be overwritten)',
189
+ roleDefinition: 'user-defined role for dev',
190
+ groups: ['read']
191
+ },
192
+ {
193
+ slug: 'my-custom-mode',
194
+ name: 'My Custom Mode',
195
+ roleDefinition: 'a project-specific mode',
196
+ groups: ['read', 'edit']
197
+ }
198
+ ]
199
+ });
200
+ fs.writeFileSync(path.join(dir, '.roomodes'), existingRoomodes, 'utf-8');
201
+
202
+ const cwdOriginal = process.cwd();
203
+ process.chdir(dir);
204
+ try {
205
+ const rooAgent = agents.getAgent('roo-code');
206
+ if (rooAgent && Array.isArray(rooAgent.extraInstructionTemplates) && rooAgent.extraInstructionTemplates.length > 0) {
207
+ await stampExtraInstructionTemplates(rooAgent, dir);
208
+ }
209
+ } finally {
210
+ process.chdir(cwdOriginal);
211
+ }
212
+
213
+ const merged = yaml.load(fs.readFileSync(path.join(dir, '.roomodes'), 'utf-8'));
214
+ assert.ok(Array.isArray(merged.customModes), 'customModes must be an array');
215
+
216
+ const devMode = merged.customModes.find(m => m.slug === 'bmad-dev');
217
+ assert.ok(devMode, 'bmad-dev slug must be present');
218
+ assert.notStrictEqual(
219
+ devMode.name,
220
+ 'User Custom Dev (should be overwritten)',
221
+ 'bmad-dev must be overwritten with the ma-agents version'
222
+ );
223
+
224
+ const customMode = merged.customModes.find(m => m.slug === 'my-custom-mode');
225
+ assert.ok(customMode, 'my-custom-mode slug must be preserved');
226
+ assert.strictEqual(customMode.name, 'My Custom Mode', 'user slug name must be preserved byte-for-byte');
227
+ assert.strictEqual(customMode.roleDefinition, 'a project-specific mode', 'user slug roleDefinition must be preserved');
228
+ } finally { cleanup(dir); }
229
+ });
230
+
231
+ // ── (e) NFR47: .roomodes fileRegex matrix ─────────────────────────────────
232
+
233
+ await test('NFR47: .roomodes fileRegex patterns enforce BMAD mode file restrictions', async () => {
234
+ const dir = mktemp();
235
+ try {
236
+ setProfile(dir, 'standard');
237
+
238
+ const cwdOriginal = process.cwd();
239
+ process.chdir(dir);
240
+ try {
241
+ const rooAgent = agents.getAgent('roo-code');
242
+ if (rooAgent) {
243
+ await updateAgentInstructions(rooAgent, dir);
244
+ if (Array.isArray(rooAgent.extraInstructionTemplates) && rooAgent.extraInstructionTemplates.length > 0) {
245
+ await stampExtraInstructionTemplates(rooAgent, dir);
246
+ }
247
+ }
248
+ } finally {
249
+ process.chdir(cwdOriginal);
250
+ }
251
+
252
+ const roomodesPath = path.join(dir, '.roomodes');
253
+ assert.ok(fs.existsSync(roomodesPath), '.roomodes must be created by roo-code install');
254
+ const parsed = yaml.load(fs.readFileSync(roomodesPath, 'utf-8'));
255
+ assert.ok(Array.isArray(parsed.customModes), 'customModes must be an array');
256
+
257
+ const bySlug = Object.fromEntries(parsed.customModes.map(m => [m.slug, m]));
258
+
259
+ /**
260
+ * Extract the fileRegex from the 'edit' group entry if it has a config object.
261
+ * Returns a RegExp if found, null if bare 'edit' string (unrestricted).
262
+ */
263
+ function getEditFileRegex(mode) {
264
+ if (!mode || !Array.isArray(mode.groups)) return null;
265
+ for (const g of mode.groups) {
266
+ if (Array.isArray(g) && g[0] === 'edit' && g[1] && typeof g[1].fileRegex === 'string') {
267
+ return new RegExp(g[1].fileRegex);
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+
273
+ // bmad-pm: accepts .md, rejects .ts / .py / .js
274
+ const pmRegex = getEditFileRegex(bySlug['bmad-pm']);
275
+ assert.ok(pmRegex !== null, 'bmad-pm must have a fileRegex on edit group (NFR47)');
276
+ assert.ok(pmRegex.test('plan.md'), 'bmad-pm fileRegex must accept .md');
277
+ assert.ok(!pmRegex.test('app.ts'), 'bmad-pm fileRegex must reject .ts');
278
+ assert.ok(!pmRegex.test('script.py'), 'bmad-pm fileRegex must reject .py');
279
+ assert.ok(!pmRegex.test('index.js'), 'bmad-pm fileRegex must reject .js');
280
+
281
+ // bmad-architect: accepts .md / .xml / .drawio, rejects .ts / .py / .js
282
+ const archRegex = getEditFileRegex(bySlug['bmad-architect']);
283
+ assert.ok(archRegex !== null, 'bmad-architect must have a fileRegex on edit group (NFR47)');
284
+ assert.ok(archRegex.test('arch.md'), 'bmad-architect fileRegex must accept .md');
285
+ assert.ok(archRegex.test('diagram.drawio'), 'bmad-architect fileRegex must accept .drawio');
286
+ assert.ok(archRegex.test('model.xml'), 'bmad-architect fileRegex must accept .xml');
287
+ assert.ok(!archRegex.test('app.ts'), 'bmad-architect fileRegex must reject .ts');
288
+ assert.ok(!archRegex.test('script.py'), 'bmad-architect fileRegex must reject .py');
289
+
290
+ // bmad-techlead: accepts .md / .json / .yaml, rejects .ts / .py
291
+ const tlRegex = getEditFileRegex(bySlug['bmad-techlead']);
292
+ assert.ok(tlRegex !== null, 'bmad-techlead must have a fileRegex on edit group (NFR47)');
293
+ assert.ok(tlRegex.test('notes.md'), 'bmad-techlead fileRegex must accept .md');
294
+ assert.ok(tlRegex.test('package.json'), 'bmad-techlead fileRegex must accept .json');
295
+ assert.ok(tlRegex.test('config.yaml'), 'bmad-techlead fileRegex must accept .yaml');
296
+ assert.ok(!tlRegex.test('app.ts'), 'bmad-techlead fileRegex must reject .ts');
297
+ assert.ok(!tlRegex.test('script.py'), 'bmad-techlead fileRegex must reject .py');
298
+
299
+ // bmad-dev: no fileRegex restriction (full access — bare "edit" string in groups)
300
+ const devRegex = getEditFileRegex(bySlug['bmad-dev']);
301
+ assert.strictEqual(devRegex, null, 'bmad-dev must have NO fileRegex restriction (full edit access)');
302
+ const devGroups = bySlug['bmad-dev'].groups;
303
+ assert.ok(devGroups.includes('edit'), 'bmad-dev must have bare "edit" in groups');
304
+ } finally { cleanup(dir); }
305
+ });
306
+
307
+ // ── (f) AC #6: Profile switch round-trip ─────────────────────────────────
308
+
309
+ await test('AC #6 profile round-trip: standard → on-prem → standard produces byte-identical first and last CLAUDE.md', async () => {
310
+ const dir = mktemp();
311
+ try {
312
+ // Install 1: standard profile
313
+ setProfile(dir, 'standard');
314
+ await runMinimalInstall(dir, { yesMode: true });
315
+ const claudeMd = path.join(dir, '.claude', 'CLAUDE.md');
316
+ assert.ok(fs.existsSync(claudeMd), 'CLAUDE.md must exist after first install');
317
+ const content1 = fs.readFileSync(claudeMd, 'utf-8');
318
+
319
+ // Install 2: switch to on-prem
320
+ setProfile(dir, 'on-prem');
321
+ assert.strictEqual(getProfile(dir), 'on-prem', 'profile must be persisted as on-prem');
322
+ await runMinimalInstall(dir, { yesMode: true });
323
+ const content2 = fs.readFileSync(claudeMd, 'utf-8');
324
+ assert.ok(content2.includes('/no_think'), 'on-prem install must stamp /no_think into CLAUDE.md');
325
+
326
+ // Install 3: switch back to standard
327
+ setProfile(dir, 'standard');
328
+ assert.strictEqual(getProfile(dir), 'standard', 'profile must be restored to standard');
329
+ await runMinimalInstall(dir, { yesMode: true });
330
+ const content3 = fs.readFileSync(claudeMd, 'utf-8');
331
+
332
+ // Install 1 and Install 3 must be byte-identical
333
+ assert.strictEqual(
334
+ content3,
335
+ content1,
336
+ 'standard reinstall after on-prem must produce byte-identical CLAUDE.md to original standard install'
337
+ );
338
+
339
+ // Install 2 must have contained on-prem content
340
+ assert.ok(!content3.includes('/no_think'), 'final standard install must not contain /no_think');
341
+ } finally { cleanup(dir); }
342
+ });
343
+
344
+ // ── (g) On-prem persona: planning persona has /no_think prefix in critical_actions ──
345
+
346
+ await test('on-prem install: planning persona has /no_think prefix in critical_actions[0]', async () => {
347
+ const dir = mktemp();
348
+ try {
349
+ const CUSTOMIZE_SOURCE = path.join(__dirname, '..', 'lib', 'bmad-customize');
350
+ const jsYaml = require('js-yaml');
351
+
352
+ // Copy all customize yaml files into a temp "deployed" dir, simulating post-BMAD deploy
353
+ const deployedDir = path.join(dir, '_config');
354
+ fs.mkdirSync(deployedDir, { recursive: true });
355
+ const files = fs.readdirSync(CUSTOMIZE_SOURCE).filter(f => f.endsWith('.customize.yaml'));
356
+ assert.ok(files.length > 0, 'bmad-customize directory must contain at least one .customize.yaml file');
357
+ for (const file of files) {
358
+ fs.copyFileSync(path.join(CUSTOMIZE_SOURCE, file), path.join(deployedDir, file));
359
+ }
360
+
361
+ // Apply the on-prem phase prefix
362
+ await applyPersonaPhasePrefix(CUSTOMIZE_SOURCE, deployedDir, 'on-prem');
363
+
364
+ // Verify bmm-pm: planning persona — critical_actions[0] must contain /no_think
365
+ const pmPath = path.join(deployedDir, 'bmm-pm.customize.yaml');
366
+ assert.ok(fs.existsSync(pmPath), 'bmm-pm.customize.yaml must exist in deployed dir');
367
+ const pmDoc = jsYaml.load(fs.readFileSync(pmPath, 'utf-8'));
368
+ assert.ok(Array.isArray(pmDoc.critical_actions) && pmDoc.critical_actions.length > 0,
369
+ 'bmm-pm critical_actions must be non-empty after on-prem deploy');
370
+ assert.ok(
371
+ pmDoc.critical_actions[0].includes('/no_think'),
372
+ `bmm-pm critical_actions[0] must contain /no_think in on-prem profile; got: ${pmDoc.critical_actions[0]}`
373
+ );
374
+
375
+ // On-prem deployment must strip the phase: and on_prem_phase_prefix: keys
376
+ assert.ok(!Object.prototype.hasOwnProperty.call(pmDoc, 'phase'),
377
+ 'deployed bmm-pm must not contain phase: key (AC #8 Story 21.7)');
378
+ assert.ok(!Object.prototype.hasOwnProperty.call(pmDoc, 'on_prem_phase_prefix'),
379
+ 'deployed bmm-pm must not contain on_prem_phase_prefix: key (AC #8 Story 21.7)');
380
+ } finally { cleanup(dir); }
381
+ });
382
+
383
+ // ── (h) Installer non-regression: vllm deployment doc not stamped ─────────
384
+
385
+ await test('vllm deployment doc is not stamped into target projects', async () => {
386
+ const dir = mktemp();
387
+ try {
388
+ setProfile(dir, 'standard');
389
+ await runMinimalInstall(dir, { yesMode: true });
390
+
391
+ // Walk the entire temp dir and look for vllm artefacts
392
+ function walkDir(dirPath) {
393
+ const entries = [];
394
+ const items = fs.readdirSync(dirPath);
395
+ for (const item of items) {
396
+ const full = path.join(dirPath, item);
397
+ const stat = fs.statSync(full);
398
+ if (stat.isDirectory()) {
399
+ entries.push(...walkDir(full));
400
+ } else {
401
+ entries.push(full);
402
+ }
403
+ }
404
+ return entries;
405
+ }
406
+
407
+ const allFiles = walkDir(dir);
408
+
409
+ // No file matching *vllm-nemotron* should exist anywhere in the project
410
+ const vllmFiles = allFiles.filter(f => path.basename(f).includes('vllm-nemotron'));
411
+ assert.strictEqual(vllmFiles.length, 0,
412
+ `vllm-nemotron docs must not be stamped into target projects; found: ${vllmFiles.join(', ')}`);
413
+
414
+ // No file should contain 'vllm serve' content
415
+ for (const filePath of allFiles) {
416
+ try {
417
+ const content = fs.readFileSync(filePath, 'utf-8');
418
+ assert.ok(
419
+ !content.includes('vllm serve'),
420
+ `File ${path.relative(dir, filePath)} must not contain "vllm serve" command`
421
+ );
422
+ } catch {
423
+ // binary files — skip
424
+ }
425
+ }
426
+ } finally { cleanup(dir); }
427
+ });
428
+
429
+ // ── Summary ───────────────────────────────────────────────────────────────
430
+
431
+ console.log(`\n ${passed} passed, ${failed} failed\n`);
432
+ if (failed > 0) {
433
+ errors.forEach(e => console.error(` \u2717 ${e.name}: ${e.error}`));
434
+ process.exit(1);
435
+ }
43
436
  }
44
437
 
45
- // Both populated but the real harness is not yet wired — flag clearly.
46
- console.log('[onprem-injection.test] PENDING fixtures populated but harness implementation not yet landed (Story 21.9).');
47
- console.log(' Run `grep "Story 21.9" _bmad-output/implementation-artifacts/21-9-tests-validation.md` for scope.');
48
- process.exit(0);
438
+ runAll().catch(err => {
439
+ console.error('Unhandled error in test harness:', err);
440
+ process.exit(1);
441
+ });