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.
- package/.ma-agents.json +10 -0
- package/AGENTS.md +97 -0
- package/MANIFEST.yaml +3 -0
- package/README.md +17 -0
- package/_bmad-output/implementation-artifacts/21-10-profile-reconfigure.md +30 -6
- package/_bmad-output/implementation-artifacts/21-11-profile-uninstall.md +2 -1
- package/_bmad-output/implementation-artifacts/21-2-universal-instruction-block-expansion.md +217 -62
- package/_bmad-output/implementation-artifacts/21-3-roomodes-template-bmad-modes.md +196 -73
- package/_bmad-output/implementation-artifacts/21-4-agents-md-template-opencode.md +242 -53
- package/_bmad-output/implementation-artifacts/21-5-clinerules-template-extension.md +180 -41
- package/_bmad-output/implementation-artifacts/21-6-onprem-layered-guardrails.md +250 -75
- package/_bmad-output/implementation-artifacts/21-7-bmad-persona-phase-prefix.md +221 -89
- package/_bmad-output/implementation-artifacts/21-8-vllm-reference-doc-readme.md +121 -63
- package/_bmad-output/implementation-artifacts/21-9-tests-validation.md +332 -61
- package/_bmad-output/implementation-artifacts/bug-bmad-recompile-fails-on-airgapped-network.md +112 -0
- package/_bmad-output/implementation-artifacts/sprint-status.yaml +3 -2
- package/bin/cli.js +59 -0
- package/docs/deployment/vllm-nemotron.md +130 -0
- package/lib/agents.js +17 -2
- package/lib/bmad-customize/bmm-analyst.customize.yaml +8 -0
- package/lib/bmad-customize/bmm-architect.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-dev.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-pm.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-qa.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-quick-flow-solo-dev.customize.yaml +8 -0
- package/lib/bmad-customize/bmm-sm.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-tech-writer.customize.yaml +2 -0
- package/lib/bmad-customize/bmm-ux-designer.customize.yaml +2 -0
- package/lib/bmad.js +293 -1
- package/lib/installer.js +617 -43
- package/lib/merge/roomodes.js +125 -0
- package/lib/profile.js +25 -2
- package/lib/reconfigure.js +334 -0
- package/lib/templates/agents-md.template.md +67 -0
- package/lib/templates/clinerules.template.md +13 -0
- package/lib/templates/instruction-block-onprem.template.md +86 -0
- package/lib/templates/instruction-block-universal.template.md +29 -0
- package/lib/templates/roomodes.template.yaml +96 -0
- package/lib/uninstall.js +314 -0
- package/package.json +4 -3
- package/test/agents-md.test.js +398 -0
- package/test/bmad-extension.test.js +2 -2
- package/test/bmad-persona-phase-prefix.test.js +271 -0
- package/test/clinerules.test.js +339 -0
- package/test/instruction-block.test.js +388 -0
- package/test/integration-verification.test.js +2 -2
- package/test/migration-validation.test.js +2 -2
- package/test/offline-recompile.test.js +237 -0
- package/test/onprem-injection.test.js +425 -32
- package/test/onprem-layer.test.js +419 -0
- package/test/reconfigure.test.js +436 -0
- package/test/roomodes.test.js +343 -0
- 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
|
-
//
|
|
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,
|
|
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
|
|
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,
|
|
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
|
+
}
|