ma-agents 3.5.6 → 3.6.1

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 +52 -9
  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 +326 -1
  30. package/lib/installer.js +629 -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 +267 -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,271 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Story 21.7 — Tests for BMAD persona phase-aware prompt prefix.
4
+ *
5
+ * AC coverage (12 required):
6
+ * 7.1 bmm-pm source contains phase: planning and non-empty on_prem_phase_prefix
7
+ * 7.2 bmm-architect source contains phase: planning and non-empty on_prem_phase_prefix
8
+ * 7.3 bmm-dev source contains phase: implementation and non-empty on_prem_phase_prefix
9
+ * 7.4 Every planning persona's on_prem_phase_prefix contains /no_think literal
10
+ * 7.5 No implementation persona's on_prem_phase_prefix contains /no_think
11
+ * 7.6 Standard-profile deploy: bmm-pm has NO phase:, NO on_prem_phase_prefix:, NO /no_think
12
+ * 7.7 On-prem deploy: bmm-pm critical_actions[0] contains planning prefix with /no_think
13
+ * 7.8 On-prem deploy: bmm-pm has NO phase: or on_prem_phase_prefix: top-level keys
14
+ * 7.9 On-prem deploy: ALL pre-existing critical_actions entries preserved in order
15
+ * 7.10 Idempotency: two consecutive on-prem deploys produce byte-identical deployed bmm-pm
16
+ * 7.11 bmm-analyst.customize.yaml exists and has phase: planning
17
+ * 7.12 bmm-quick-flow-solo-dev.customize.yaml exists and has phase: implementation
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
+ const jsYaml = require('js-yaml');
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ const errors = [];
30
+
31
+ async function test(name, fn) {
32
+ try {
33
+ await fn();
34
+ console.log(` \u2713 ${name}`);
35
+ passed++;
36
+ } catch (err) {
37
+ console.error(` \u2717 ${name}: ${err.stack || err.message}`);
38
+ failed++;
39
+ errors.push({ name, error: err.message });
40
+ }
41
+ }
42
+
43
+ const CUSTOMIZE_SOURCE = path.join(__dirname, '..', 'lib', 'bmad-customize');
44
+ const { applyPersonaPhasePrefix } = require('../lib/bmad');
45
+ const { setProfile } = require('../lib/profile');
46
+
47
+ function mktemp() {
48
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ma-21-7-'));
49
+ }
50
+
51
+ function cleanup(dir) {
52
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
53
+ }
54
+
55
+ /**
56
+ * Copy all customize yaml files from CUSTOMIZE_SOURCE into tmpDir,
57
+ * then call applyPersonaPhasePrefix with the given profile.
58
+ * Returns the parsed YAML of the specified file from tmpDir.
59
+ */
60
+ async function deployAndParse(tmpDir, profile, filename) {
61
+ // Copy all source customize files into tmpDir
62
+ const files = fs.readdirSync(CUSTOMIZE_SOURCE).filter(f => f.endsWith('.customize.yaml'));
63
+ for (const file of files) {
64
+ fs.copyFileSync(
65
+ path.join(CUSTOMIZE_SOURCE, file),
66
+ path.join(tmpDir, file)
67
+ );
68
+ }
69
+ await applyPersonaPhasePrefix(CUSTOMIZE_SOURCE, tmpDir, profile);
70
+ const raw = fs.readFileSync(path.join(tmpDir, filename), 'utf8');
71
+ return jsYaml.load(raw);
72
+ }
73
+
74
+ console.log('\n story 21.7 — BMAD persona phase-aware prompt prefix\n');
75
+
76
+ async function runAll() {
77
+
78
+ // 7.1 — bmm-pm source contains phase: planning and non-empty on_prem_phase_prefix
79
+ await test('7.1 bmm-pm.customize.yaml source has phase: planning and non-empty on_prem_phase_prefix', () => {
80
+ const doc = jsYaml.load(fs.readFileSync(path.join(CUSTOMIZE_SOURCE, 'bmm-pm.customize.yaml'), 'utf8'));
81
+ assert.strictEqual(doc.phase, 'planning', 'phase should be "planning"');
82
+ assert.ok(doc.on_prem_phase_prefix && doc.on_prem_phase_prefix.trim().length > 0,
83
+ 'on_prem_phase_prefix should be non-empty');
84
+ });
85
+
86
+ // 7.2 — bmm-architect source contains phase: planning and non-empty on_prem_phase_prefix
87
+ await test('7.2 bmm-architect.customize.yaml source has phase: planning and non-empty on_prem_phase_prefix', () => {
88
+ const doc = jsYaml.load(fs.readFileSync(path.join(CUSTOMIZE_SOURCE, 'bmm-architect.customize.yaml'), 'utf8'));
89
+ assert.strictEqual(doc.phase, 'planning', 'phase should be "planning"');
90
+ assert.ok(doc.on_prem_phase_prefix && doc.on_prem_phase_prefix.trim().length > 0,
91
+ 'on_prem_phase_prefix should be non-empty');
92
+ });
93
+
94
+ // 7.3 — bmm-dev source contains phase: implementation and non-empty on_prem_phase_prefix
95
+ await test('7.3 bmm-dev.customize.yaml source has phase: implementation and non-empty on_prem_phase_prefix', () => {
96
+ const doc = jsYaml.load(fs.readFileSync(path.join(CUSTOMIZE_SOURCE, 'bmm-dev.customize.yaml'), 'utf8'));
97
+ assert.strictEqual(doc.phase, 'implementation', 'phase should be "implementation"');
98
+ assert.ok(doc.on_prem_phase_prefix && doc.on_prem_phase_prefix.trim().length > 0,
99
+ 'on_prem_phase_prefix should be non-empty');
100
+ });
101
+
102
+ // 7.4 — Every planning persona's on_prem_phase_prefix contains /no_think literal
103
+ await test('7.4 All planning personas have /no_think in on_prem_phase_prefix', () => {
104
+ const files = fs.readdirSync(CUSTOMIZE_SOURCE).filter(f => f.endsWith('.customize.yaml'));
105
+ for (const file of files) {
106
+ const doc = jsYaml.load(fs.readFileSync(path.join(CUSTOMIZE_SOURCE, file), 'utf8'));
107
+ if (doc && doc.phase === 'planning') {
108
+ assert.ok(
109
+ typeof doc.on_prem_phase_prefix === 'string' && doc.on_prem_phase_prefix.includes('/no_think'),
110
+ `${file}: planning persona on_prem_phase_prefix must contain /no_think, got: ${doc.on_prem_phase_prefix}`
111
+ );
112
+ }
113
+ }
114
+ });
115
+
116
+ // 7.5 — No implementation persona's on_prem_phase_prefix contains /no_think
117
+ await test('7.5 No implementation persona has /no_think in on_prem_phase_prefix', () => {
118
+ const files = fs.readdirSync(CUSTOMIZE_SOURCE).filter(f => f.endsWith('.customize.yaml'));
119
+ for (const file of files) {
120
+ const doc = jsYaml.load(fs.readFileSync(path.join(CUSTOMIZE_SOURCE, file), 'utf8'));
121
+ if (doc && doc.phase === 'implementation') {
122
+ const prefix = doc.on_prem_phase_prefix || '';
123
+ assert.ok(
124
+ !prefix.includes('/no_think'),
125
+ `${file}: implementation persona on_prem_phase_prefix must NOT contain /no_think`
126
+ );
127
+ }
128
+ }
129
+ });
130
+
131
+ // 7.6 — Standard-profile deploy: bmm-pm has NO phase:, NO on_prem_phase_prefix:, NO /no_think
132
+ await test('7.6 Standard-profile deploy: bmm-pm has no phase, no on_prem_phase_prefix, no /no_think', async () => {
133
+ const tmpDir = mktemp();
134
+ try {
135
+ const doc = await deployAndParse(tmpDir, 'standard', 'bmm-pm.customize.yaml');
136
+ assert.ok(!Object.prototype.hasOwnProperty.call(doc, 'phase'),
137
+ 'deployed file must not have "phase" key');
138
+ assert.ok(!Object.prototype.hasOwnProperty.call(doc, 'on_prem_phase_prefix'),
139
+ 'deployed file must not have "on_prem_phase_prefix" key');
140
+ const raw = fs.readFileSync(path.join(tmpDir, 'bmm-pm.customize.yaml'), 'utf8');
141
+ assert.ok(!raw.includes('/no_think'),
142
+ 'deployed file must not contain /no_think string');
143
+ } finally {
144
+ cleanup(tmpDir);
145
+ }
146
+ });
147
+
148
+ // 7.7 — On-prem deploy: bmm-pm critical_actions[0] contains planning prefix with /no_think
149
+ await test('7.7 On-prem deploy: bmm-pm critical_actions[0] contains /no_think prefix', async () => {
150
+ const tmpDir = mktemp();
151
+ try {
152
+ const doc = await deployAndParse(tmpDir, 'on-prem', 'bmm-pm.customize.yaml');
153
+ assert.ok(Array.isArray(doc.critical_actions) && doc.critical_actions.length > 0,
154
+ 'critical_actions must be a non-empty array');
155
+ const first = doc.critical_actions[0];
156
+ assert.ok(typeof first === 'string' && first.includes('/no_think'),
157
+ `critical_actions[0] must contain /no_think, got: ${first}`
158
+ );
159
+ } finally {
160
+ cleanup(tmpDir);
161
+ }
162
+ });
163
+
164
+ // 7.8 — On-prem deploy: bmm-pm has NO phase: or on_prem_phase_prefix: top-level keys
165
+ await test('7.8 On-prem deploy: bmm-pm has no phase or on_prem_phase_prefix keys after deploy', async () => {
166
+ const tmpDir = mktemp();
167
+ try {
168
+ const doc = await deployAndParse(tmpDir, 'on-prem', 'bmm-pm.customize.yaml');
169
+ assert.ok(!Object.prototype.hasOwnProperty.call(doc, 'phase'),
170
+ 'deployed on-prem file must not have "phase" key');
171
+ assert.ok(!Object.prototype.hasOwnProperty.call(doc, 'on_prem_phase_prefix'),
172
+ 'deployed on-prem file must not have "on_prem_phase_prefix" key');
173
+ } finally {
174
+ cleanup(tmpDir);
175
+ }
176
+ });
177
+
178
+ // 7.9 — On-prem deploy: ALL pre-existing critical_actions entries preserved in order
179
+ await test('7.9 On-prem deploy: all original critical_actions entries preserved', async () => {
180
+ const tmpDir = mktemp();
181
+ try {
182
+ // Get original critical_actions from source
183
+ const sourceDoc = jsYaml.load(
184
+ fs.readFileSync(path.join(CUSTOMIZE_SOURCE, 'bmm-pm.customize.yaml'), 'utf8')
185
+ );
186
+ const originalActions = Array.isArray(sourceDoc.critical_actions)
187
+ ? sourceDoc.critical_actions : [];
188
+
189
+ const deployedDoc = await deployAndParse(tmpDir, 'on-prem', 'bmm-pm.customize.yaml');
190
+ assert.ok(Array.isArray(deployedDoc.critical_actions),
191
+ 'critical_actions must be an array');
192
+ // The deployed array should be [prefix, ...originalActions]
193
+ assert.strictEqual(
194
+ deployedDoc.critical_actions.length,
195
+ originalActions.length + 1,
196
+ `deployed critical_actions length should be original + 1, got ${deployedDoc.critical_actions.length}`
197
+ );
198
+ for (let i = 0; i < originalActions.length; i++) {
199
+ assert.strictEqual(
200
+ deployedDoc.critical_actions[i + 1],
201
+ originalActions[i],
202
+ `critical_actions[${i + 1}] should match original[${i}]`
203
+ );
204
+ }
205
+ } finally {
206
+ cleanup(tmpDir);
207
+ }
208
+ });
209
+
210
+ // 7.10 — Idempotency: two consecutive on-prem deploys produce byte-identical deployed bmm-pm
211
+ await test('7.10 Idempotency: two on-prem deploys produce byte-identical bmm-pm.customize.yaml', async () => {
212
+ const tmpDir = mktemp();
213
+ try {
214
+ // First deploy
215
+ const files = fs.readdirSync(CUSTOMIZE_SOURCE).filter(f => f.endsWith('.customize.yaml'));
216
+ for (const file of files) {
217
+ fs.copyFileSync(
218
+ path.join(CUSTOMIZE_SOURCE, file),
219
+ path.join(tmpDir, file)
220
+ );
221
+ }
222
+ await applyPersonaPhasePrefix(CUSTOMIZE_SOURCE, tmpDir, 'on-prem');
223
+ const after1 = fs.readFileSync(path.join(tmpDir, 'bmm-pm.customize.yaml'), 'utf8');
224
+
225
+ // Second deploy: re-copy source files and apply again
226
+ for (const file of files) {
227
+ fs.copyFileSync(
228
+ path.join(CUSTOMIZE_SOURCE, file),
229
+ path.join(tmpDir, file)
230
+ );
231
+ }
232
+ await applyPersonaPhasePrefix(CUSTOMIZE_SOURCE, tmpDir, 'on-prem');
233
+ const after2 = fs.readFileSync(path.join(tmpDir, 'bmm-pm.customize.yaml'), 'utf8');
234
+
235
+ assert.strictEqual(after1, after2, 'Two consecutive on-prem deploys must produce byte-identical files');
236
+ } finally {
237
+ cleanup(tmpDir);
238
+ }
239
+ });
240
+
241
+ // 7.11 — bmm-analyst.customize.yaml exists and has phase: planning
242
+ await test('7.11 bmm-analyst.customize.yaml exists and has phase: planning', () => {
243
+ const filePath = path.join(CUSTOMIZE_SOURCE, 'bmm-analyst.customize.yaml');
244
+ assert.ok(fs.existsSync(filePath), 'bmm-analyst.customize.yaml must exist in lib/bmad-customize/');
245
+ const doc = jsYaml.load(fs.readFileSync(filePath, 'utf8'));
246
+ assert.strictEqual(doc.phase, 'planning', 'bmm-analyst must have phase: planning');
247
+ });
248
+
249
+ // 7.12 — bmm-quick-flow-solo-dev.customize.yaml exists and has phase: implementation
250
+ await test('7.12 bmm-quick-flow-solo-dev.customize.yaml exists and has phase: implementation', () => {
251
+ const filePath = path.join(CUSTOMIZE_SOURCE, 'bmm-quick-flow-solo-dev.customize.yaml');
252
+ assert.ok(fs.existsSync(filePath), 'bmm-quick-flow-solo-dev.customize.yaml must exist in lib/bmad-customize/');
253
+ const doc = jsYaml.load(fs.readFileSync(filePath, 'utf8'));
254
+ assert.strictEqual(doc.phase, 'implementation', 'bmm-quick-flow-solo-dev must have phase: implementation');
255
+ });
256
+
257
+ // Summary
258
+ console.log(`\n ${passed} passed, ${failed} failed\n`);
259
+ if (errors.length > 0) {
260
+ console.error('Failures:');
261
+ for (const e of errors) {
262
+ console.error(` - ${e.name}: ${e.error}`);
263
+ }
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ runAll().catch(err => {
269
+ console.error('Unexpected error:', err);
270
+ process.exit(1);
271
+ });
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Story 21.5 — Unit + integration tests for the Cline `.clinerules` template
4
+ * extension.
5
+ *
6
+ * AC coverage:
7
+ * 5.1 clinerules.template.md exists with Architect-mode framing (AC #1)
8
+ * 5.2 NFR44 per-file: standard-profile output lacks forbidden strings (AC #7)
9
+ * 5.3 NFR46 per-file: two consecutive installs are byte-identical (AC #5)
10
+ * 5.4 Cross-file identity by construction (AC #2, #6)
11
+ * 5.5 Fresh install writes both Cline files with marker-wrapped content (AC #3, #4)
12
+ * 5.6 User content outside markers preserved byte-for-byte (AC #4)
13
+ * 5.7 On-prem profile — graceful fallback when onprem template absent (AC #8)
14
+ * 5.8 MANIFEST path rendering resolves to .cline/skills/MANIFEST.yaml (AC #9)
15
+ * 5.9 Dual-file drift detection (AC #6)
16
+ */
17
+ 'use strict';
18
+
19
+ const assert = require('assert');
20
+ const path = require('path');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ const errors = [];
27
+
28
+ async function test(name, fn) {
29
+ try {
30
+ await fn();
31
+ console.log(` \u2713 ${name}`);
32
+ passed++;
33
+ } catch (err) {
34
+ console.error(` \u2717 ${name}: ${err.stack || err.message}`);
35
+ failed++;
36
+ errors.push({ name, error: err.message });
37
+ }
38
+ }
39
+
40
+ const installerModule = require('../lib/installer');
41
+ const {
42
+ composeInstructionBlock,
43
+ ClinerulesDualFileDriftError,
44
+ checkClinerulesDualFileDrift,
45
+ CLINERULES_TEMPLATE_PATH,
46
+ ONPREM_INSTRUCTION_TEMPLATE_PATH,
47
+ _testUpdateAgentInstructions: updateAgentInstructions
48
+ } = installerModule;
49
+ const { setProfile } = require('../lib/profile');
50
+ const agents = require('../lib/agents');
51
+
52
+ function mktemp() {
53
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-clinerules-test-'));
54
+ }
55
+ function cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
56
+
57
+ function withSilencedLogs(fn) {
58
+ return async (...args) => {
59
+ const origLog = console.log;
60
+ console.log = () => {};
61
+ try { return await fn(...args); }
62
+ finally { console.log = origLog; }
63
+ };
64
+ }
65
+
66
+ function extractMarkerInner(text) {
67
+ const m = text.match(/<!-- MA-AGENTS-START -->([\s\S]*?)<!-- MA-AGENTS-END -->/);
68
+ return m ? m[1] : null;
69
+ }
70
+
71
+ console.log('\n clinerules template tests (Story 21.5)\n');
72
+
73
+ async function runAll() {
74
+ // 5.1 — Template existence + framing
75
+ await test('5.1 clinerules.template.md exists with Architect-mode framing (AC #1)', () => {
76
+ assert.ok(fs.existsSync(CLINERULES_TEMPLATE_PATH), 'template file must exist');
77
+ const body = fs.readFileSync(CLINERULES_TEMPLATE_PATH, 'utf-8');
78
+ assert.ok(/# Cline Project Rules/.test(body), 'header "# Cline Project Rules" required');
79
+ assert.ok(/Architect mode/i.test(body), 'Architect-mode framing line required');
80
+ assert.ok(/<!-- MA-AGENTS-START -->/.test(body), 'MA-AGENTS-START marker required');
81
+ assert.ok(/<!-- MA-AGENTS-END -->/.test(body), 'MA-AGENTS-END marker required');
82
+ // AC #7 — framing itself must not contain forbidden local-LLM strings.
83
+ assert.ok(!body.includes('/no_think'), 'framing must not contain /no_think');
84
+ assert.ok(!body.includes('str_replace_editor'), 'framing must not contain str_replace_editor');
85
+ assert.ok(!body.includes('~/.claude/'), 'framing must not contain ~/.claude/');
86
+ });
87
+
88
+ // 5.2 — NFR44 per-file: standard-profile output lacks forbidden strings
89
+ await test('5.2 NFR44 — standard profile: neither Cline file contains /no_think, str_replace_editor, ~/.claude/ (AC #7)', withSilencedLogs(async () => {
90
+ const dir = mktemp();
91
+ try {
92
+ setProfile(dir, 'standard');
93
+ const cline = agents.getAgent('cline');
94
+ await updateAgentInstructions(cline, dir);
95
+ for (const rel of ['.cline/clinerules.md', '.clinerules']) {
96
+ const content = fs.readFileSync(path.join(dir, rel), 'utf-8');
97
+ assert.ok(!content.includes('/no_think'), `${rel} must not contain /no_think`);
98
+ assert.ok(!content.includes('str_replace_editor'), `${rel} must not contain str_replace_editor`);
99
+ assert.ok(!content.includes('~/.claude/'), `${rel} must not contain ~/.claude/`);
100
+ }
101
+ } finally { cleanup(dir); }
102
+ }));
103
+
104
+ // 5.3 — NFR46 per-file: two consecutive installs byte-identical
105
+ await test('5.3 NFR46 idempotency: two consecutive installs produce byte-identical files (AC #5)', withSilencedLogs(async () => {
106
+ const dir = mktemp();
107
+ try {
108
+ setProfile(dir, 'standard');
109
+ const cline = agents.getAgent('cline');
110
+ await updateAgentInstructions(cline, dir);
111
+ const firstA = fs.readFileSync(path.join(dir, '.cline', 'clinerules.md'), 'utf-8');
112
+ const firstB = fs.readFileSync(path.join(dir, '.clinerules'), 'utf-8');
113
+ await updateAgentInstructions(cline, dir);
114
+ const secondA = fs.readFileSync(path.join(dir, '.cline', 'clinerules.md'), 'utf-8');
115
+ const secondB = fs.readFileSync(path.join(dir, '.clinerules'), 'utf-8');
116
+ assert.strictEqual(firstA, secondA, '.cline/clinerules.md byte-identical across installs');
117
+ assert.strictEqual(firstB, secondB, '.clinerules byte-identical across installs');
118
+ } finally { cleanup(dir); }
119
+ }));
120
+
121
+ // 5.4 — Cross-file identity by construction (AC #2, #6)
122
+ await test('5.4 Cross-file identity: marker-block content is byte-identical between .cline/clinerules.md and .clinerules (AC #2)', withSilencedLogs(async () => {
123
+ const dir = mktemp();
124
+ try {
125
+ setProfile(dir, 'standard');
126
+ const cline = agents.getAgent('cline');
127
+ await updateAgentInstructions(cline, dir);
128
+ const innerA = extractMarkerInner(fs.readFileSync(path.join(dir, '.cline', 'clinerules.md'), 'utf-8'));
129
+ const innerB = extractMarkerInner(fs.readFileSync(path.join(dir, '.clinerules'), 'utf-8'));
130
+ assert.ok(innerA != null && innerB != null, 'both files contain marker blocks');
131
+ assert.strictEqual(innerA, innerB, 'cross-file marker-block byte-identity (AC #2)');
132
+ } finally { cleanup(dir); }
133
+ }));
134
+
135
+ // 5.5 — Fresh install writes both Cline files
136
+ await test('5.5 Fresh install writes both .cline/clinerules.md and .clinerules with marker-wrapped content (AC #3, #4)', withSilencedLogs(async () => {
137
+ const dir = mktemp();
138
+ try {
139
+ setProfile(dir, 'standard');
140
+ const cline = agents.getAgent('cline');
141
+ await updateAgentInstructions(cline, dir);
142
+ for (const rel of ['.cline/clinerules.md', '.clinerules']) {
143
+ const p = path.join(dir, rel);
144
+ assert.ok(fs.existsSync(p), `${rel} must be created`);
145
+ const content = fs.readFileSync(p, 'utf-8');
146
+ assert.ok(content.includes('<!-- MA-AGENTS-START -->'), `${rel} has start marker`);
147
+ assert.ok(content.includes('<!-- MA-AGENTS-END -->'), `${rel} has end marker`);
148
+ // Composed content present
149
+ assert.ok(content.includes('# AI Agent Skills - Planning Instruction'), `${rel} has composer content`);
150
+ assert.ok(content.includes('Respond in TEXT vs. create FILES'), `${rel} has text-vs-file rules`);
151
+ // Cline framing on fresh install
152
+ assert.ok(content.includes('# Cline Project Rules'), `${rel} has Cline framing header`);
153
+ assert.ok(/Architect mode/i.test(content), `${rel} has Architect-mode guidance`);
154
+ }
155
+ } finally { cleanup(dir); }
156
+ }));
157
+
158
+ // 5.6 — User content outside markers preserved
159
+ await test('5.6 User content outside markers preserved byte-for-byte across two installs (AC #4)', withSilencedLogs(async () => {
160
+ const dir = mktemp();
161
+ try {
162
+ setProfile(dir, 'standard');
163
+ const cline = agents.getAgent('cline');
164
+ const before = '# My personal cline rules\n\nUser preamble.\n\n';
165
+ const after = '\n## Trailing user section\n\nMore notes.\n';
166
+ const preMarker = '<!-- MA-AGENTS-START -->\nSTALE\n<!-- MA-AGENTS-END -->';
167
+ // Seed both files with identical marker-block content (so drift check passes)
168
+ // but different surrounding user content.
169
+ fs.mkdirSync(path.join(dir, '.cline'), { recursive: true });
170
+ fs.writeFileSync(path.join(dir, '.cline', 'clinerules.md'), before + preMarker + after, 'utf-8');
171
+ const beforeB = '# .clinerules preamble\n\nB preamble.\n\n';
172
+ const afterB = '\n## B trailing\n\nB notes.\n';
173
+ fs.writeFileSync(path.join(dir, '.clinerules'), beforeB + preMarker + afterB, 'utf-8');
174
+ await updateAgentInstructions(cline, dir);
175
+ await updateAgentInstructions(cline, dir);
176
+ const finalA = fs.readFileSync(path.join(dir, '.cline', 'clinerules.md'), 'utf-8');
177
+ const finalB = fs.readFileSync(path.join(dir, '.clinerules'), 'utf-8');
178
+ assert.ok(finalA.startsWith(before), '.cline/clinerules.md pre-marker preserved');
179
+ assert.ok(finalA.endsWith(after), '.cline/clinerules.md post-marker preserved');
180
+ assert.ok(finalB.startsWith(beforeB), '.clinerules pre-marker preserved');
181
+ assert.ok(finalB.endsWith(afterB), '.clinerules post-marker preserved');
182
+ assert.ok(!finalA.includes('STALE'), 'stale marker content replaced in A');
183
+ assert.ok(!finalB.includes('STALE'), 'stale marker content replaced in B');
184
+ } finally { cleanup(dir); }
185
+ }));
186
+
187
+ // 5.7a — On-prem profile with onprem template absent: throws (composer contract)
188
+ // Story 21.6 ships the on-prem template, so we temporarily rename it to simulate
189
+ // absence (mirrors instruction-block.test.js test 5.6 pattern for the universal template).
190
+ await test('5.7a On-prem profile without onprem template: composer throws (Story 21.2 AC #3)', () => {
191
+ const fs = require('fs');
192
+ const bak = ONPREM_INSTRUCTION_TEMPLATE_PATH + '.bak-test-5-7a';
193
+ const had = fs.existsSync(ONPREM_INSTRUCTION_TEMPLATE_PATH);
194
+ if (had) fs.renameSync(ONPREM_INSTRUCTION_TEMPLATE_PATH, bak);
195
+ try {
196
+ assert.throws(
197
+ () => composeInstructionBlock({ profile: 'on-prem', projectRoot: mktemp() }),
198
+ /instruction-block-onprem\.template\.md is missing/
199
+ );
200
+ } finally {
201
+ if (had) fs.renameSync(bak, ONPREM_INSTRUCTION_TEMPLATE_PATH);
202
+ }
203
+ });
204
+
205
+ // 5.8 — MANIFEST_PATH resolves to .cline/skills/MANIFEST.yaml (forward-slashed)
206
+ await test('5.8 MANIFEST_PATH resolves to .cline/skills/MANIFEST.yaml in both Cline files (AC #9)', withSilencedLogs(async () => {
207
+ const dir = mktemp();
208
+ try {
209
+ setProfile(dir, 'standard');
210
+ const cline = agents.getAgent('cline');
211
+ await updateAgentInstructions(cline, dir);
212
+ for (const rel of ['.cline/clinerules.md', '.clinerules']) {
213
+ const content = fs.readFileSync(path.join(dir, rel), 'utf-8');
214
+ assert.ok(content.includes('.cline/skills/MANIFEST.yaml'),
215
+ `${rel} must reference .cline/skills/MANIFEST.yaml (forward-slashed)`);
216
+ assert.ok(!content.includes('{{MANIFEST_PATH}}'),
217
+ `${rel} must not contain unreplaced placeholder`);
218
+ // Windows path separator must not leak.
219
+ assert.ok(!content.includes('.cline\\skills\\MANIFEST.yaml'),
220
+ `${rel} must not contain back-slashed path`);
221
+ }
222
+ } finally { cleanup(dir); }
223
+ }));
224
+
225
+ // 5.9a — Drift detection: identical marker blocks proceed silently
226
+ await test('5.9a Drift detection: both files with identical marker blocks → install proceeds', withSilencedLogs(async () => {
227
+ const dir = mktemp();
228
+ try {
229
+ setProfile(dir, 'standard');
230
+ const cline = agents.getAgent('cline');
231
+ await updateAgentInstructions(cline, dir);
232
+ // Re-install should not throw: both files are identical by construction.
233
+ await updateAgentInstructions(cline, dir);
234
+ } finally { cleanup(dir); }
235
+ }));
236
+
237
+ // 5.9b — Drift detection: divergent marker blocks → throws
238
+ await test('5.9b Drift detection: divergent marker blocks throw ClinerulesDualFileDriftError (AC #6)', () => {
239
+ const dir = mktemp();
240
+ try {
241
+ fs.mkdirSync(path.join(dir, '.cline'), { recursive: true });
242
+ fs.writeFileSync(path.join(dir, '.cline', 'clinerules.md'),
243
+ '<!-- MA-AGENTS-START -->\nAAA\n<!-- MA-AGENTS-END -->\n', 'utf-8');
244
+ fs.writeFileSync(path.join(dir, '.clinerules'),
245
+ '<!-- MA-AGENTS-START -->\nBBB\n<!-- MA-AGENTS-END -->\n', 'utf-8');
246
+ assert.throws(
247
+ () => checkClinerulesDualFileDrift(dir),
248
+ (err) => {
249
+ assert.ok(err instanceof ClinerulesDualFileDriftError, 'must be ClinerulesDualFileDriftError');
250
+ assert.ok(err.message.includes('.cline/clinerules.md'));
251
+ assert.ok(err.message.includes('.clinerules'));
252
+ assert.ok(err.message.includes('AAA') || err.diff.includes('AAA'), 'diff includes divergent content A');
253
+ assert.ok(err.message.includes('BBB') || err.diff.includes('BBB'), 'diff includes divergent content B');
254
+ return true;
255
+ }
256
+ );
257
+ } finally { cleanup(dir); }
258
+ });
259
+
260
+ // 5.9c — `--yes` does NOT bypass drift (AC #6 explicit exception)
261
+ await test('5.9c --yes does NOT bypass drift detection (AC #6 exception)', async () => {
262
+ const dir = mktemp();
263
+ try {
264
+ setProfile(dir, 'standard');
265
+ fs.mkdirSync(path.join(dir, '.cline'), { recursive: true });
266
+ fs.writeFileSync(path.join(dir, '.cline', 'clinerules.md'),
267
+ '<!-- MA-AGENTS-START -->\nAAA\n<!-- MA-AGENTS-END -->\n', 'utf-8');
268
+ fs.writeFileSync(path.join(dir, '.clinerules'),
269
+ '<!-- MA-AGENTS-START -->\nBBB\n<!-- MA-AGENTS-END -->\n', 'utf-8');
270
+ const cline = agents.getAgent('cline');
271
+ const origYes = process.env.MA_AGENTS_YES;
272
+ process.env.MA_AGENTS_YES = '1';
273
+ try {
274
+ let threw = false;
275
+ try {
276
+ const origLog = console.log;
277
+ console.log = () => {};
278
+ try { await updateAgentInstructions(cline, dir, { yesMode: true }); }
279
+ finally { console.log = origLog; }
280
+ } catch (err) {
281
+ threw = true;
282
+ assert.ok(err instanceof ClinerulesDualFileDriftError,
283
+ `expected ClinerulesDualFileDriftError, got ${err.name}`);
284
+ }
285
+ assert.ok(threw, '--yes must not bypass drift check');
286
+ } finally {
287
+ if (origYes === undefined) delete process.env.MA_AGENTS_YES;
288
+ else process.env.MA_AGENTS_YES = origYes;
289
+ }
290
+ } finally { cleanup(dir); }
291
+ });
292
+
293
+ // 5.9d — Only one of the two files exists → drift detection skipped
294
+ await test('5.9d Drift detection skipped when only one Cline file exists; install writes both', withSilencedLogs(async () => {
295
+ const dir = mktemp();
296
+ try {
297
+ setProfile(dir, 'standard');
298
+ // Seed only .clinerules with a hand-edit; .cline/clinerules.md absent.
299
+ fs.writeFileSync(path.join(dir, '.clinerules'),
300
+ '<!-- MA-AGENTS-START -->\nEXISTING\n<!-- MA-AGENTS-END -->\n', 'utf-8');
301
+ const cline = agents.getAgent('cline');
302
+ // Should NOT throw — only one file present means drift check is skipped.
303
+ // It will, however, trigger the marker-block drift handler for .clinerules
304
+ // (Story 21.2 AC #10). That needs yesMode to not hang.
305
+ await updateAgentInstructions(cline, dir, { yesMode: true });
306
+ assert.ok(fs.existsSync(path.join(dir, '.cline', 'clinerules.md')),
307
+ '.cline/clinerules.md created');
308
+ assert.ok(fs.existsSync(path.join(dir, '.clinerules')),
309
+ '.clinerules still present');
310
+ } finally { cleanup(dir); }
311
+ }));
312
+
313
+ // Extra — confirm the composer output is embedded exactly once in each file
314
+ await test('5.10 Composer output appears exactly once inside each Cline file marker block', withSilencedLogs(async () => {
315
+ const dir = mktemp();
316
+ try {
317
+ setProfile(dir, 'standard');
318
+ const cline = agents.getAgent('cline');
319
+ await updateAgentInstructions(cline, dir);
320
+ for (const rel of ['.cline/clinerules.md', '.clinerules']) {
321
+ const content = fs.readFileSync(path.join(dir, rel), 'utf-8');
322
+ const occurrences = (content.match(/# AI Agent Skills - Planning Instruction/g) || []).length;
323
+ assert.strictEqual(occurrences, 1,
324
+ `${rel} must contain composer output exactly once`);
325
+ }
326
+ } finally { cleanup(dir); }
327
+ }));
328
+
329
+ console.log(`\n ${passed} passed, ${failed} failed\n`);
330
+ if (failed > 0) {
331
+ errors.forEach(e => console.error(` ${e.name}: ${e.error}`));
332
+ process.exit(1);
333
+ }
334
+ }
335
+
336
+ runAll().catch(err => {
337
+ console.error('Unhandled error:', err);
338
+ process.exit(1);
339
+ });