ma-agents 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.opencode/skills/.ma-agents.json +99 -99
  2. package/README.md +48 -2
  3. package/bin/cli.js +546 -2
  4. package/lib/bmad-extension/module-help.csv +8 -4
  5. package/lib/bmad-extension/skills/add-sprint/SKILL.md +126 -40
  6. package/lib/bmad-extension/skills/add-to-sprint/SKILL.md +116 -142
  7. package/lib/bmad-extension/skills/cleanup-done/.gitkeep +0 -0
  8. package/lib/bmad-extension/skills/cleanup-done/SKILL.md +159 -0
  9. package/lib/bmad-extension/skills/cleanup-done/bmad-skill-manifest.yaml +3 -0
  10. package/lib/bmad-extension/skills/create-bug-story/SKILL.md +75 -7
  11. package/lib/bmad-extension/skills/generate-backlog/SKILL.md +183 -0
  12. package/lib/bmad-extension/skills/generate-backlog/bmad-skill-manifest.yaml +3 -0
  13. package/lib/bmad-extension/skills/modify-sprint/SKILL.md +63 -0
  14. package/lib/bmad-extension/skills/prioritize-backlog/.gitkeep +0 -0
  15. package/lib/bmad-extension/skills/prioritize-backlog/SKILL.md +195 -0
  16. package/lib/bmad-extension/skills/prioritize-backlog/bmad-skill-manifest.yaml +3 -0
  17. package/lib/bmad-extension/skills/remove-from-sprint/.gitkeep +0 -0
  18. package/lib/bmad-extension/skills/remove-from-sprint/SKILL.md +163 -0
  19. package/lib/bmad-extension/skills/remove-from-sprint/bmad-skill-manifest.yaml +3 -0
  20. package/lib/bmad-extension/skills/sprint-status-view/SKILL.md +199 -138
  21. package/lib/bmad-extension/workflows/add-sprint/workflow.md +129 -39
  22. package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +3 -205
  23. package/lib/bmad-extension/workflows/modify-sprint/workflow.md +5 -0
  24. package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +3 -192
  25. package/lib/installer.js +109 -2
  26. package/lib/templates/project-context.template.md +1 -1
  27. package/package.json +2 -2
  28. package/test/cicd-remote-mode.test.js +224 -0
  29. package/test/config-layout.test.js +230 -0
  30. package/test/config-lost-on-update.test.js +363 -0
  31. package/test/config-storage.test.js +275 -0
  32. package/test/cross-repo-validation.test.js +201 -0
  33. package/test/generate-project-context.test.js +148 -2
  34. package/test/portable-paths.test.js +268 -0
  35. package/test/repo-layout.test.js +246 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for Story 16.4: Validate Cross-Repo Path Resolution
4
+ * Verifies config-driven path resolution and backward compatibility
5
+ */
6
+ 'use strict';
7
+
8
+ const assert = require('assert');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+
13
+ let passed = 0;
14
+ let failed = 0;
15
+ const errors = [];
16
+
17
+ function test(name, fn) {
18
+ try {
19
+ fn();
20
+ console.log(` \u2713 ${name}`);
21
+ passed++;
22
+ } catch (err) {
23
+ console.error(` \u2717 ${name}: ${err.message}`);
24
+ failed++;
25
+ errors.push({ name, error: err.message });
26
+ }
27
+ }
28
+
29
+ let writeRepoLayoutConfig, writeConfigField;
30
+ try {
31
+ ({ writeRepoLayoutConfig, writeConfigField } = require('../bin/cli.js'));
32
+ } catch (e) {
33
+ console.error('\n FATAL: Cannot load exports from cli.js');
34
+ console.error(' ', e.message);
35
+ process.exit(1);
36
+ }
37
+
38
+ // ─── Task 1: Audit verification ─────────────────────────────────────────────
39
+ console.log('\nTask 1 — Workflow audit: no hardcoded _bmad-output/ paths');
40
+
41
+ test('no workflow files contain hardcoded _bmad-output/', () => {
42
+ const bmadDir = path.join(__dirname, '..', '_bmad', 'bmm');
43
+ if (!fs.existsSync(bmadDir)) {
44
+ console.log(' SKIP: _bmad/bmm not found (BMAD not installed)');
45
+ return;
46
+ }
47
+
48
+ function scanDir(dir) {
49
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
50
+ const results = [];
51
+ for (const entry of entries) {
52
+ const full = path.join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ results.push(...scanDir(full));
55
+ } else if ((entry.name.endsWith('.md') || entry.name.endsWith('.yaml')) && entry.name !== 'config.yaml') {
56
+ // Exclude config.yaml — it stores resolved path values, not hardcoded references
57
+ const content = fs.readFileSync(full, 'utf-8');
58
+ // Look for literal _bmad-output/ that isn't in a comment or template variable context
59
+ const lines = content.split('\n');
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i];
62
+ if (line.includes('_bmad-output/') && !line.includes('{') && !line.includes('#')) {
63
+ results.push({ file: full, line: i + 1, content: line.trim() });
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return results;
69
+ }
70
+
71
+ const hardcoded = scanDir(bmadDir);
72
+ assert.strictEqual(hardcoded.length, 0,
73
+ `Found ${hardcoded.length} hardcoded _bmad-output/ references:\n` +
74
+ hardcoded.map(h => ` ${h.file}:${h.line}: ${h.content}`).join('\n'));
75
+ });
76
+
77
+ // ─── Task 2: Config-driven path resolution ──────────────────────────────────
78
+ console.log('\nTask 2 — Config writes planning_artifacts and implementation_artifacts');
79
+
80
+ test('single-repo: writes default artifact paths', () => {
81
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
82
+ const origCwd = process.cwd();
83
+ try {
84
+ process.chdir(tmpDir);
85
+ const configDir = path.join(tmpDir, '_bmad', 'bmm');
86
+ fs.mkdirSync(configDir, { recursive: true });
87
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
88
+ writeRepoLayoutConfig({
89
+ knowledgebase: { mode: 'same', path: '.' },
90
+ sprintManagement: { mode: 'same', path: '.' }
91
+ });
92
+ const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
93
+ assert.ok(content.includes('planning_artifacts: "_bmad-output/planning-artifacts"'),
94
+ `Expected default planning_artifacts. Got:\n${content}`);
95
+ assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'),
96
+ `Expected default implementation_artifacts. Got:\n${content}`);
97
+ } finally {
98
+ process.chdir(origCwd);
99
+ fs.rmSync(tmpDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test('multi-repo: writes external artifact paths', () => {
104
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
105
+ const origCwd = process.cwd();
106
+ try {
107
+ process.chdir(tmpDir);
108
+ const configDir = path.join(tmpDir, '_bmad', 'bmm');
109
+ fs.mkdirSync(configDir, { recursive: true });
110
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
111
+ writeRepoLayoutConfig({
112
+ knowledgebase: { mode: 'local', path: 'd:\\Code\\kb-repo' },
113
+ sprintManagement: { mode: 'local', path: 'd:\\Code\\sprint-repo' }
114
+ });
115
+ const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
116
+ assert.ok(content.includes('planning_artifacts: "d:/Code/kb-repo/_bmad-output/planning-artifacts"'),
117
+ `Expected external planning_artifacts. Got:\n${content}`);
118
+ assert.ok(content.includes('implementation_artifacts: "d:/Code/sprint-repo/_bmad-output/implementation-artifacts"'),
119
+ `Expected external implementation_artifacts. Got:\n${content}`);
120
+ } finally {
121
+ process.chdir(origCwd);
122
+ fs.rmSync(tmpDir, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test('mixed mode: kb external, sprint same', () => {
127
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
128
+ const origCwd = process.cwd();
129
+ try {
130
+ process.chdir(tmpDir);
131
+ const configDir = path.join(tmpDir, '_bmad', 'bmm');
132
+ fs.mkdirSync(configDir, { recursive: true });
133
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
134
+ writeRepoLayoutConfig({
135
+ knowledgebase: { mode: 'local', path: '/ext/kb' },
136
+ sprintManagement: { mode: 'same', path: '.' }
137
+ });
138
+ const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
139
+ assert.ok(content.includes('planning_artifacts: "/ext/kb/_bmad-output/planning-artifacts"'),
140
+ `Got:\n${content}`);
141
+ assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'),
142
+ `Got:\n${content}`);
143
+ } finally {
144
+ process.chdir(origCwd);
145
+ fs.rmSync(tmpDir, { recursive: true, force: true });
146
+ }
147
+ });
148
+
149
+ // ─── Task 3: Single-repo backward compatibility ─────────────────────────────
150
+ console.log('\nTask 3 — Single-repo backward compatibility');
151
+
152
+ test('single-repo config matches pre-Epic-16 defaults', () => {
153
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
154
+ const origCwd = process.cwd();
155
+ try {
156
+ process.chdir(tmpDir);
157
+ const configDir = path.join(tmpDir, '_bmad', 'bmm');
158
+ fs.mkdirSync(configDir, { recursive: true });
159
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), '{}');
160
+ writeRepoLayoutConfig({
161
+ knowledgebase: { mode: 'same', path: '.' },
162
+ sprintManagement: { mode: 'same', path: '.' }
163
+ });
164
+ const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
165
+ // These are the default relative paths that workflows used before Epic 16
166
+ assert.ok(content.includes('planning_artifacts: "_bmad-output/planning-artifacts"'));
167
+ assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'));
168
+ assert.ok(content.includes('knowledgebase_path: "."'));
169
+ assert.ok(content.includes('sprint_management_path: "."'));
170
+ } finally {
171
+ process.chdir(origCwd);
172
+ fs.rmSync(tmpDir, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ // ─── Task 4: Source-of-truth precedence ─────────────────────────────────────
177
+ console.log('\nTask 4 — Source-of-truth precedence documented');
178
+
179
+ test('validation report exists', () => {
180
+ const reportPath = path.join(__dirname, '..', '_bmad-output', 'implementation-artifacts', '16-4-validation-report.md');
181
+ assert.ok(fs.existsSync(reportPath), 'Validation report should exist');
182
+ const content = fs.readFileSync(reportPath, 'utf-8');
183
+ assert.ok(content.includes('Source-of-Truth Precedence'), 'Should document precedence');
184
+ assert.ok(content.includes('PRIMARY'), 'Should identify primary source');
185
+ assert.ok(content.includes('config.yaml'), 'Should mention config.yaml as primary');
186
+ });
187
+
188
+ test('path accessibility: fs.accessSync pattern available', () => {
189
+ // Verify fs.accessSync works for testing path accessibility
190
+ assert.ok(typeof fs.accessSync === 'function', 'fs.accessSync should be available');
191
+ // Test with known accessible path
192
+ assert.doesNotThrow(() => fs.accessSync(__dirname, fs.constants.R_OK));
193
+ });
194
+
195
+ // ─── Summary ────────────────────────────────────────────────────────────────
196
+ console.log(`\n${passed} passed, ${failed} failed`);
197
+ if (errors.length > 0) {
198
+ console.log('\nFailed tests:');
199
+ errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
200
+ }
201
+ if (failed > 0) process.exit(1);
@@ -47,9 +47,9 @@ async function test(name, fn) {
47
47
  // Template path derived directly — not imported from module (avoids leaking internals)
48
48
  const _TEMPLATE_PATH = path.join(__dirname, '..', 'lib', 'templates', 'project-context.template.md');
49
49
 
50
- let generateProjectContext, updateProjectContextManifestPaths;
50
+ let generateProjectContext, updateProjectContextManifestPaths, generateRepoLayoutSection, updateProjectContextRepoLayout;
51
51
  try {
52
- ({ generateProjectContext, _updateProjectContextManifestPaths: updateProjectContextManifestPaths } = require('../lib/installer'));
52
+ ({ generateProjectContext, _updateProjectContextManifestPaths: updateProjectContextManifestPaths, generateRepoLayoutSection, updateProjectContextRepoLayout } = require('../lib/installer'));
53
53
  } catch (e) {
54
54
  console.error('\n FATAL: Cannot load installer module');
55
55
  console.error(' ', e.message);
@@ -326,6 +326,152 @@ async function runAll() {
326
326
  fs.rmSync(tmpDir, { recursive: true });
327
327
  });
328
328
 
329
+ // ─── Story 16.3: generateRepoLayoutSection tests ─────────────────────────
330
+
331
+ // 5.1 null layout → empty string
332
+ await test('5.1 generateRepoLayoutSection: null layout returns empty string', async () => {
333
+ assert.strictEqual(generateRepoLayoutSection(null), '');
334
+ });
335
+
336
+ // 5.2 single-repo (both same) → empty string
337
+ await test('5.2 generateRepoLayoutSection: both same mode returns empty string', async () => {
338
+ const result = generateRepoLayoutSection({
339
+ knowledgebase: { mode: 'same', path: '.' },
340
+ sprintManagement: { mode: 'same', path: '.' }
341
+ });
342
+ assert.strictEqual(result, '');
343
+ });
344
+
345
+ // 5.3 multi-repo returns section with markers
346
+ await test('5.3 generateRepoLayoutSection: multi-repo returns section with markers', async () => {
347
+ const result = generateRepoLayoutSection({
348
+ knowledgebase: { mode: 'local', path: '/path/to/kb' },
349
+ sprintManagement: { mode: 'local', path: '/path/to/sprint' }
350
+ });
351
+ assert.ok(result.includes('<!-- ma-agents:repo-layout-start -->'), 'should have start marker');
352
+ assert.ok(result.includes('<!-- ma-agents:repo-layout-end -->'), 'should have end marker');
353
+ assert.ok(result.includes('### Repository Layout'), 'should have section header');
354
+ assert.ok(result.includes('/path/to/kb'), 'should include kb path');
355
+ assert.ok(result.includes('/path/to/sprint'), 'should include sprint path');
356
+ });
357
+
358
+ // 5.4 mixed config: one external + one same
359
+ await test('5.4 generateRepoLayoutSection: mixed config shows "current repository" for same', async () => {
360
+ const result = generateRepoLayoutSection({
361
+ knowledgebase: { mode: 'local', path: '/ext/kb' },
362
+ sprintManagement: { mode: 'same', path: '.' }
363
+ });
364
+ assert.ok(result.includes('/ext/kb'), 'should include external kb path');
365
+ assert.ok(result.includes('current repository (default)'), 'should show default for same mode');
366
+ assert.ok(result.includes('planning artifacts'), 'should have kb usage instruction');
367
+ assert.ok(!result.includes('sprint/story artifacts'), 'should NOT have sprint instruction when sprint is same');
368
+ });
369
+
370
+ // 5.5 path normalization
371
+ await test('5.5 generateRepoLayoutSection: normalizes backslashes', async () => {
372
+ const result = generateRepoLayoutSection({
373
+ knowledgebase: { mode: 'local', path: 'C:\\Users\\dev\\kb' },
374
+ sprintManagement: { mode: 'same', path: '.' }
375
+ });
376
+ assert.ok(result.includes('C:/Users/dev/kb'), 'should normalize backslashes');
377
+ assert.ok(!result.includes('\\'), 'should not contain backslashes');
378
+ });
379
+
380
+ // 5.6 backward compat: generateProjectContext without layout still works
381
+ await test('5.6 generateProjectContext: no layout param → no crash, no placeholder in output', async () => {
382
+ const content = await generateProjectContext('/some/root', [agent('.claude/skills')]);
383
+ assert.ok(!content.includes('{{REPO_LAYOUT_SECTION}}'), 'placeholder should be removed');
384
+ assert.ok(!content.includes('repo-layout-start'), 'should not have layout markers without layout');
385
+ });
386
+
387
+ // 5.7 generateProjectContext with multi-repo layout inserts section
388
+ await test('5.7 generateProjectContext: multi-repo layout inserts Repository Layout section', async () => {
389
+ const layout = {
390
+ knowledgebase: { mode: 'local', path: '/ext/kb' },
391
+ sprintManagement: { mode: 'same', path: '.' }
392
+ };
393
+ const content = await generateProjectContext('/some/root', [agent('.claude/skills')], layout);
394
+ assert.ok(content.includes('### Repository Layout'), 'should include Repository Layout section');
395
+ assert.ok(content.includes('/ext/kb'), 'should include kb path');
396
+ assert.ok(content.includes('<!-- ma-agents:repo-layout-start -->'), 'should have start marker');
397
+ });
398
+
399
+ // 5.8 no double blank lines when section is empty
400
+ await test('5.8 generateProjectContext: single-repo layout → no double blank lines', async () => {
401
+ const layout = {
402
+ knowledgebase: { mode: 'same', path: '.' },
403
+ sprintManagement: { mode: 'same', path: '.' }
404
+ };
405
+ const content = await generateProjectContext('/some/root', [agent('.claude/skills')], layout);
406
+ assert.ok(!content.includes('\n\n\n'), 'should not have triple newlines (double blank lines)');
407
+ assert.ok(!content.includes('repo-layout'), 'should not have any layout markers');
408
+ });
409
+
410
+ // ─── updateProjectContextRepoLayout tests ──────────────────────────────────
411
+
412
+ // 5.9 markers present → update content
413
+ await test('5.9 updateProjectContextRepoLayout: existing markers → updates content', async () => {
414
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
415
+ const outputPath = path.join(tmpDir, 'project-context.md');
416
+ const initial = [
417
+ '# Project Context',
418
+ '<!-- ma-agents:repo-layout-start -->',
419
+ '### Repository Layout',
420
+ '- old content',
421
+ '<!-- ma-agents:repo-layout-end -->',
422
+ '## Technology Stack'
423
+ ].join('\n');
424
+ fs.writeFileSync(outputPath, initial, 'utf8');
425
+
426
+ const result = await updateProjectContextRepoLayout(outputPath, {
427
+ knowledgebase: { mode: 'local', path: '/new/kb' },
428
+ sprintManagement: { mode: 'local', path: '/new/sprint' }
429
+ });
430
+ assert.strictEqual(result, true, 'should return true');
431
+ const content = fs.readFileSync(outputPath, 'utf8');
432
+ assert.ok(content.includes('/new/kb'), 'should have new kb path');
433
+ assert.ok(!content.includes('old content'), 'old content should be replaced');
434
+ fs.rmSync(tmpDir, { recursive: true });
435
+ });
436
+
437
+ // 5.10 no markers + multi-repo → inserts before Technology Stack
438
+ await test('5.10 updateProjectContextRepoLayout: no markers → inserts before Technology Stack', async () => {
439
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
440
+ const outputPath = path.join(tmpDir, 'project-context.md');
441
+ const initial = '# Project Context\nSome content\n\n## Technology Stack\nmore content';
442
+ fs.writeFileSync(outputPath, initial, 'utf8');
443
+
444
+ const result = await updateProjectContextRepoLayout(outputPath, {
445
+ knowledgebase: { mode: 'local', path: '/ext/kb' },
446
+ sprintManagement: { mode: 'same', path: '.' }
447
+ });
448
+ assert.strictEqual(result, true, 'should return true');
449
+ const content = fs.readFileSync(outputPath, 'utf8');
450
+ assert.ok(content.includes('### Repository Layout'), 'should insert section');
451
+ assert.ok(content.indexOf('Repository Layout') < content.indexOf('## Technology Stack'), 'section before Tech Stack');
452
+ fs.rmSync(tmpDir, { recursive: true });
453
+ });
454
+
455
+ // 5.11 single-repo + no markers → returns false (nothing to do)
456
+ await test('5.11 updateProjectContextRepoLayout: single-repo + no markers → returns false', async () => {
457
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
458
+ const outputPath = path.join(tmpDir, 'project-context.md');
459
+ fs.writeFileSync(outputPath, '# Project Context\n## Technology Stack\n', 'utf8');
460
+
461
+ const result = await updateProjectContextRepoLayout(outputPath, {
462
+ knowledgebase: { mode: 'same', path: '.' },
463
+ sprintManagement: { mode: 'same', path: '.' }
464
+ });
465
+ assert.strictEqual(result, false, 'should return false for single-repo with no markers');
466
+ fs.rmSync(tmpDir, { recursive: true });
467
+ });
468
+
469
+ // 5.12 missing layout fields → no crash
470
+ await test('5.12 generateRepoLayoutSection: missing layout fields returns empty string', async () => {
471
+ assert.strictEqual(generateRepoLayoutSection({}), '');
472
+ assert.strictEqual(generateRepoLayoutSection({ knowledgebase: null }), '');
473
+ });
474
+
329
475
  // ─── Report ────────────────────────────────────────────────────────────────
330
476
  console.log(`\n ${passed} passed, ${failed} failed\n`);
331
477
  if (failed > 0) {
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for Story 16.7: Portable Path Storage
4
+ * Tests toPortablePath(), resolveStoredPath(), and round-trip write/read with relative paths
5
+ */
6
+ 'use strict';
7
+
8
+ const assert = require('assert');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+
13
+ let passed = 0;
14
+ let failed = 0;
15
+ const errors = [];
16
+
17
+ function test(name, fn) {
18
+ try {
19
+ fn();
20
+ console.log(` \u2713 ${name}`);
21
+ passed++;
22
+ } catch (err) {
23
+ console.error(` \u2717 ${name}: ${err.message}`);
24
+ failed++;
25
+ errors.push({ name, error: err.message });
26
+ }
27
+ }
28
+
29
+ // ─── Load module ─────────────────────────────────────────────────────────────
30
+ let toPortablePath, resolveStoredPath, normalizePath, writeProjectLayoutYaml, writeRepoLayoutConfig, readExistingLayout;
31
+ try {
32
+ ({ toPortablePath, resolveStoredPath, normalizePath, writeProjectLayoutYaml, writeRepoLayoutConfig, readExistingLayout } = require('../bin/cli.js'));
33
+ } catch (e) {
34
+ console.error('\n FATAL: Cannot load exports from cli.js');
35
+ console.error(' ', e.message);
36
+ process.exit(1);
37
+ }
38
+
39
+ // Helper
40
+ function withTmpDir() {
41
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-test-'));
42
+ const origCwd = process.cwd();
43
+ process.chdir(tmpDir);
44
+ return { tmpDir, cleanup() { process.chdir(origCwd); fs.rmSync(tmpDir, { recursive: true, force: true }); } };
45
+ }
46
+
47
+ // ─── toPortablePath ────────────────────────────────────────────────────────
48
+ console.log('\ntoPortablePath');
49
+
50
+ test('sibling path stored as relative: d:/Code/kb from d:/Code/agents becomes ../kb', () => {
51
+ const result = toPortablePath('d:/Code/kb', 'd:/Code/agents');
52
+ assert.strictEqual(result.portable, '../kb');
53
+ assert.strictEqual(result.isAbsolute, false);
54
+ });
55
+
56
+ test('nested relative works: ../../other/kb', () => {
57
+ const result = toPortablePath('/home/user/other/kb', '/home/user/projects/myapp');
58
+ assert.strictEqual(result.portable, '../../other/kb');
59
+ assert.strictEqual(result.isAbsolute, false);
60
+ });
61
+
62
+ test('dot path stays as dot', () => {
63
+ const result = toPortablePath('.', '/any/root');
64
+ assert.strictEqual(result.portable, '.');
65
+ assert.strictEqual(result.isAbsolute, false);
66
+ });
67
+
68
+ test('null/empty returns dot', () => {
69
+ const result = toPortablePath('', '/any/root');
70
+ assert.strictEqual(result.portable, '.');
71
+ assert.strictEqual(result.isAbsolute, false);
72
+ });
73
+
74
+ test('deeply nested (>3 levels up) falls back to absolute', () => {
75
+ const result = toPortablePath('/a/b', '/a/b/c/d/e/f');
76
+ assert.strictEqual(result.isAbsolute, true, 'should be marked absolute');
77
+ // Path should be the normalized absolute path
78
+ assert.ok(!result.portable.startsWith('.'), 'should not be relative');
79
+ });
80
+
81
+ test('same directory returns dot', () => {
82
+ const result = toPortablePath('/home/user/project', '/home/user/project');
83
+ assert.strictEqual(result.portable, '.');
84
+ assert.strictEqual(result.isAbsolute, false);
85
+ });
86
+
87
+ test('subdirectory becomes simple relative', () => {
88
+ const result = toPortablePath('/home/user/project/sub', '/home/user/project');
89
+ assert.strictEqual(result.portable, 'sub');
90
+ assert.strictEqual(result.isAbsolute, false);
91
+ });
92
+
93
+ test('backslashes in result are normalized to forward slashes', () => {
94
+ // On Windows, path.relative may return backslashes
95
+ const result = toPortablePath('d:\\Code\\kb', 'd:\\Code\\agents');
96
+ assert.ok(!result.portable.includes('\\'), 'should not contain backslashes');
97
+ });
98
+
99
+ // ─── resolveStoredPath ─────────────────────────────────────────────────────
100
+ console.log('\nresolveStoredPath');
101
+
102
+ test('dot path stays as dot', () => {
103
+ assert.strictEqual(resolveStoredPath('.', '/any/root'), '.');
104
+ });
105
+
106
+ test('null returns dot (safe default)', () => {
107
+ assert.strictEqual(resolveStoredPath(null, '/any/root'), '.');
108
+ });
109
+
110
+ test('absolute path passes through unchanged', () => {
111
+ assert.strictEqual(resolveStoredPath('/absolute/path', '/any/root'), '/absolute/path');
112
+ });
113
+
114
+ test('relative path resolved from project root', () => {
115
+ const result = resolveStoredPath('../kb', '/home/user/project');
116
+ assert.strictEqual(result, normalizePath(path.resolve('/home/user/project', '../kb')));
117
+ });
118
+
119
+ test('Windows drive letter path passes through', () => {
120
+ assert.strictEqual(resolveStoredPath('d:/Code/kb', '/any/root'), 'd:/Code/kb');
121
+ });
122
+
123
+ // ─── writeProjectLayoutYaml with portable paths ───────────────────────────
124
+ console.log('\nwriteProjectLayoutYaml (portable paths)');
125
+
126
+ test('writes relative paths for sibling directories', () => {
127
+ const { tmpDir, cleanup } = withTmpDir();
128
+ try {
129
+ const siblingPath = path.join(path.dirname(tmpDir), 'kb-repo');
130
+ writeProjectLayoutYaml({
131
+ knowledgebase: { mode: 'local', path: siblingPath },
132
+ sprintManagement: { mode: 'same', path: '.' }
133
+ });
134
+ const content = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
135
+ assert.ok(content.includes('../kb-repo'), `should contain relative path, got: ${content}`);
136
+ assert.ok(!content.includes('PORTABILITY'), 'should not have portability warning');
137
+ } finally {
138
+ cleanup();
139
+ }
140
+ });
141
+
142
+ test('writes dot path unchanged for same-repo', () => {
143
+ const { tmpDir, cleanup } = withTmpDir();
144
+ try {
145
+ writeProjectLayoutYaml({
146
+ knowledgebase: { mode: 'local', path: '.' },
147
+ sprintManagement: { mode: 'local', path: path.join(path.dirname(tmpDir), 'sprint') }
148
+ });
149
+ const content = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
150
+ assert.ok(content.includes('path: "."'), `dot path should be preserved, got: ${content}`);
151
+ assert.ok(content.includes('../sprint'), 'sibling should be relative');
152
+ } finally {
153
+ cleanup();
154
+ }
155
+ });
156
+
157
+ // ─── writeRepoLayoutConfig with portable paths ────────────────────────────
158
+ console.log('\nwriteRepoLayoutConfig (portable paths)');
159
+
160
+ test('writes relative paths to config.yaml', () => {
161
+ const { tmpDir, cleanup } = withTmpDir();
162
+ try {
163
+ const configDir = path.join(tmpDir, '_bmad', 'bmm');
164
+ fs.mkdirSync(configDir, { recursive: true });
165
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), '# existing config\n{}');
166
+ const siblingKb = path.join(path.dirname(tmpDir), 'kb-repo');
167
+ writeRepoLayoutConfig({
168
+ knowledgebase: { mode: 'local', path: siblingKb },
169
+ sprintManagement: { mode: 'same', path: '.' }
170
+ });
171
+ const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
172
+ assert.ok(content.includes('knowledgebase_path: "../kb-repo"'), `should have relative path, got: ${content}`);
173
+ assert.ok(content.includes('sprint_management_path: "."'), `dot path preserved, got: ${content}`);
174
+ } finally {
175
+ cleanup();
176
+ }
177
+ });
178
+
179
+ test('derived artifact paths are relative when base is relative', () => {
180
+ const { tmpDir, cleanup } = withTmpDir();
181
+ try {
182
+ const configDir = path.join(tmpDir, '_bmad', 'bmm');
183
+ fs.mkdirSync(configDir, { recursive: true });
184
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), '# config\n{}');
185
+ const siblingKb = path.join(path.dirname(tmpDir), 'kb-repo');
186
+ writeRepoLayoutConfig({
187
+ knowledgebase: { mode: 'local', path: siblingKb },
188
+ sprintManagement: { mode: 'same', path: '.' }
189
+ });
190
+ const content = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf-8');
191
+ assert.ok(content.includes('planning_artifacts: "../kb-repo/_bmad-output/planning-artifacts"'),
192
+ `planning_artifacts should be relative, got: ${content}`);
193
+ assert.ok(content.includes('implementation_artifacts: "_bmad-output/implementation-artifacts"'),
194
+ `impl_artifacts should use default for ".", got: ${content}`);
195
+ } finally {
196
+ cleanup();
197
+ }
198
+ });
199
+
200
+ // ─── Round-trip: write relative → read → resolve to absolute ───────────────
201
+ console.log('\nRound-trip: write relative, read back, resolve to absolute');
202
+
203
+ test('round-trip: sibling path written as relative, read back as resolved absolute', () => {
204
+ const { tmpDir, cleanup } = withTmpDir();
205
+ try {
206
+ const siblingPath = normalizePath(path.join(path.dirname(tmpDir), 'kb-repo'));
207
+ writeProjectLayoutYaml({
208
+ knowledgebase: { mode: 'local', path: siblingPath },
209
+ sprintManagement: { mode: 'same', path: '.' }
210
+ });
211
+ // Verify file has relative path
212
+ const fileContent = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'project-layout.yaml'), 'utf-8');
213
+ assert.ok(fileContent.includes('../kb-repo'), 'file should store relative');
214
+
215
+ // Read back — should resolve to absolute
216
+ const result = readExistingLayout();
217
+ assert.ok(result, 'should read layout');
218
+ const resolvedKb = normalizePath(result.knowledgebase.path);
219
+ assert.strictEqual(resolvedKb, siblingPath, `should resolve back to original: got ${resolvedKb}`);
220
+ } finally {
221
+ cleanup();
222
+ }
223
+ });
224
+
225
+ test('round-trip: dot path stays as dot', () => {
226
+ const { tmpDir, cleanup } = withTmpDir();
227
+ try {
228
+ writeProjectLayoutYaml({
229
+ knowledgebase: { mode: 'local', path: path.join(path.dirname(tmpDir), 'kb') },
230
+ sprintManagement: { mode: 'same', path: '.' }
231
+ });
232
+ const result = readExistingLayout();
233
+ assert.strictEqual(result.sprintManagement.path, '.', 'dot should stay as dot');
234
+ } finally {
235
+ cleanup();
236
+ }
237
+ });
238
+
239
+ test('backward compat: absolute paths in existing files still work', () => {
240
+ const { tmpDir, cleanup } = withTmpDir();
241
+ try {
242
+ // Manually write absolute path (old format)
243
+ const dir = path.join(tmpDir, '_bmad-output');
244
+ fs.mkdirSync(dir, { recursive: true });
245
+ fs.writeFileSync(path.join(dir, 'project-layout.yaml'), [
246
+ 'knowledgebase:',
247
+ ' mode: local',
248
+ ' path: "/absolute/old/kb"',
249
+ 'sprint_management:',
250
+ ' mode: same',
251
+ ' path: "."',
252
+ ].join('\n'));
253
+
254
+ const result = readExistingLayout();
255
+ assert.ok(result, 'should read layout');
256
+ assert.strictEqual(result.knowledgebase.path, '/absolute/old/kb', 'absolute path should pass through');
257
+ } finally {
258
+ cleanup();
259
+ }
260
+ });
261
+
262
+ // ─── Summary ───────────────────────────────────────────────────────────────
263
+ console.log(`\n${passed} passed, ${failed} failed`);
264
+ if (errors.length > 0) {
265
+ console.log('\nFailed tests:');
266
+ errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
267
+ }
268
+ if (failed > 0) process.exit(1);