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
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Story 21.2 — Unit + integration tests for composeInstructionBlock and mergers.
4
+ *
5
+ * AC coverage:
6
+ * 5.1 Universal template reads and {{MANIFEST_PATH}} substitutes exactly once
7
+ * 5.2 standard profile returns universal-only (even when on-prem file exists)
8
+ * 5.3 on-prem profile appends on-prem content; throws when on-prem file missing
9
+ * 5.4 Idempotency — composer returns byte-identical output across calls
10
+ * 5.5 NFR44 — standard profile output lacks /no_think, str_replace_editor, ~/.claude/
11
+ * 5.6 Defensive — throws when universal template missing / no placeholder
12
+ * 5.7 Marker-block replace preserves content outside markers (two installs)
13
+ * 5.8 Fresh install writes universal block to markdown-merger agents
14
+ * 5.9 OpenCode json-merge receives composed string; other keys untouched;
15
+ * second install replaces (not duplicates) ma-agents entry
16
+ * 5.10 Upgrade-safety: clean/drifted --yes warning + backup; no backup for clean
17
+ * 5.11 Backup filename format matches the canonical <target>.backup-<ISO-timestamp>
18
+ */
19
+ 'use strict';
20
+
21
+ const assert = require('assert');
22
+ const path = require('path');
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ const errors = [];
29
+
30
+ async function test(name, fn) {
31
+ try {
32
+ await fn();
33
+ console.log(` \u2713 ${name}`);
34
+ passed++;
35
+ } catch (err) {
36
+ console.error(` \u2717 ${name}: ${err.stack || err.message}`);
37
+ failed++;
38
+ errors.push({ name, error: err.message });
39
+ }
40
+ }
41
+
42
+ const installerModule = require('../lib/installer');
43
+ const {
44
+ composeInstructionBlock,
45
+ buildBackupFilename,
46
+ formatBackupTimestamp,
47
+ _testUpdateAgentInstructions: updateAgentInstructions,
48
+ UNIVERSAL_INSTRUCTION_TEMPLATE_PATH,
49
+ ONPREM_INSTRUCTION_TEMPLATE_PATH
50
+ } = installerModule;
51
+ const { setProfile } = require('../lib/profile');
52
+ const agents = require('../lib/agents');
53
+
54
+ function mktemp() {
55
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-instr-block-test-'));
56
+ }
57
+
58
+ function cleanup(dir) {
59
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
60
+ }
61
+
62
+ console.log('\n instruction-block unit + integration tests (Story 21.2)\n');
63
+
64
+ async function runAll() {
65
+ // 5.1 — Universal template reads + {{MANIFEST_PATH}} substitution
66
+ await test('5.1 composer returns universal template containing {{MANIFEST_PATH}} exactly once', () => {
67
+ const dir = mktemp();
68
+ try {
69
+ const out = composeInstructionBlock({ profile: 'standard', projectRoot: dir });
70
+ const matches = out.match(/\{\{MANIFEST_PATH\}\}/g) || [];
71
+ assert.strictEqual(matches.length, 1, 'expected exactly one {{MANIFEST_PATH}} placeholder');
72
+ // Post-composition substitution is caller-owned, verify a literal replace works
73
+ const substituted = out.replace(/\{\{MANIFEST_PATH\}\}/g, '.claude/skills/MANIFEST.yaml');
74
+ assert.ok(substituted.includes('.claude/skills/MANIFEST.yaml'));
75
+ assert.ok(!substituted.includes('{{MANIFEST_PATH}}'));
76
+ } finally { cleanup(dir); }
77
+ });
78
+
79
+ // 5.2 — standard profile returns universal only even when on-prem template exists
80
+ await test('5.2 standard profile returns universal-only content', () => {
81
+ const dir = mktemp();
82
+ try {
83
+ // Universal template ships with the package; just verify profile='standard' returns it without any on-prem sentinel
84
+ const out = composeInstructionBlock({ profile: 'standard', projectRoot: dir });
85
+ assert.ok(out.includes('# AI Agent Skills - Planning Instruction'));
86
+ // We don't write to lib/templates/ from tests (guarded by AC #9 isolation rules).
87
+ // Instead we assert that standard output is independent of the on-prem file's existence.
88
+ const withoutOnprem = composeInstructionBlock({ profile: 'standard', projectRoot: dir });
89
+ assert.strictEqual(out, withoutOnprem);
90
+ } finally { cleanup(dir); }
91
+ });
92
+
93
+ // 5.3a — on-prem throws when on-prem template is absent (decision A: NO silent fallback)
94
+ await test('5.3a on-prem profile throws when instruction-block-onprem.template.md is missing', () => {
95
+ // ONPREM_INSTRUCTION_TEMPLATE_PATH is the canonical ship-path. If the package
96
+ // does not include the on-prem template yet (Story 21.6 ships it), the composer
97
+ // must throw the pinned error. This test skips itself if the file is already present.
98
+ if (fs.existsSync(ONPREM_INSTRUCTION_TEMPLATE_PATH)) {
99
+ console.log(' (skipped — on-prem template already present; verified in 5.3b)');
100
+ return;
101
+ }
102
+ assert.throws(
103
+ () => composeInstructionBlock({ profile: 'on-prem', projectRoot: mktemp() }),
104
+ /on-prem profile selected but instruction-block-onprem\.template\.md is missing/
105
+ );
106
+ });
107
+
108
+ // 5.3b — on-prem appends on-prem content when template file is present
109
+ await test('5.3b on-prem profile appends on-prem content when file present (simulated via temp template)', () => {
110
+ // To test without modifying ship-files, we write a sibling on-prem template to the
111
+ // canonical location only if absent, exercise the composer, and delete it afterward.
112
+ // This is the same tmp-file discipline as profile.test.js.
113
+ if (fs.existsSync(ONPREM_INSTRUCTION_TEMPLATE_PATH)) {
114
+ // File genuinely exists (Story 21.6 shipped) — exercise directly.
115
+ const out = composeInstructionBlock({ profile: 'on-prem', projectRoot: mktemp() });
116
+ assert.ok(out.includes('# AI Agent Skills - Planning Instruction'));
117
+ return;
118
+ }
119
+ const sentinel = '## [TEST] On-prem sentinel marker ONPREM-SENTINEL-12345';
120
+ fs.writeFileSync(ONPREM_INSTRUCTION_TEMPLATE_PATH, sentinel + '\n', 'utf-8');
121
+ try {
122
+ const out = composeInstructionBlock({ profile: 'on-prem', projectRoot: mktemp() });
123
+ assert.ok(out.includes('# AI Agent Skills - Planning Instruction'), 'universal content should be present');
124
+ assert.ok(out.includes('ONPREM-SENTINEL-12345'), 'on-prem sentinel should be appended');
125
+ // There should be exactly one blank line separating universal from on-prem
126
+ // i.e., the joined string contains "\n\n" between the last universal line and the first on-prem line.
127
+ const splitIdx = out.indexOf('ONPREM-SENTINEL');
128
+ assert.ok(splitIdx > 0);
129
+ const preceding = out.slice(0, splitIdx);
130
+ assert.ok(/\n\n[^\n]*ONPREM-SENTINEL/.test(out), 'expected single blank line between universal and on-prem');
131
+ void preceding;
132
+ } finally {
133
+ try { fs.unlinkSync(ONPREM_INSTRUCTION_TEMPLATE_PATH); } catch {}
134
+ }
135
+ });
136
+
137
+ // 5.4 — Idempotency
138
+ await test('5.4 composer is idempotent (byte-identical across calls)', () => {
139
+ const dir = mktemp();
140
+ try {
141
+ const a = composeInstructionBlock({ profile: 'standard', projectRoot: dir });
142
+ const b = composeInstructionBlock({ profile: 'standard', projectRoot: dir });
143
+ assert.strictEqual(a, b);
144
+ } finally { cleanup(dir); }
145
+ });
146
+
147
+ // 5.5 — NFR44: no local-LLM-specific strings in standard profile
148
+ await test('5.5 NFR44 — standard profile output lacks /no_think, str_replace_editor, ~/.claude/', () => {
149
+ const out = composeInstructionBlock({ profile: 'standard', projectRoot: mktemp() });
150
+ assert.ok(!out.includes('/no_think'), 'contains forbidden /no_think');
151
+ assert.ok(!out.includes('str_replace_editor'), 'contains forbidden str_replace_editor');
152
+ assert.ok(!out.includes('~/.claude/'), 'contains forbidden ~/.claude/');
153
+ });
154
+
155
+ // 5.6 — defensive throw
156
+ await test('5.6 composer throws when universal template is missing', () => {
157
+ // Temporarily rename the universal template to simulate absence.
158
+ const bak = UNIVERSAL_INSTRUCTION_TEMPLATE_PATH + '.bak-test';
159
+ fs.renameSync(UNIVERSAL_INSTRUCTION_TEMPLATE_PATH, bak);
160
+ try {
161
+ assert.throws(
162
+ () => composeInstructionBlock({ profile: 'standard', projectRoot: mktemp() }),
163
+ /universal instruction template not found/
164
+ );
165
+ } finally {
166
+ fs.renameSync(bak, UNIVERSAL_INSTRUCTION_TEMPLATE_PATH);
167
+ }
168
+ });
169
+
170
+ // 5.7 — Marker-block replace preserves surrounding content across two installs
171
+ await test('5.7 Marker-block replace preserves content outside markers (two installs)', async () => {
172
+ const dir = mktemp();
173
+ try {
174
+ setProfile(dir, 'standard');
175
+ const claudeMdPath = path.join(dir, '.claude', 'CLAUDE.md');
176
+ fs.mkdirSync(path.dirname(claudeMdPath), { recursive: true });
177
+ const before = '# My Project\n\nUser notes above.\n';
178
+ const after = '\n## Trailing section\n\nMore notes.\n';
179
+ // Write a file with pre-existing markers wrapping old content
180
+ const preMarker = '<!-- MA-AGENTS-START -->\nOLD STALE CONTENT\n<!-- MA-AGENTS-END -->';
181
+ fs.writeFileSync(claudeMdPath, before + preMarker + after, 'utf-8');
182
+
183
+ const agent = agents.getAgent('claude-code');
184
+ await updateAgentInstructions(agent, dir);
185
+ const first = fs.readFileSync(claudeMdPath, 'utf-8');
186
+ assert.ok(first.startsWith(before), 'pre-marker content byte-preserved');
187
+ assert.ok(first.endsWith(after), 'post-marker content byte-preserved');
188
+ assert.ok(!first.includes('OLD STALE CONTENT'));
189
+
190
+ await updateAgentInstructions(agent, dir);
191
+ const second = fs.readFileSync(claudeMdPath, 'utf-8');
192
+ assert.strictEqual(first, second, 'idempotent: second install byte-identical to first');
193
+ } finally { cleanup(dir); }
194
+ });
195
+
196
+ // 5.8 — Fresh install writes universal block for each markdown-merger agent
197
+ await test('5.8 Fresh install writes universal block to markdown-merger agents', async () => {
198
+ const dir = mktemp();
199
+ try {
200
+ setProfile(dir, 'standard');
201
+ const agent = agents.getAgent('claude-code');
202
+ const cwdOriginal = process.cwd();
203
+ process.chdir(dir);
204
+ try {
205
+ await updateAgentInstructions(agent, dir);
206
+ } finally {
207
+ process.chdir(cwdOriginal);
208
+ }
209
+ const p = path.join(dir, '.claude', 'CLAUDE.md');
210
+ assert.ok(fs.existsSync(p), 'instruction file created');
211
+ const content = fs.readFileSync(p, 'utf-8');
212
+ assert.ok(content.includes('<!-- MA-AGENTS-START -->'));
213
+ assert.ok(content.includes('<!-- MA-AGENTS-END -->'));
214
+ assert.ok(content.includes('# AI Agent Skills - Planning Instruction'));
215
+ assert.ok(content.includes('Respond in TEXT vs. create FILES'), 'universal rule section present');
216
+ assert.ok(content.includes('BMAD phase discipline'), 'phase-discipline section present');
217
+ } finally { cleanup(dir); }
218
+ });
219
+
220
+ // 5.9 — OpenCode json-merge path (NFR18)
221
+ await test('5.9 OpenCode json-merge receives composed string; other keys untouched; re-install replaces entry', async () => {
222
+ const dir = mktemp();
223
+ try {
224
+ setProfile(dir, 'standard');
225
+ const opencode = agents.getAgent('opencode');
226
+ if (!opencode) {
227
+ console.log(' (skipped — opencode agent not registered in lib/agents.js)');
228
+ return;
229
+ }
230
+ // Seed opencode.json with a user entry and an unrelated key to guard NFR18.
231
+ const filePath = path.join(dir, opencode.instructionFiles[0]);
232
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
233
+ fs.writeFileSync(filePath, JSON.stringify({
234
+ instructions: ['user-kept-entry'],
235
+ somethingElse: { keep: true, count: 42 }
236
+ }, null, 2), 'utf-8');
237
+
238
+ const cwdOriginal = process.cwd();
239
+ process.chdir(dir);
240
+ try {
241
+ await updateAgentInstructions(opencode, dir);
242
+ } finally {
243
+ process.chdir(cwdOriginal);
244
+ }
245
+
246
+ let data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
247
+ assert.deepStrictEqual(data.somethingElse, { keep: true, count: 42 }, 'unrelated keys untouched');
248
+ assert.ok(Array.isArray(data.instructions));
249
+ assert.ok(data.instructions.includes('user-kept-entry'), 'user entry preserved');
250
+ const maEntries = data.instructions.filter(e => typeof e === 'string' && e.startsWith('[ma-agents]'));
251
+ assert.strictEqual(maEntries.length, 1, 'exactly one ma-agents entry');
252
+ assert.ok(maEntries[0].includes('# AI Agent Skills - Planning Instruction'));
253
+ assert.ok(maEntries[0].includes('Respond in TEXT vs. create FILES'));
254
+ // Placeholder must have been substituted AFTER composition
255
+ assert.ok(!maEntries[0].includes('{{MANIFEST_PATH}}'));
256
+
257
+ // Re-install — should replace, not duplicate
258
+ process.chdir(dir);
259
+ try {
260
+ await updateAgentInstructions(opencode, dir);
261
+ } finally {
262
+ process.chdir(cwdOriginal);
263
+ }
264
+ data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
265
+ const maEntries2 = data.instructions.filter(e => typeof e === 'string' && e.startsWith('[ma-agents]'));
266
+ assert.strictEqual(maEntries2.length, 1, 'still exactly one ma-agents entry after re-install');
267
+ assert.ok(data.instructions.includes('user-kept-entry'), 'user entry still preserved');
268
+ } finally { cleanup(dir); }
269
+ });
270
+
271
+ // 5.10a — Clean re-install: no warning, no backup
272
+ await test('5.10a Upgrade-safety — clean marker block produces no backup on re-install', async () => {
273
+ const dir = mktemp();
274
+ try {
275
+ setProfile(dir, 'standard');
276
+ const agent = agents.getAgent('claude-code');
277
+ await updateAgentInstructions(agent, dir);
278
+ const targetPath = path.join(dir, '.claude', 'CLAUDE.md');
279
+ const siblingBefore = fs.readdirSync(path.dirname(targetPath));
280
+ await updateAgentInstructions(agent, dir);
281
+ const siblingAfter = fs.readdirSync(path.dirname(targetPath));
282
+ const backupsBefore = siblingBefore.filter(n => n.startsWith('CLAUDE.md.backup-'));
283
+ const backupsAfter = siblingAfter.filter(n => n.startsWith('CLAUDE.md.backup-'));
284
+ assert.strictEqual(backupsBefore.length, 0);
285
+ assert.strictEqual(backupsAfter.length, 0, 'clean re-install must not create a backup');
286
+ } finally { cleanup(dir); }
287
+ });
288
+
289
+ // 5.10b — Hand-edited marker block with yesMode: WARNING emitted + backup file created
290
+ await test('5.10b Upgrade-safety — hand-edited block + yesMode emits WARNING and writes backup', async () => {
291
+ const dir = mktemp();
292
+ try {
293
+ setProfile(dir, 'standard');
294
+ const agent = agents.getAgent('claude-code');
295
+ // Seed an existing file with a hand-edited marker block
296
+ const targetPath = path.join(dir, '.claude', 'CLAUDE.md');
297
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
298
+ const handEdited = '<!-- MA-AGENTS-START -->\nHAND EDITED STALE CONTENT\n<!-- MA-AGENTS-END -->';
299
+ fs.writeFileSync(targetPath, 'prefix\n' + handEdited + '\nsuffix\n', 'utf-8');
300
+
301
+ // Capture console.log to verify WARNING line
302
+ const origLog = console.log;
303
+ let captured = '';
304
+ console.log = (...args) => { captured += args.join(' ') + '\n'; };
305
+ // Force yesMode via env flag honored by the drift handler
306
+ const origYes = process.env.MA_AGENTS_YES;
307
+ process.env.MA_AGENTS_YES = '1';
308
+ try {
309
+ await updateAgentInstructions(agent, dir);
310
+ } finally {
311
+ console.log = origLog;
312
+ if (origYes === undefined) delete process.env.MA_AGENTS_YES;
313
+ else process.env.MA_AGENTS_YES = origYes;
314
+ }
315
+
316
+ assert.ok(
317
+ captured.includes('WARNING: ma-agents marker-block content modified since last install'),
318
+ 'pinned WARNING line should be emitted'
319
+ );
320
+ assert.ok(captured.includes('backed up to'), 'WARNING must name the backup path');
321
+
322
+ const siblings = fs.readdirSync(path.dirname(targetPath));
323
+ const backups = siblings.filter(n => n.startsWith('CLAUDE.md.backup-'));
324
+ assert.strictEqual(backups.length, 1, 'exactly one backup created');
325
+ const backupContent = fs.readFileSync(path.join(path.dirname(targetPath), backups[0]), 'utf-8');
326
+ // Backup contains ONLY the marker-block region including marker lines
327
+ assert.strictEqual(backupContent, handEdited, 'backup contains only the marker-block region');
328
+
329
+ // Prefix/suffix outside markers preserved after overwrite
330
+ const afterContent = fs.readFileSync(targetPath, 'utf-8');
331
+ assert.ok(afterContent.startsWith('prefix\n'), 'prefix preserved');
332
+ assert.ok(afterContent.endsWith('suffix\n'), 'suffix preserved');
333
+ assert.ok(!afterContent.includes('HAND EDITED STALE CONTENT'));
334
+ } finally { cleanup(dir); }
335
+ });
336
+
337
+ // 5.10c — opts.yesMode (passed by cli.js --yes) takes precedence over absent env var
338
+ await test('5.10c Upgrade-safety — opts.yesMode bypasses interactive prompt without env var', async () => {
339
+ const dir = mktemp();
340
+ try {
341
+ setProfile(dir, 'standard');
342
+ const agent = agents.getAgent('claude-code');
343
+ const targetPath = path.join(dir, '.claude', 'CLAUDE.md');
344
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
345
+ fs.writeFileSync(targetPath, 'prefix\n<!-- MA-AGENTS-START -->\nDRIFT\n<!-- MA-AGENTS-END -->\nsuffix\n', 'utf-8');
346
+
347
+ // Ensure env var is NOT set
348
+ const origYes = process.env.MA_AGENTS_YES;
349
+ delete process.env.MA_AGENTS_YES;
350
+ const origLog = console.log;
351
+ let captured = '';
352
+ console.log = (...args) => { captured += args.join(' ') + '\n'; };
353
+ try {
354
+ await updateAgentInstructions(agent, dir, { yesMode: true });
355
+ } finally {
356
+ console.log = origLog;
357
+ if (origYes !== undefined) process.env.MA_AGENTS_YES = origYes;
358
+ }
359
+ assert.ok(
360
+ captured.includes('WARNING: ma-agents marker-block content modified since last install'),
361
+ 'opts.yesMode should route through non-interactive WARNING path'
362
+ );
363
+ const backups = fs.readdirSync(path.dirname(targetPath)).filter(n => n.startsWith('CLAUDE.md.backup-'));
364
+ assert.strictEqual(backups.length, 1, 'backup written via opts.yesMode path');
365
+ } finally { cleanup(dir); }
366
+ });
367
+
368
+ // 5.11 — Backup filename format matches canonical
369
+ await test('5.11 buildBackupFilename produces <target>.backup-<ISO-timestamp> with hyphens not colons', () => {
370
+ const fixed = new Date('2026-04-15T12:30:00.000Z');
371
+ const name = buildBackupFilename('.roomodes', fixed);
372
+ assert.strictEqual(name, '.roomodes.backup-2026-04-15T12-30-00Z');
373
+ assert.ok(!name.includes(':'), 'no colons allowed (Windows filename safety)');
374
+ const ts = formatBackupTimestamp(fixed);
375
+ assert.strictEqual(ts, '2026-04-15T12-30-00Z');
376
+ });
377
+
378
+ console.log(`\n ${passed} passed, ${failed} failed\n`);
379
+ if (failed > 0) {
380
+ errors.forEach(e => console.error(` ${e.name}: ${e.error}`));
381
+ process.exit(1);
382
+ }
383
+ }
384
+
385
+ runAll().catch(err => {
386
+ console.error('Unhandled error:', err);
387
+ process.exit(1);
388
+ });
@@ -184,11 +184,11 @@ console.log('\nTask 1 — End-to-end IDE agent test');
184
184
  `lib/bmad-extension/agents/ must have 0 .customize.yaml files after Story 15.6, found ${agentFiles.length}`);
185
185
  }
186
186
 
187
- // 8 built-in customize.yaml files now in lib/bmad-customize/
187
+ // 10 built-in customize.yaml files now in lib/bmad-customize/
188
188
  const customizeDir = path.join(__dirname, '..', 'lib', 'bmad-customize');
189
189
  assert.ok(await fs.pathExists(customizeDir), 'lib/bmad-customize/ must exist');
190
190
  const files = (await fs.readdir(customizeDir)).filter(f => f.endsWith('.customize.yaml'));
191
- assert.strictEqual(files.length, 8, `Expected 8 .customize.yaml files in bmad-customize/, found ${files.length}`);
191
+ assert.strictEqual(files.length, 10, `Expected 10 .customize.yaml files in bmad-customize/, found ${files.length}`);
192
192
  });
193
193
 
194
194
  // Task 2.2 (continued): Verify deployment via fs.copy
@@ -438,10 +438,10 @@ test('6.2: bmad.js applyCustomizations is exported', () => {
438
438
  assert.ok(typeof bmad.applyCustomizations === 'function', 'applyCustomizations must be exported');
439
439
  });
440
440
 
441
- test('6.3: lib/bmad-customize/ has exactly 8 built-in agent files', () => {
441
+ test('6.3: lib/bmad-customize/ has exactly 10 built-in agent files', () => {
442
442
  assert.ok(fs.existsSync(CUSTOMIZE_DIR), 'lib/bmad-customize/ must exist');
443
443
  const files = fs.readdirSync(CUSTOMIZE_DIR).filter(f => f.endsWith('.customize.yaml'));
444
- assert.strictEqual(files.length, 8, `Expected 8 built-in agent files, found ${files.length}`);
444
+ assert.strictEqual(files.length, 10, `Expected 10 built-in agent files, found ${files.length}`);
445
445
  });
446
446
 
447
447
  const BUILTIN_AGENTS = [
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regression tests for bug-bmad-recompile-fails-on-airgapped-network.
4
+ *
5
+ * bmad-method 6.2.2's installer unconditionally performs `git fetch` /
6
+ * `git clone` / `npm install` against external module repos. On an
7
+ * air-gapped host those commands fail and lib/bmad.js used to swallow the
8
+ * error with a one-line red banner ("BMAD recompile failed: ..."). These
9
+ * tests lock in the new offline-safe classifier behaviour:
10
+ *
11
+ * - Offline declared + cache intact -> WARN (downgrade)
12
+ * - Offline declared + cache missing -> ERROR (actionable remediation)
13
+ * - Not offline and error doesn't look offline -> RETHROW (loud failure)
14
+ *
15
+ * We mock the cache inspector and the environment so the test runs
16
+ * deterministically and cross-platform (including in CI where ~/.bmad may
17
+ * not exist). No real subprocess is spawned.
18
+ */
19
+ 'use strict';
20
+
21
+ const assert = require('assert');
22
+ const path = require('path');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ const errors = [];
27
+
28
+ function test(name, fn) {
29
+ try {
30
+ fn();
31
+ console.log(` \u2713 ${name}`);
32
+ passed++;
33
+ } catch (err) {
34
+ console.error(` \u2717 ${name}: ${err.message}`);
35
+ failed++;
36
+ errors.push({ name, err });
37
+ }
38
+ }
39
+
40
+ console.log('\nBug: BMAD recompile on air-gapped network\n');
41
+
42
+ const bmad = require('../lib/bmad');
43
+
44
+ // ---- isOfflineModeDeclared ----
45
+
46
+ console.log('isOfflineModeDeclared()');
47
+
48
+ test('returns false when env is unset', () => {
49
+ const prev = process.env.MA_AGENTS_OFFLINE;
50
+ delete process.env.MA_AGENTS_OFFLINE;
51
+ try {
52
+ assert.strictEqual(bmad.isOfflineModeDeclared(), false);
53
+ } finally {
54
+ if (prev !== undefined) process.env.MA_AGENTS_OFFLINE = prev;
55
+ }
56
+ });
57
+
58
+ test('returns true for "1", "true", "yes" (case-insensitive)', () => {
59
+ const prev = process.env.MA_AGENTS_OFFLINE;
60
+ try {
61
+ for (const v of ['1', 'true', 'TRUE', 'yes', 'YES']) {
62
+ process.env.MA_AGENTS_OFFLINE = v;
63
+ assert.strictEqual(bmad.isOfflineModeDeclared(), true, `failed for ${v}`);
64
+ }
65
+ } finally {
66
+ if (prev !== undefined) process.env.MA_AGENTS_OFFLINE = prev;
67
+ else delete process.env.MA_AGENTS_OFFLINE;
68
+ }
69
+ });
70
+
71
+ test('returns false for "0", "false", empty', () => {
72
+ const prev = process.env.MA_AGENTS_OFFLINE;
73
+ try {
74
+ for (const v of ['0', 'false', '', 'no']) {
75
+ process.env.MA_AGENTS_OFFLINE = v;
76
+ assert.strictEqual(bmad.isOfflineModeDeclared(), false, `failed for "${v}"`);
77
+ }
78
+ } finally {
79
+ if (prev !== undefined) process.env.MA_AGENTS_OFFLINE = prev;
80
+ else delete process.env.MA_AGENTS_OFFLINE;
81
+ }
82
+ });
83
+
84
+ // ---- looksLikeOfflineFailure ----
85
+
86
+ console.log('\nlooksLikeOfflineFailure()');
87
+
88
+ test('detects ENOTFOUND', () => {
89
+ const err = new Error('getaddrinfo ENOTFOUND github.com');
90
+ assert.strictEqual(bmad.looksLikeOfflineFailure(err), true);
91
+ });
92
+
93
+ test('detects git fetch failure', () => {
94
+ const err = new Error("Command failed: git fetch origin --depth 1\nfatal: unable to access 'https://github.com/...': Could not resolve host");
95
+ assert.strictEqual(bmad.looksLikeOfflineFailure(err), true);
96
+ });
97
+
98
+ test('detects connection refused / timed out', () => {
99
+ assert.strictEqual(
100
+ bmad.looksLikeOfflineFailure(new Error('connect ECONNREFUSED 140.82.114.3:443')),
101
+ true,
102
+ );
103
+ assert.strictEqual(
104
+ bmad.looksLikeOfflineFailure(new Error('connect ETIMEDOUT')),
105
+ true,
106
+ );
107
+ });
108
+
109
+ test('ignores non-network failures', () => {
110
+ assert.strictEqual(
111
+ bmad.looksLikeOfflineFailure(new Error('SyntaxError in agent.yaml at line 42')),
112
+ false,
113
+ );
114
+ assert.strictEqual(bmad.looksLikeOfflineFailure(null), false);
115
+ assert.strictEqual(bmad.looksLikeOfflineFailure(undefined), false);
116
+ });
117
+
118
+ test('inspects stderr/stdout properties too', () => {
119
+ const err = new Error('Command failed');
120
+ err.stderr = 'fatal: unable to access https://github.com/bmad-code-org/...';
121
+ assert.strictEqual(bmad.looksLikeOfflineFailure(err), true);
122
+ });
123
+
124
+ // ---- classifyRecompileFailure ----
125
+
126
+ console.log('\nclassifyRecompileFailure()');
127
+
128
+ const intactCache = () => ({
129
+ present: ['bmb', 'cis', 'gds', 'tea', 'wds'],
130
+ missing: [],
131
+ intact: true,
132
+ cacheDir: '/fake/.bmad/cache/external-modules',
133
+ });
134
+
135
+ const missingCache = () => ({
136
+ present: ['bmb'],
137
+ missing: ['cis', 'gds', 'tea', 'wds'],
138
+ intact: false,
139
+ cacheDir: '/fake/.bmad/cache/external-modules',
140
+ });
141
+
142
+ test('rethrows loudly when error does not look offline and offline not declared', () => {
143
+ const prev = process.env.MA_AGENTS_OFFLINE;
144
+ delete process.env.MA_AGENTS_OFFLINE;
145
+ try {
146
+ const result = bmad.classifyRecompileFailure(
147
+ new Error('SyntaxError in customize.yaml'),
148
+ { cacheInspector: intactCache },
149
+ );
150
+ assert.strictEqual(result.action, 'rethrow');
151
+ } finally {
152
+ if (prev !== undefined) process.env.MA_AGENTS_OFFLINE = prev;
153
+ }
154
+ });
155
+
156
+ test('warns and proceeds when offline declared and cache intact', () => {
157
+ const prev = process.env.MA_AGENTS_OFFLINE;
158
+ process.env.MA_AGENTS_OFFLINE = '1';
159
+ try {
160
+ const result = bmad.classifyRecompileFailure(
161
+ new Error('getaddrinfo ENOTFOUND github.com'),
162
+ { cacheInspector: intactCache },
163
+ );
164
+ assert.strictEqual(result.action, 'warn');
165
+ assert.ok(result.message.includes('vendored cache is intact'));
166
+ } finally {
167
+ if (prev !== undefined) process.env.MA_AGENTS_OFFLINE = prev;
168
+ else delete process.env.MA_AGENTS_OFFLINE;
169
+ }
170
+ });
171
+
172
+ test('warns when offline is merely inferred (error looks like network) and cache intact', () => {
173
+ const prev = process.env.MA_AGENTS_OFFLINE;
174
+ delete process.env.MA_AGENTS_OFFLINE;
175
+ try {
176
+ const result = bmad.classifyRecompileFailure(
177
+ new Error("fatal: unable to access 'https://github.com/...': Could not resolve host"),
178
+ { cacheInspector: intactCache },
179
+ );
180
+ assert.strictEqual(result.action, 'warn');
181
+ } finally {
182
+ if (prev !== undefined) process.env.MA_AGENTS_OFFLINE = prev;
183
+ }
184
+ });
185
+
186
+ test('emits actionable error when offline and cache incomplete', () => {
187
+ const prev = process.env.MA_AGENTS_OFFLINE;
188
+ process.env.MA_AGENTS_OFFLINE = '1';
189
+ try {
190
+ const result = bmad.classifyRecompileFailure(
191
+ new Error('getaddrinfo ENOTFOUND github.com'),
192
+ { cacheInspector: missingCache },
193
+ );
194
+ assert.strictEqual(result.action, 'error');
195
+ assert.deepStrictEqual(result.missing, ['cis', 'gds', 'tea', 'wds']);
196
+ assert.ok(
197
+ result.message.includes('npm run build:bmad-cache'),
198
+ 'error should include actionable remediation command',
199
+ );
200
+ assert.ok(
201
+ result.message.includes('/fake/.bmad/cache/external-modules'),
202
+ 'error should include cache path',
203
+ );
204
+ } finally {
205
+ if (prev !== undefined) process.env.MA_AGENTS_OFFLINE = prev;
206
+ else delete process.env.MA_AGENTS_OFFLINE;
207
+ }
208
+ });
209
+
210
+ // ---- inspectBmadCache (lightweight integration — uses real manifest) ----
211
+
212
+ console.log('\ninspectBmadCache()');
213
+
214
+ test('reads real cache-manifest.json and reports expected modules', () => {
215
+ // Point at a directory that doesn't exist so every module is "missing"
216
+ // — the test just verifies we consult the manifest correctly.
217
+ const result = bmad.inspectBmadCache(path.join(__dirname, '__nonexistent_cache__'));
218
+ assert.ok(Array.isArray(result.present));
219
+ assert.ok(Array.isArray(result.missing));
220
+ // cache-manifest.json ships with bmb/cis/gds/tea/wds
221
+ assert.ok(
222
+ result.missing.includes('bmb'),
223
+ `expected bmb in missing, got: ${JSON.stringify(result.missing)}`,
224
+ );
225
+ assert.strictEqual(result.intact, false);
226
+ });
227
+
228
+ // ---- Summary ----
229
+
230
+ console.log(`\n${passed} passed, ${failed} failed\n`);
231
+ if (failed > 0) {
232
+ for (const { name, err } of errors) {
233
+ console.error(`FAIL: ${name}`);
234
+ console.error(err.stack || err);
235
+ }
236
+ process.exit(1);
237
+ }