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,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Story 21.4 — Unit + integration tests for AGENTS.md template stamping and
|
|
4
|
+
* OpenCode AGENTS.md wiring.
|
|
5
|
+
*
|
|
6
|
+
* AC coverage:
|
|
7
|
+
* 6.1 Template is static text — NO {{...}} placeholders (AC #2)
|
|
8
|
+
* 6.2 Stamper invokes composeInstructionBlock exactly once per artifact (AC #3)
|
|
9
|
+
* 6.3 Composed string appears byte-for-byte inside AGENTS.md markers (AC #3)
|
|
10
|
+
* 6.4 NFR44 — standard-profile rendered output lacks /no_think, str_replace_editor;
|
|
11
|
+
* ~/.claude/ appears only in Critical Behavior Rules sentence (AC #9)
|
|
12
|
+
* 6.5 Path-resolution precedence (AC #10)
|
|
13
|
+
* 6.6 Fresh install — AGENTS.md written with stamped content (AC #1, #5 create)
|
|
14
|
+
* 6.7 Fresh install — opencode.json::instructions[] contains both the
|
|
15
|
+
* [ma-agents] entry AND "AGENTS.md"; other keys untouched (AC #6, #12)
|
|
16
|
+
* 6.8 Re-install idempotency — byte-identical marker-block; no duplicate
|
|
17
|
+
* entries; no .backup files (AC #8, NFR46)
|
|
18
|
+
* 6.9 Existing AGENTS.md with user content outside markers: preserved byte-
|
|
19
|
+
* for-byte (AC #5 merge case)
|
|
20
|
+
* 6.10 Existing AGENTS.md without markers: block appended at EOF with one
|
|
21
|
+
* blank line; existing content preserved (AC #5 no-markers case)
|
|
22
|
+
* 6.11 Upgrade-safety drift: hand-edited marker block + yesMode triggers
|
|
23
|
+
* WARNING + backup file (AC #11)
|
|
24
|
+
* 6.12 Pre-existing "AGENTS.md" in instructions[] → no duplicate (AC #6)
|
|
25
|
+
* 6.13 Object-form { path: 'AGENTS.md' } entry → installer still appends
|
|
26
|
+
* the string literal (AC #6 string-equality contract)
|
|
27
|
+
*/
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const assert = require('assert');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
|
|
35
|
+
let passed = 0;
|
|
36
|
+
let failed = 0;
|
|
37
|
+
const errors = [];
|
|
38
|
+
|
|
39
|
+
async function test(name, fn) {
|
|
40
|
+
try {
|
|
41
|
+
await fn();
|
|
42
|
+
console.log(` \u2713 ${name}`);
|
|
43
|
+
passed++;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(` \u2717 ${name}: ${err.stack || err.message}`);
|
|
46
|
+
failed++;
|
|
47
|
+
errors.push({ name, error: err.message });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const installerModule = require('../lib/installer');
|
|
52
|
+
const {
|
|
53
|
+
composeInstructionBlock,
|
|
54
|
+
resolveBmadOutputDirs,
|
|
55
|
+
_testUpdateAgentInstructions: updateAgentInstructions
|
|
56
|
+
} = installerModule;
|
|
57
|
+
const { setProfile } = require('../lib/profile');
|
|
58
|
+
const agents = require('../lib/agents');
|
|
59
|
+
|
|
60
|
+
const AGENTS_MD_TEMPLATE_PATH = path.join(__dirname, '..', 'lib', 'templates', 'agents-md.template.md');
|
|
61
|
+
|
|
62
|
+
function mktemp() {
|
|
63
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-agents-md-test-'));
|
|
64
|
+
}
|
|
65
|
+
function cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
|
|
66
|
+
|
|
67
|
+
// Silence log lines from install (BMAD output dirs resolution, merger messages).
|
|
68
|
+
function withSilencedLogs(fn) {
|
|
69
|
+
return async (...args) => {
|
|
70
|
+
const origLog = console.log;
|
|
71
|
+
console.log = () => {};
|
|
72
|
+
try {
|
|
73
|
+
return await fn(...args);
|
|
74
|
+
} finally {
|
|
75
|
+
console.log = origLog;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function captureLogs(fn) {
|
|
81
|
+
const origLog = console.log;
|
|
82
|
+
let captured = '';
|
|
83
|
+
console.log = (...args) => { captured += args.join(' ') + '\n'; };
|
|
84
|
+
return Promise.resolve(fn()).then(
|
|
85
|
+
(v) => { console.log = origLog; return { result: v, captured }; },
|
|
86
|
+
(e) => { console.log = origLog; throw e; }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log('\n agents-md stamping tests (Story 21.4)\n');
|
|
91
|
+
|
|
92
|
+
async function runAll() {
|
|
93
|
+
// 6.1 — Template is static text, no {{...}} placeholders (AC #2)
|
|
94
|
+
await test('6.1 agents-md.template.md exists and contains NO {{...}} placeholders', () => {
|
|
95
|
+
assert.ok(fs.existsSync(AGENTS_MD_TEMPLATE_PATH), 'template file must exist');
|
|
96
|
+
const body = fs.readFileSync(AGENTS_MD_TEMPLATE_PATH, 'utf-8');
|
|
97
|
+
const placeholders = body.match(/\{\{[^}]+\}\}/g) || [];
|
|
98
|
+
assert.strictEqual(placeholders.length, 0,
|
|
99
|
+
`template must be static text; found placeholders: ${JSON.stringify(placeholders)}`);
|
|
100
|
+
// Required sections per AC #1
|
|
101
|
+
assert.ok(/## Universal Rules/.test(body), '## Universal Rules section required');
|
|
102
|
+
assert.ok(/## Critical Behavior Rules/.test(body), '## Critical Behavior Rules section required');
|
|
103
|
+
assert.ok(/## BMAD Phase Declaration/.test(body), '## BMAD Phase Declaration section required');
|
|
104
|
+
assert.ok(/## Project BMAD Output Structure/.test(body), '## Project BMAD Output Structure section required');
|
|
105
|
+
assert.ok(/<!-- MA-AGENTS-START -->/.test(body), 'MA-AGENTS-START marker required');
|
|
106
|
+
assert.ok(/<!-- MA-AGENTS-END -->/.test(body), 'MA-AGENTS-END marker required');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 6.2 — Stamper invokes composeInstructionBlock exactly once per artifact
|
|
110
|
+
await test('6.2 composeInstructionBlock invoked exactly once per AGENTS.md stamp', withSilencedLogs(async () => {
|
|
111
|
+
const dir = mktemp();
|
|
112
|
+
try {
|
|
113
|
+
setProfile(dir, 'standard');
|
|
114
|
+
// Spy by wrapping the exported composer via a local count.
|
|
115
|
+
// We can't easily stub module-internal calls, so instead we count
|
|
116
|
+
// via the side-effect: the composer reads from the universal template
|
|
117
|
+
// on every call, so we shim fs.readFileSync only for that path and
|
|
118
|
+
// count. To stay honest with the "don't stub real modules" rule, we
|
|
119
|
+
// instead perform a behavior assertion: after one stampExtraInstructionTemplates
|
|
120
|
+
// call for opencode, the file contents should match exactly one composer output.
|
|
121
|
+
const opencode = agents.getAgent('opencode');
|
|
122
|
+
// Pre-create opencode.json so the json-merge branch has something to read.
|
|
123
|
+
const opencodeJsonPath = path.join(dir, 'opencode.json');
|
|
124
|
+
fs.writeFileSync(opencodeJsonPath, JSON.stringify({ instructions: [] }, null, 2), 'utf-8');
|
|
125
|
+
await updateAgentInstructions(opencode, dir);
|
|
126
|
+
const agentsMdPath = path.join(dir, 'AGENTS.md');
|
|
127
|
+
assert.ok(fs.existsSync(agentsMdPath));
|
|
128
|
+
// AC #3: composed content appears exactly once inside the marker block
|
|
129
|
+
const content = fs.readFileSync(agentsMdPath, 'utf-8');
|
|
130
|
+
const composed = composeInstructionBlock({ profile: 'standard', projectRoot: dir })
|
|
131
|
+
.replace(/\{\{MANIFEST_PATH\}\}/g, path.relative(dir, path.join(opencode.getProjectPath(), 'MANIFEST.yaml')).replace(/\\/g, '/'));
|
|
132
|
+
// Count occurrences inside the marker block
|
|
133
|
+
const blockMatch = content.match(/<!-- MA-AGENTS-START -->([\s\S]*?)<!-- MA-AGENTS-END -->/);
|
|
134
|
+
assert.ok(blockMatch, 'marker block present in AGENTS.md');
|
|
135
|
+
const occurrences = (blockMatch[1].match(new RegExp(escapeRegExp(composed.split('\n')[0]), 'g')) || []).length;
|
|
136
|
+
assert.strictEqual(occurrences, 1, 'composed content appears exactly once inside markers');
|
|
137
|
+
} finally { cleanup(dir); }
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
// 6.3 — composed string appears byte-for-byte inside the marker block
|
|
141
|
+
await test('6.3 composed string appears byte-for-byte inside AGENTS.md markers', withSilencedLogs(async () => {
|
|
142
|
+
const dir = mktemp();
|
|
143
|
+
try {
|
|
144
|
+
setProfile(dir, 'standard');
|
|
145
|
+
const opencode = agents.getAgent('opencode');
|
|
146
|
+
await updateAgentInstructions(opencode, dir);
|
|
147
|
+
const content = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf-8');
|
|
148
|
+
const relManifestPath = path.relative(dir, path.join(opencode.getProjectPath(), 'MANIFEST.yaml')).replace(/\\/g, '/');
|
|
149
|
+
const composed = composeInstructionBlock({ profile: 'standard', projectRoot: dir })
|
|
150
|
+
.replace(/\{\{MANIFEST_PATH\}\}/g, relManifestPath);
|
|
151
|
+
const expectedInside = composed.replace(/\s+$/, '') + '\n';
|
|
152
|
+
const blockMatch = content.match(/<!-- MA-AGENTS-START -->\n([\s\S]*?)<!-- MA-AGENTS-END -->/);
|
|
153
|
+
assert.ok(blockMatch);
|
|
154
|
+
assert.strictEqual(blockMatch[1], expectedInside,
|
|
155
|
+
'content between markers must match composer output byte-for-byte');
|
|
156
|
+
} finally { cleanup(dir); }
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
// 6.4 — NFR44 — standard-profile output lacks forbidden local-LLM strings;
|
|
160
|
+
// ~/.claude/ only inside Critical Behavior Rules sentence
|
|
161
|
+
await test('6.4 NFR44 — standard profile AGENTS.md lacks /no_think, str_replace_editor; ~/.claude/ only inside Critical Behavior Rules', withSilencedLogs(async () => {
|
|
162
|
+
const dir = mktemp();
|
|
163
|
+
try {
|
|
164
|
+
setProfile(dir, 'standard');
|
|
165
|
+
const opencode = agents.getAgent('opencode');
|
|
166
|
+
await updateAgentInstructions(opencode, dir);
|
|
167
|
+
const content = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf-8');
|
|
168
|
+
assert.ok(!content.includes('/no_think'), 'must not contain /no_think');
|
|
169
|
+
assert.ok(!content.includes('str_replace_editor'), 'must not contain str_replace_editor');
|
|
170
|
+
const claudeMatches = content.match(/~\/\.claude\//g) || [];
|
|
171
|
+
// The Critical Behavior Rules sentence contains exactly one occurrence of ~/.claude/.
|
|
172
|
+
assert.strictEqual(claudeMatches.length, 1,
|
|
173
|
+
'exactly one ~/.claude/ allowed (Critical Behavior Rules sentence)');
|
|
174
|
+
// Assert that the occurrence is within the Critical Behavior Rules section.
|
|
175
|
+
const cbrIdx = content.indexOf('## Critical Behavior Rules');
|
|
176
|
+
const nextSectionIdx = content.indexOf('\n## ', cbrIdx + 1);
|
|
177
|
+
const cbrSlice = content.slice(cbrIdx, nextSectionIdx === -1 ? undefined : nextSectionIdx);
|
|
178
|
+
assert.ok(cbrSlice.includes('~/.claude/'), '~/.claude/ must appear inside the Critical Behavior Rules section');
|
|
179
|
+
} finally { cleanup(dir); }
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
// 6.5 — Path-resolution precedence (AC #10)
|
|
183
|
+
await test('6.5a resolveBmadOutputDirs uses defaults when _bmad/bmm/config.yaml absent', () => {
|
|
184
|
+
const dir = mktemp();
|
|
185
|
+
try {
|
|
186
|
+
// Clear memoization by creating a fresh dir
|
|
187
|
+
const dirs = resolveBmadOutputDirs(dir);
|
|
188
|
+
assert.strictEqual(dirs.planning, '_bmad-output/planning-artifacts');
|
|
189
|
+
assert.strictEqual(dirs.architecture, '_bmad-output/planning-artifacts');
|
|
190
|
+
assert.strictEqual(dirs.stories, '_bmad-output/implementation-artifacts');
|
|
191
|
+
} finally { cleanup(dir); }
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('6.5b resolveBmadOutputDirs honors _bmad/bmm/config.yaml when present', () => {
|
|
195
|
+
const dir = mktemp();
|
|
196
|
+
try {
|
|
197
|
+
const cfgDir = path.join(dir, '_bmad', 'bmm');
|
|
198
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
199
|
+
fs.writeFileSync(path.join(cfgDir, 'config.yaml'),
|
|
200
|
+
'planning_artifacts: "custom/planning"\narchitecture_artifacts: "custom/arch"\nimplementation_artifacts: "custom/stories"\n',
|
|
201
|
+
'utf-8');
|
|
202
|
+
const dirs = resolveBmadOutputDirs(dir);
|
|
203
|
+
assert.strictEqual(dirs.planning, 'custom/planning');
|
|
204
|
+
assert.strictEqual(dirs.architecture, 'custom/arch');
|
|
205
|
+
assert.strictEqual(dirs.stories, 'custom/stories');
|
|
206
|
+
} finally { cleanup(dir); }
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// 6.6 — Fresh install writes AGENTS.md with required structure
|
|
210
|
+
await test('6.6 Fresh install writes AGENTS.md with stamped content + all required sections', withSilencedLogs(async () => {
|
|
211
|
+
const dir = mktemp();
|
|
212
|
+
try {
|
|
213
|
+
setProfile(dir, 'standard');
|
|
214
|
+
const opencode = agents.getAgent('opencode');
|
|
215
|
+
await updateAgentInstructions(opencode, dir);
|
|
216
|
+
const content = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf-8');
|
|
217
|
+
assert.ok(content.startsWith('<!-- Generated by ma-agents'), 'leading Generated-by comment required');
|
|
218
|
+
assert.ok(content.includes('# Project Agent Instructions'));
|
|
219
|
+
assert.ok(content.includes('## Universal Rules'));
|
|
220
|
+
assert.ok(content.includes('## Critical Behavior Rules'));
|
|
221
|
+
assert.ok(content.includes('## BMAD Phase Declaration'));
|
|
222
|
+
assert.ok(content.includes('## Project BMAD Output Structure'));
|
|
223
|
+
assert.ok(content.includes('<!-- MA-AGENTS-START -->'));
|
|
224
|
+
assert.ok(content.includes('<!-- MA-AGENTS-END -->'));
|
|
225
|
+
// Composer content present between markers
|
|
226
|
+
assert.ok(content.includes('# AI Agent Skills - Planning Instruction'));
|
|
227
|
+
assert.ok(content.includes('Respond in TEXT vs. create FILES'));
|
|
228
|
+
} finally { cleanup(dir); }
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
// 6.7 — opencode.json::instructions[] contains ma-agents entry + AGENTS.md
|
|
232
|
+
await test('6.7 Fresh install: opencode.json::instructions[] has both [ma-agents] and "AGENTS.md"; other keys untouched', withSilencedLogs(async () => {
|
|
233
|
+
const dir = mktemp();
|
|
234
|
+
try {
|
|
235
|
+
setProfile(dir, 'standard');
|
|
236
|
+
const opencode = agents.getAgent('opencode');
|
|
237
|
+
// Seed with user-owned keys + a user entry
|
|
238
|
+
fs.writeFileSync(path.join(dir, 'opencode.json'), JSON.stringify({
|
|
239
|
+
instructions: ['user-entry'],
|
|
240
|
+
otherKey: { preserve: true }
|
|
241
|
+
}, null, 2), 'utf-8');
|
|
242
|
+
await updateAgentInstructions(opencode, dir);
|
|
243
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8'));
|
|
244
|
+
assert.deepStrictEqual(data.otherKey, { preserve: true }, 'other keys untouched (NFR18)');
|
|
245
|
+
const hasMa = data.instructions.some(e => typeof e === 'string' && e.startsWith('[ma-agents]'));
|
|
246
|
+
const hasAgentsMd = data.instructions.includes('AGENTS.md');
|
|
247
|
+
assert.ok(hasMa, '[ma-agents] entry present');
|
|
248
|
+
assert.ok(hasAgentsMd, '"AGENTS.md" literal present');
|
|
249
|
+
assert.ok(data.instructions.includes('user-entry'), 'user entry preserved');
|
|
250
|
+
} finally { cleanup(dir); }
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
// 6.8 — Idempotency: re-install produces byte-identical marker block, no duplicates, no backup
|
|
254
|
+
await test('6.8 Re-install idempotency: byte-identical AGENTS.md marker block; no duplicate entries; no backup files', withSilencedLogs(async () => {
|
|
255
|
+
const dir = mktemp();
|
|
256
|
+
try {
|
|
257
|
+
setProfile(dir, 'standard');
|
|
258
|
+
const opencode = agents.getAgent('opencode');
|
|
259
|
+
await updateAgentInstructions(opencode, dir);
|
|
260
|
+
const firstAgentsMd = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf-8');
|
|
261
|
+
const firstJson = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8');
|
|
262
|
+
await updateAgentInstructions(opencode, dir);
|
|
263
|
+
const secondAgentsMd = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf-8');
|
|
264
|
+
const secondJson = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8');
|
|
265
|
+
assert.strictEqual(firstAgentsMd, secondAgentsMd, 'AGENTS.md byte-identical across installs');
|
|
266
|
+
// JSON: exactly one ma-agents entry, one AGENTS.md entry
|
|
267
|
+
const d2 = JSON.parse(secondJson);
|
|
268
|
+
const maCount = d2.instructions.filter(e => typeof e === 'string' && e.startsWith('[ma-agents]')).length;
|
|
269
|
+
const agentsMdCount = d2.instructions.filter(e => e === 'AGENTS.md').length;
|
|
270
|
+
assert.strictEqual(maCount, 1, 'exactly one ma-agents entry');
|
|
271
|
+
assert.strictEqual(agentsMdCount, 1, 'exactly one AGENTS.md entry');
|
|
272
|
+
// No backup files at project root
|
|
273
|
+
const siblings = fs.readdirSync(dir);
|
|
274
|
+
const backups = siblings.filter(n => n.startsWith('AGENTS.md.backup-'));
|
|
275
|
+
assert.strictEqual(backups.length, 0, 'no backup file when no drift');
|
|
276
|
+
} finally { cleanup(dir); }
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
// 6.9 — User content outside markers preserved byte-for-byte
|
|
280
|
+
await test('6.9 Pre-existing AGENTS.md with user content outside markers: preserved byte-for-byte', withSilencedLogs(async () => {
|
|
281
|
+
const dir = mktemp();
|
|
282
|
+
try {
|
|
283
|
+
setProfile(dir, 'standard');
|
|
284
|
+
const before = '# My personal AGENTS\n\nUser notes.\n\n';
|
|
285
|
+
const after = '\n## Trailing user section\n\nMore notes.\n';
|
|
286
|
+
const preMarker = '<!-- MA-AGENTS-START -->\nOLD STALE CONTENT\n<!-- MA-AGENTS-END -->';
|
|
287
|
+
fs.writeFileSync(path.join(dir, 'AGENTS.md'), before + preMarker + after, 'utf-8');
|
|
288
|
+
const opencode = agents.getAgent('opencode');
|
|
289
|
+
await updateAgentInstructions(opencode, dir);
|
|
290
|
+
const final = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf-8');
|
|
291
|
+
assert.ok(final.startsWith(before), 'pre-marker content preserved byte-for-byte');
|
|
292
|
+
assert.ok(final.endsWith(after), 'post-marker content preserved byte-for-byte');
|
|
293
|
+
assert.ok(!final.includes('OLD STALE CONTENT'), 'stale marker content replaced');
|
|
294
|
+
} finally { cleanup(dir); }
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
// 6.10 — Pre-existing AGENTS.md without markers: append block at EOF + blank line
|
|
298
|
+
await test('6.10 Pre-existing AGENTS.md without markers: block appended at EOF with blank-line separator', withSilencedLogs(async () => {
|
|
299
|
+
const dir = mktemp();
|
|
300
|
+
try {
|
|
301
|
+
setProfile(dir, 'standard');
|
|
302
|
+
const preContent = '# My AGENTS notes\n\nSome notes.\n';
|
|
303
|
+
fs.writeFileSync(path.join(dir, 'AGENTS.md'), preContent, 'utf-8');
|
|
304
|
+
const opencode = agents.getAgent('opencode');
|
|
305
|
+
await updateAgentInstructions(opencode, dir);
|
|
306
|
+
const final = fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf-8');
|
|
307
|
+
assert.ok(final.startsWith(preContent), 'existing content preserved byte-for-byte at start');
|
|
308
|
+
assert.ok(final.includes('<!-- MA-AGENTS-START -->'));
|
|
309
|
+
assert.ok(final.includes('<!-- MA-AGENTS-END -->'));
|
|
310
|
+
// Separator is exactly one blank line between the original trailing newline and the marker block
|
|
311
|
+
const blankLineSep = preContent + '\n<!-- MA-AGENTS-START -->';
|
|
312
|
+
// Allow either exact (preserves existing trailing newline + one blank line) match:
|
|
313
|
+
const idxOfMarker = final.indexOf('<!-- MA-AGENTS-START -->');
|
|
314
|
+
const between = final.slice(preContent.length, idxOfMarker);
|
|
315
|
+
assert.ok(/^\n/.test(between) || between === '\n' || between === '',
|
|
316
|
+
`expected a blank-line separator before marker block, got: ${JSON.stringify(between)}`);
|
|
317
|
+
void blankLineSep;
|
|
318
|
+
} finally { cleanup(dir); }
|
|
319
|
+
}));
|
|
320
|
+
|
|
321
|
+
// 6.11 — Hand-edited marker block + yesMode → WARNING + backup
|
|
322
|
+
await test('6.11 Upgrade-safety: hand-edited marker block + yesMode emits WARNING and writes backup', async () => {
|
|
323
|
+
const dir = mktemp();
|
|
324
|
+
try {
|
|
325
|
+
setProfile(dir, 'standard');
|
|
326
|
+
const handEdited = '<!-- MA-AGENTS-START -->\nHAND EDITED\n<!-- MA-AGENTS-END -->';
|
|
327
|
+
fs.writeFileSync(path.join(dir, 'AGENTS.md'), 'prefix\n' + handEdited + '\nsuffix\n', 'utf-8');
|
|
328
|
+
const opencode = agents.getAgent('opencode');
|
|
329
|
+
const origYes = process.env.MA_AGENTS_YES;
|
|
330
|
+
process.env.MA_AGENTS_YES = '1';
|
|
331
|
+
try {
|
|
332
|
+
const { captured } = await captureLogs(() => updateAgentInstructions(opencode, dir));
|
|
333
|
+
assert.ok(captured.includes('WARNING: ma-agents marker-block content modified since last install'),
|
|
334
|
+
'WARNING line must be emitted');
|
|
335
|
+
const siblings = fs.readdirSync(dir);
|
|
336
|
+
const backups = siblings.filter(n => n.startsWith('AGENTS.md.backup-'));
|
|
337
|
+
assert.strictEqual(backups.length, 1, 'exactly one backup file');
|
|
338
|
+
const backupContent = fs.readFileSync(path.join(dir, backups[0]), 'utf-8');
|
|
339
|
+
assert.strictEqual(backupContent, handEdited, 'backup contains only marker-block region');
|
|
340
|
+
} finally {
|
|
341
|
+
if (origYes === undefined) delete process.env.MA_AGENTS_YES;
|
|
342
|
+
else process.env.MA_AGENTS_YES = origYes;
|
|
343
|
+
}
|
|
344
|
+
} finally { cleanup(dir); }
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// 6.12 — Pre-existing "AGENTS.md" in instructions[] → no duplicate
|
|
348
|
+
await test('6.12 Pre-existing "AGENTS.md" entry in opencode.json is not duplicated', withSilencedLogs(async () => {
|
|
349
|
+
const dir = mktemp();
|
|
350
|
+
try {
|
|
351
|
+
setProfile(dir, 'standard');
|
|
352
|
+
fs.writeFileSync(path.join(dir, 'opencode.json'), JSON.stringify({
|
|
353
|
+
instructions: ['AGENTS.md']
|
|
354
|
+
}, null, 2), 'utf-8');
|
|
355
|
+
const opencode = agents.getAgent('opencode');
|
|
356
|
+
await updateAgentInstructions(opencode, dir);
|
|
357
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8'));
|
|
358
|
+
const count = data.instructions.filter(e => e === 'AGENTS.md').length;
|
|
359
|
+
assert.strictEqual(count, 1, 'no duplicate AGENTS.md entry');
|
|
360
|
+
} finally { cleanup(dir); }
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
// 6.13 — Object-form { path: 'AGENTS.md' } is NOT treated as a dedup match
|
|
364
|
+
await test('6.13 Object-form { path: "AGENTS.md" } entry: installer still appends the literal string (AC #6 string-equality)', withSilencedLogs(async () => {
|
|
365
|
+
const dir = mktemp();
|
|
366
|
+
try {
|
|
367
|
+
setProfile(dir, 'standard');
|
|
368
|
+
fs.writeFileSync(path.join(dir, 'opencode.json'), JSON.stringify({
|
|
369
|
+
instructions: [{ path: 'AGENTS.md' }]
|
|
370
|
+
}, null, 2), 'utf-8');
|
|
371
|
+
const opencode = agents.getAgent('opencode');
|
|
372
|
+
await updateAgentInstructions(opencode, dir);
|
|
373
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8'));
|
|
374
|
+
// Rationale (AC #6 string equality): object form is a DIFFERENT entry; the
|
|
375
|
+
// string "AGENTS.md" is appended because `=== 'AGENTS.md'` check matches no
|
|
376
|
+
// existing entry. Preserves user's object entry unchanged.
|
|
377
|
+
const stringCount = data.instructions.filter(e => e === 'AGENTS.md').length;
|
|
378
|
+
assert.strictEqual(stringCount, 1, 'string literal "AGENTS.md" appended');
|
|
379
|
+
const objCount = data.instructions.filter(e => typeof e === 'object' && e != null && e.path === 'AGENTS.md').length;
|
|
380
|
+
assert.strictEqual(objCount, 1, 'user object-form entry preserved');
|
|
381
|
+
} finally { cleanup(dir); }
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
|
385
|
+
if (failed > 0) {
|
|
386
|
+
errors.forEach(e => console.error(` ${e.name}: ${e.error}`));
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function escapeRegExp(s) {
|
|
392
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
runAll().catch(err => {
|
|
396
|
+
console.error('Unhandled error:', err);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
});
|
|
@@ -83,9 +83,9 @@ const builtinAgents = [
|
|
|
83
83
|
'bmm-sm', 'bmm-tech-writer', 'bmm-ux-designer', 'bmm-bmad-master'
|
|
84
84
|
];
|
|
85
85
|
|
|
86
|
-
test('2.2: lib/bmad-customize/ contains exactly
|
|
86
|
+
test('2.2: lib/bmad-customize/ contains exactly 10 .customize.yaml files', () => {
|
|
87
87
|
const files = fs.readdirSync(customizeDir).filter(f => f.endsWith('.customize.yaml'));
|
|
88
|
-
assert.strictEqual(files.length,
|
|
88
|
+
assert.strictEqual(files.length, 10, `Expected 10 .customize.yaml files, found ${files.length}`);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
for (const agentId of builtinAgents) {
|