ma-agents 2.20.3 → 2.22.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 (149) hide show
  1. package/.opencode/skills/.ma-agents.json +241 -0
  2. package/.opencode/skills/MANIFEST.yaml +254 -0
  3. package/.opencode/skills/ai-audit-trail/SKILL.md +23 -0
  4. package/.opencode/skills/auto-bug-detection/SKILL.md +169 -0
  5. package/.opencode/skills/cmake-best-practices/SKILL.md +64 -0
  6. package/.opencode/skills/cmake-best-practices/examples/cmake.md +59 -0
  7. package/.opencode/skills/code-documentation/SKILL.md +57 -0
  8. package/.opencode/skills/code-documentation/examples/cpp.md +29 -0
  9. package/.opencode/skills/code-documentation/examples/csharp.md +28 -0
  10. package/.opencode/skills/code-documentation/examples/javascript_typescript.md +28 -0
  11. package/.opencode/skills/code-documentation/examples/python.md +57 -0
  12. package/.opencode/skills/code-review/SKILL.md +43 -0
  13. package/.opencode/skills/commit-message/SKILL.md +79 -0
  14. package/.opencode/skills/cpp-best-practices/SKILL.md +234 -0
  15. package/.opencode/skills/cpp-best-practices/examples/modern-idioms.md +189 -0
  16. package/.opencode/skills/cpp-best-practices/examples/naming-and-organization.md +102 -0
  17. package/.opencode/skills/cpp-concurrency-safety/SKILL.md +60 -0
  18. package/.opencode/skills/cpp-concurrency-safety/examples/concurrency.md +73 -0
  19. package/.opencode/skills/cpp-const-correctness/SKILL.md +63 -0
  20. package/.opencode/skills/cpp-const-correctness/examples/const_correctness.md +54 -0
  21. package/.opencode/skills/cpp-memory-handling/SKILL.md +42 -0
  22. package/.opencode/skills/cpp-memory-handling/examples/modern-cpp.md +49 -0
  23. package/.opencode/skills/cpp-memory-handling/examples/smart-pointers.md +46 -0
  24. package/.opencode/skills/cpp-modern-composition/SKILL.md +64 -0
  25. package/.opencode/skills/cpp-modern-composition/examples/composition.md +51 -0
  26. package/.opencode/skills/cpp-robust-interfaces/SKILL.md +55 -0
  27. package/.opencode/skills/cpp-robust-interfaces/examples/interfaces.md +56 -0
  28. package/.opencode/skills/create-hardened-docker-skill/SKILL.md +637 -0
  29. package/.opencode/skills/create-hardened-docker-skill/scripts/create-all.sh +489 -0
  30. package/.opencode/skills/csharp-best-practices/SKILL.md +278 -0
  31. package/.opencode/skills/docker-hardening-verification/SKILL.md +28 -0
  32. package/.opencode/skills/docker-hardening-verification/scripts/verify-hardening.sh +39 -0
  33. package/.opencode/skills/docker-image-signing/SKILL.md +28 -0
  34. package/.opencode/skills/docker-image-signing/scripts/sign-image.sh +33 -0
  35. package/.opencode/skills/document-revision-history/SKILL.md +104 -0
  36. package/.opencode/skills/git-workflow-skill/SKILL.md +194 -0
  37. package/.opencode/skills/git-workflow-skill/hooks/commit-msg +61 -0
  38. package/.opencode/skills/git-workflow-skill/hooks/pre-commit +38 -0
  39. package/.opencode/skills/git-workflow-skill/hooks/prepare-commit-msg +56 -0
  40. package/.opencode/skills/git-workflow-skill/scripts/finish-feature.sh +192 -0
  41. package/.opencode/skills/git-workflow-skill/scripts/install-hooks.sh +55 -0
  42. package/.opencode/skills/git-workflow-skill/scripts/start-feature.sh +110 -0
  43. package/.opencode/skills/git-workflow-skill/scripts/validate-workflow.sh +229 -0
  44. package/.opencode/skills/js-ts-dependency-mgmt/SKILL.md +49 -0
  45. package/.opencode/skills/js-ts-dependency-mgmt/examples/dependency_mgmt.md +60 -0
  46. package/.opencode/skills/js-ts-security-skill/SKILL.md +64 -0
  47. package/.opencode/skills/js-ts-security-skill/scripts/verify-security.sh +136 -0
  48. package/.opencode/skills/logging-best-practices/SKILL.md +50 -0
  49. package/.opencode/skills/logging-best-practices/examples/cpp.md +36 -0
  50. package/.opencode/skills/logging-best-practices/examples/csharp.md +49 -0
  51. package/.opencode/skills/logging-best-practices/examples/javascript.md +77 -0
  52. package/.opencode/skills/logging-best-practices/examples/python.md +57 -0
  53. package/.opencode/skills/logging-best-practices/references/logging-standards.md +29 -0
  54. package/.opencode/skills/open-presentation/SKILL.md +35 -0
  55. package/.opencode/skills/opentelemetry-best-practices/SKILL.md +34 -0
  56. package/.opencode/skills/opentelemetry-best-practices/examples/go.md +32 -0
  57. package/.opencode/skills/opentelemetry-best-practices/examples/javascript.md +58 -0
  58. package/.opencode/skills/opentelemetry-best-practices/examples/python.md +37 -0
  59. package/.opencode/skills/opentelemetry-best-practices/references/otel-standards.md +37 -0
  60. package/.opencode/skills/python-best-practices/SKILL.md +385 -0
  61. package/.opencode/skills/python-dependency-mgmt/SKILL.md +42 -0
  62. package/.opencode/skills/python-dependency-mgmt/examples/dependency_mgmt.md +67 -0
  63. package/.opencode/skills/python-security-skill/SKILL.md +56 -0
  64. package/.opencode/skills/python-security-skill/examples/security.md +56 -0
  65. package/.opencode/skills/self-signed-cert/SKILL.md +42 -0
  66. package/.opencode/skills/self-signed-cert/scripts/generate-cert.ps1 +45 -0
  67. package/.opencode/skills/self-signed-cert/scripts/generate-cert.sh +43 -0
  68. package/.opencode/skills/skill-creator/SKILL.md +196 -0
  69. package/.opencode/skills/skill-creator/references/output-patterns.md +82 -0
  70. package/.opencode/skills/skill-creator/references/workflows.md +28 -0
  71. package/.opencode/skills/skill-creator/scripts/init_skill.py +208 -0
  72. package/.opencode/skills/skill-creator/scripts/package_skill.py +99 -0
  73. package/.opencode/skills/skill-creator/scripts/quick_validate.py +113 -0
  74. package/.opencode/skills/story-status-lookup/SKILL.md +78 -0
  75. package/.opencode/skills/test-accompanied-development/SKILL.md +50 -0
  76. package/.opencode/skills/test-generator/SKILL.md +65 -0
  77. package/.opencode/skills/vercel-react-best-practices/SKILL.md +109 -0
  78. package/.opencode/skills/verify-hardened-docker-skill/SKILL.md +442 -0
  79. package/.opencode/skills/verify-hardened-docker-skill/scripts/verify-docker-hardening.sh +439 -0
  80. package/AiAudit.md +5 -0
  81. package/QUICK_START.md +11 -5
  82. package/README.md +52 -1
  83. package/bin/cli.js +31 -4
  84. package/docs/BMAD_AI_Development_Training.pptx +0 -0
  85. package/docs/technical-notes/context-persistence-research.md +434 -0
  86. package/docs/technical-notes/enforcement-hooks-research.md +415 -0
  87. package/lib/agents.js +34 -0
  88. package/lib/bmad-extension/agents/bmm-architect.customize.yaml +5 -0
  89. package/lib/bmad-extension/agents/bmm-bmad-master.customize.yaml +5 -0
  90. package/lib/bmad-extension/agents/bmm-cyber.customize.yaml +30 -0
  91. package/lib/bmad-extension/agents/bmm-dev.customize.yaml +5 -0
  92. package/lib/bmad-extension/agents/bmm-devops.customize.yaml +30 -0
  93. package/lib/bmad-extension/agents/bmm-mil498.customize.yaml +42 -0
  94. package/lib/bmad-extension/agents/bmm-pm.customize.yaml +5 -0
  95. package/lib/bmad-extension/agents/bmm-qa.customize.yaml +5 -0
  96. package/lib/bmad-extension/agents/bmm-sm.customize.yaml +5 -0
  97. package/lib/bmad-extension/agents/bmm-sre.customize.yaml +30 -0
  98. package/lib/bmad-extension/agents/bmm-tech-writer.customize.yaml +5 -0
  99. package/lib/bmad-extension/agents/bmm-ux-designer.customize.yaml +5 -0
  100. package/lib/bmad-extension/module-help.csv +7 -0
  101. package/lib/bmad-extension/module.yaml +3 -0
  102. package/lib/bmad-extension/workflows/add-sprint/workflow.md +112 -0
  103. package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +206 -0
  104. package/lib/bmad-extension/workflows/create-bug-story/workflow.md +186 -0
  105. package/lib/bmad-extension/workflows/modify-sprint/workflow.md +250 -0
  106. package/lib/bmad-extension/workflows/project-context-expansion/workflow.md +229 -0
  107. package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +193 -0
  108. package/lib/bmad.js +168 -36
  109. package/lib/hooks/claude-code/verify-manifest.js +56 -0
  110. package/lib/installer.js +282 -1
  111. package/lib/methodology/BMAD_AI_Development_Training.pptx +0 -0
  112. package/lib/methodology/version.json +7 -0
  113. package/lib/skill-authoring.js +732 -0
  114. package/lib/templates/project-context.template.md +47 -0
  115. package/opencode.json +8 -0
  116. package/package.json +2 -2
  117. package/skills/auto-bug-detection/SKILL.md +165 -0
  118. package/skills/auto-bug-detection/skill.json +8 -0
  119. package/skills/code-review/SKILL.md +40 -0
  120. package/skills/cpp-best-practices/SKILL.md +230 -0
  121. package/skills/cpp-best-practices/examples/modern-idioms.md +189 -0
  122. package/skills/cpp-best-practices/examples/naming-and-organization.md +102 -0
  123. package/skills/cpp-best-practices/skill.json +25 -0
  124. package/skills/csharp-best-practices/SKILL.md +274 -0
  125. package/skills/csharp-best-practices/skill.json +23 -0
  126. package/skills/git-workflow-skill/skill.json +1 -1
  127. package/skills/open-presentation/SKILL.md +31 -0
  128. package/skills/open-presentation/skill.json +11 -0
  129. package/skills/python-best-practices/SKILL.md +381 -0
  130. package/skills/python-best-practices/skill.json +26 -0
  131. package/skills/story-status-lookup/SKILL.md +74 -0
  132. package/skills/story-status-lookup/skill.json +8 -0
  133. package/test/agent-injection-strategy.test.js +13 -7
  134. package/test/bmad-extension.test.js +237 -0
  135. package/test/bmad-output-policy.test.js +119 -0
  136. package/test/build-bmad-args.test.js +361 -0
  137. package/test/create-agent.test.js +232 -0
  138. package/test/enforcement-hooks.test.js +324 -0
  139. package/test/generate-project-context.test.js +337 -0
  140. package/test/integration-verification.test.js +402 -0
  141. package/test/opencode-agent.test.js +150 -0
  142. package/test/opencode-json-error.test.js +260 -0
  143. package/test/opencode-json-injection.test.js +256 -0
  144. package/test/opencode-json-merge.test.js +299 -0
  145. package/test/skill-authoring.test.js +272 -0
  146. package/test/skill-customize-agent.test.js +253 -0
  147. package/test/skill-mandatory.test.js +235 -0
  148. package/test/skill-validation.test.js +378 -0
  149. package/test/yes-flag.test.js +1 -1
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for Story 8.5: Per-Agent Enforcement Hooks Research
4
+ *
5
+ * Tests the Claude Code hook deployment/removal in installer.js
6
+ * and the verify-manifest.js hook script.
7
+ */
8
+ 'use strict';
9
+
10
+ const assert = require('assert');
11
+ const fs = require('fs-extra');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const { execSync } = require('child_process');
15
+
16
+ let passed = 0;
17
+ let failed = 0;
18
+ const errors = [];
19
+
20
+ function test(name, fn) {
21
+ try {
22
+ fn();
23
+ console.log(` \u2713 ${name}`);
24
+ passed++;
25
+ } catch (err) {
26
+ console.error(` \u2717 ${name}: ${err.message}`);
27
+ failed++;
28
+ errors.push({ name, error: err.message });
29
+ }
30
+ }
31
+
32
+ async function asyncTest(name, fn) {
33
+ try {
34
+ await fn();
35
+ console.log(` \u2713 ${name}`);
36
+ passed++;
37
+ } catch (err) {
38
+ console.error(` \u2717 ${name}: ${err.message}`);
39
+ failed++;
40
+ errors.push({ name, error: err.message });
41
+ }
42
+ }
43
+
44
+ const { deployClaudeCodeHook, removeClaudeCodeHook } = require('../lib/installer');
45
+
46
+ // Helper to create a temp directory for each test
47
+ function createTempDir() {
48
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-'));
49
+ }
50
+
51
+ /**
52
+ * Helper: run the hook script with JSON input via a temp file piped to stdin.
53
+ * Works cross-platform (avoids shell quoting issues with echo).
54
+ */
55
+ function runHookWithInput(inputObj) {
56
+ const hs = path.join(__dirname, '..', 'lib', 'hooks', 'claude-code', 'verify-manifest.js');
57
+ const tmpInput = path.join(os.tmpdir(), `hook-input-${Date.now()}.json`);
58
+ fs.writeFileSync(tmpInput, typeof inputObj === 'string' ? inputObj : JSON.stringify(inputObj), 'utf-8');
59
+ try {
60
+ return execSync(`node "${hs}" < "${tmpInput}"`, {
61
+ encoding: 'utf-8',
62
+ timeout: 5000
63
+ });
64
+ } finally {
65
+ fs.removeSync(tmpInput);
66
+ }
67
+ }
68
+
69
+ // ─── Task 1: deployClaudeCodeHook() tests ───────────────────────────────────
70
+
71
+ console.log('\nTask 1 — deployClaudeCodeHook()');
72
+
73
+ asyncTest('1.1: creates settings.json with hook when file does not exist', async () => {
74
+ const tmpDir = createTempDir();
75
+ try {
76
+ await deployClaudeCodeHook(tmpDir);
77
+
78
+ const settingsPath = path.join(tmpDir, '.claude', 'settings.json');
79
+ assert.ok(fs.existsSync(settingsPath), 'settings.json should be created');
80
+
81
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
82
+ assert.ok(settings.hooks, 'hooks key should exist');
83
+ assert.ok(settings.hooks.SessionStart, 'SessionStart hooks should exist');
84
+ assert.strictEqual(settings.hooks.SessionStart.length, 1);
85
+ assert.strictEqual(settings.hooks.SessionStart[0].matcher, 'startup');
86
+ assert.strictEqual(settings.hooks.SessionStart[0].hooks[0].type, 'command');
87
+ assert.ok(settings.hooks.SessionStart[0].hooks[0].command.includes('verify-manifest.js'));
88
+ } finally {
89
+ fs.removeSync(tmpDir);
90
+ }
91
+ }).then(() =>
92
+
93
+ asyncTest('1.2: preserves existing settings when adding hook', async () => {
94
+ const tmpDir = createTempDir();
95
+ try {
96
+ const settingsDir = path.join(tmpDir, '.claude');
97
+ await fs.ensureDir(settingsDir);
98
+ const existingSettings = {
99
+ permissions: {
100
+ allow: ['Bash(node:*)']
101
+ },
102
+ someOtherSetting: true
103
+ };
104
+ await fs.writeFile(
105
+ path.join(settingsDir, 'settings.json'),
106
+ JSON.stringify(existingSettings, null, 2),
107
+ 'utf-8'
108
+ );
109
+
110
+ await deployClaudeCodeHook(tmpDir);
111
+
112
+ const settings = JSON.parse(fs.readFileSync(path.join(settingsDir, 'settings.json'), 'utf-8'));
113
+ assert.ok(settings.permissions, 'existing permissions should be preserved');
114
+ assert.deepStrictEqual(settings.permissions.allow, ['Bash(node:*)']);
115
+ assert.strictEqual(settings.someOtherSetting, true);
116
+ assert.ok(settings.hooks.SessionStart, 'hook should be added');
117
+ } finally {
118
+ fs.removeSync(tmpDir);
119
+ }
120
+ })).then(() =>
121
+
122
+ asyncTest('1.3: preserves existing hooks when adding our hook', async () => {
123
+ const tmpDir = createTempDir();
124
+ try {
125
+ const settingsDir = path.join(tmpDir, '.claude');
126
+ await fs.ensureDir(settingsDir);
127
+ const existingSettings = {
128
+ hooks: {
129
+ SessionStart: [
130
+ {
131
+ matcher: 'startup',
132
+ hooks: [{ type: 'command', command: 'echo "existing hook"' }]
133
+ }
134
+ ],
135
+ PreToolUse: [
136
+ {
137
+ matcher: 'Bash',
138
+ hooks: [{ type: 'command', command: 'echo "pre-tool"' }]
139
+ }
140
+ ]
141
+ }
142
+ };
143
+ await fs.writeFile(
144
+ path.join(settingsDir, 'settings.json'),
145
+ JSON.stringify(existingSettings, null, 2),
146
+ 'utf-8'
147
+ );
148
+
149
+ await deployClaudeCodeHook(tmpDir);
150
+
151
+ const settings = JSON.parse(fs.readFileSync(path.join(settingsDir, 'settings.json'), 'utf-8'));
152
+ assert.strictEqual(settings.hooks.SessionStart.length, 2, 'should have 2 SessionStart hook groups');
153
+ assert.ok(settings.hooks.PreToolUse, 'PreToolUse hooks should be preserved');
154
+ } finally {
155
+ fs.removeSync(tmpDir);
156
+ }
157
+ })).then(() =>
158
+
159
+ asyncTest('1.4: is idempotent — does not duplicate hook on second call', async () => {
160
+ const tmpDir = createTempDir();
161
+ try {
162
+ await deployClaudeCodeHook(tmpDir);
163
+ await deployClaudeCodeHook(tmpDir);
164
+
165
+ const settingsPath = path.join(tmpDir, '.claude', 'settings.json');
166
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
167
+ assert.strictEqual(settings.hooks.SessionStart.length, 1, 'should not duplicate hook');
168
+ } finally {
169
+ fs.removeSync(tmpDir);
170
+ }
171
+ })).then(() => {
172
+
173
+ // ─── Task 2: removeClaudeCodeHook() tests ──────────────────────────────────
174
+
175
+ console.log('\nTask 2 — removeClaudeCodeHook()');
176
+
177
+ return asyncTest('2.1: removes our hook while preserving other hooks', async () => {
178
+ const tmpDir = createTempDir();
179
+ try {
180
+ const settingsDir = path.join(tmpDir, '.claude');
181
+ await fs.ensureDir(settingsDir);
182
+
183
+ // Deploy our hook first
184
+ await deployClaudeCodeHook(tmpDir);
185
+
186
+ // Add another hook manually
187
+ const settingsPath = path.join(settingsDir, 'settings.json');
188
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
189
+ settings.hooks.SessionStart.unshift({
190
+ matcher: 'startup',
191
+ hooks: [{ type: 'command', command: 'echo "other hook"' }]
192
+ });
193
+ settings.hooks.PreToolUse = [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo "bash"' }] }];
194
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
195
+
196
+ await removeClaudeCodeHook(tmpDir);
197
+
198
+ const updated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
199
+ assert.strictEqual(updated.hooks.SessionStart.length, 1, 'should keep other SessionStart hook');
200
+ assert.ok(updated.hooks.PreToolUse, 'PreToolUse hooks should be preserved');
201
+ assert.ok(!updated.hooks.SessionStart[0].hooks[0].command.includes('verify-manifest'), 'our hook should be removed');
202
+ } finally {
203
+ fs.removeSync(tmpDir);
204
+ }
205
+ });
206
+
207
+ }).then(() =>
208
+
209
+ asyncTest('2.2: cleans up empty hooks object when last hook removed', async () => {
210
+ const tmpDir = createTempDir();
211
+ try {
212
+ await deployClaudeCodeHook(tmpDir);
213
+ await removeClaudeCodeHook(tmpDir);
214
+
215
+ const settingsPath = path.join(tmpDir, '.claude', 'settings.json');
216
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
217
+ assert.ok(!settings.hooks, 'hooks key should be removed when empty');
218
+ } finally {
219
+ fs.removeSync(tmpDir);
220
+ }
221
+ })).then(() =>
222
+
223
+ asyncTest('2.3: no-op when settings.json does not exist', async () => {
224
+ const tmpDir = createTempDir();
225
+ try {
226
+ // Should not throw
227
+ await removeClaudeCodeHook(tmpDir);
228
+ assert.ok(!fs.existsSync(path.join(tmpDir, '.claude', 'settings.json')));
229
+ } finally {
230
+ fs.removeSync(tmpDir);
231
+ }
232
+ })).then(() => {
233
+
234
+ // ─── Task 3: verify-manifest.js hook script tests ──────────────────────────
235
+
236
+ console.log('\nTask 3 — verify-manifest.js hook script');
237
+
238
+ return asyncTest('3.1: outputs reminder when MANIFEST.yaml exists', async () => {
239
+ const tmpDir = createTempDir();
240
+ try {
241
+ const skillsDir = path.join(tmpDir, '.claude', 'skills');
242
+ await fs.ensureDir(skillsDir);
243
+ await fs.writeFile(path.join(skillsDir, 'MANIFEST.yaml'), '# test manifest\n', 'utf-8');
244
+
245
+ const result = runHookWithInput({ cwd: tmpDir, session_id: 'test', hook_event_name: 'SessionStart' });
246
+
247
+ assert.ok(result.includes('SKILL ENFORCEMENT ACTIVE'), 'should output enforcement reminder');
248
+ assert.ok(result.includes('MANIFEST.yaml'), 'should reference MANIFEST.yaml');
249
+ } finally {
250
+ fs.removeSync(tmpDir);
251
+ }
252
+ });
253
+
254
+ }).then(() =>
255
+
256
+ asyncTest('3.2: silent when no MANIFEST.yaml exists', async () => {
257
+ const tmpDir = createTempDir();
258
+ try {
259
+ const result = runHookWithInput({ cwd: tmpDir, session_id: 'test', hook_event_name: 'SessionStart' });
260
+ assert.strictEqual(result.trim(), '', 'should produce no output when no manifest');
261
+ } finally {
262
+ fs.removeSync(tmpDir);
263
+ }
264
+ })).then(() =>
265
+
266
+ asyncTest('3.3: exits 0 on malformed JSON input', async () => {
267
+ const tmpDir = createTempDir();
268
+ try {
269
+ // Pipe invalid JSON — should exit 0 (not crash)
270
+ const result = runHookWithInput('not json at all');
271
+ // If we get here, exit code was 0
272
+ assert.ok(true, 'should exit 0 on malformed input');
273
+ } finally {
274
+ fs.removeSync(tmpDir);
275
+ }
276
+ })).then(() => {
277
+
278
+ // ─── Task 4: Technical note document exists ─────────────────────────────────
279
+
280
+ console.log('\nTask 4 — Technical note document');
281
+
282
+ test('4.1: enforcement-hooks-research.md exists', () => {
283
+ const docPath = path.join(__dirname, '..', 'docs', 'technical-notes', 'enforcement-hooks-research.md');
284
+ assert.ok(fs.existsSync(docPath), 'technical note should exist');
285
+ });
286
+
287
+ test('4.2: document contains agent summary table', () => {
288
+ const docPath = path.join(__dirname, '..', 'docs', 'technical-notes', 'enforcement-hooks-research.md');
289
+ const content = fs.readFileSync(docPath, 'utf-8');
290
+ assert.ok(content.includes('| Agent |'), 'should contain agent summary table');
291
+ assert.ok(content.includes('Claude Code'), 'should reference Claude Code');
292
+ assert.ok(content.includes('Cursor'), 'should reference Cursor');
293
+ assert.ok(content.includes('GitHub Copilot'), 'should reference GitHub Copilot');
294
+ assert.ok(content.includes('Gemini'), 'should reference Gemini');
295
+ assert.ok(content.includes('Cline'), 'should reference Cline');
296
+ assert.ok(content.includes('Kilocode'), 'should reference Kilocode');
297
+ assert.ok(content.includes('Antigravity'), 'should reference Antigravity');
298
+ assert.ok(content.includes('BMAD'), 'should reference BMAD agents');
299
+ });
300
+
301
+ test('4.3: document contains recommendations section', () => {
302
+ const docPath = path.join(__dirname, '..', 'docs', 'technical-notes', 'enforcement-hooks-research.md');
303
+ const content = fs.readFileSync(docPath, 'utf-8');
304
+ assert.ok(content.includes('## Recommendations'), 'should contain recommendations section');
305
+ });
306
+
307
+ test('4.4: document contains prototype implementation section', () => {
308
+ const docPath = path.join(__dirname, '..', 'docs', 'technical-notes', 'enforcement-hooks-research.md');
309
+ const content = fs.readFileSync(docPath, 'utf-8');
310
+ assert.ok(content.includes('Prototype Implementation'), 'should contain prototype section');
311
+ assert.ok(content.includes('verify-manifest'), 'should reference the hook script');
312
+ });
313
+
314
+ // ─── Summary ────────────────────────────────────────────────────────────────
315
+
316
+ console.log(`\n${'─'.repeat(60)}`);
317
+ console.log(`Results: ${passed} passed, ${failed} failed`);
318
+ if (errors.length > 0) {
319
+ console.log('\nFailures:');
320
+ errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
321
+ process.exit(1);
322
+ }
323
+
324
+ });
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for generateProjectContext() and updateProjectContextManifestPaths() — Stories 13.1, 13.2
4
+ *
5
+ * generateProjectContext AC coverage:
6
+ * 3.1 single platform → one MANIFEST path in output (AC #14)
7
+ * 3.2 multiple platforms → correct count, correct order (AC #15)
8
+ * 3.3 all relative paths — no absolute path segments (AC #9)
9
+ * 3.4 {{MANIFEST_PATHS_LIST}} fully replaced (AC #10)
10
+ * 3.5 empty agents array → fallback message, no error (AC #12)
11
+ * 3.6 missing template → Error with path in message (AC #13)
12
+ * 3.7 template version comment present in output (AC #4, NFR25)
13
+ * 3.8 function does NOT call fs.writeFile / fs.pathExists (AC #11)
14
+ * 3.9 agent without skillsDir is skipped (no undefined/MANIFEST.yaml)
15
+ * 3.10 all agents lack skillsDir → fallback message
16
+ *
17
+ * updateProjectContextManifestPaths AC coverage:
18
+ * 4.1 markers present + new agent → file updated, returns true
19
+ * 4.2 already up to date → returns false, no write
20
+ * 4.3 no markers (old-format) → returns false, backward compatible
21
+ * 4.4 agents without skillsDir excluded from update
22
+ */
23
+ 'use strict';
24
+
25
+ const assert = require('assert');
26
+ const path = require('path');
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+
30
+ let passed = 0;
31
+ let failed = 0;
32
+ const errors = [];
33
+
34
+ async function test(name, fn) {
35
+ try {
36
+ await fn();
37
+ console.log(` ✓ ${name}`);
38
+ passed++;
39
+ } catch (err) {
40
+ console.error(` ✗ ${name}: ${err.message}`);
41
+ failed++;
42
+ errors.push({ name, error: err.message });
43
+ }
44
+ }
45
+
46
+ // ─── Module imports ─────────────────────────────────────────────────────────
47
+ // Template path derived directly — not imported from module (avoids leaking internals)
48
+ const _TEMPLATE_PATH = path.join(__dirname, '..', 'lib', 'templates', 'project-context.template.md');
49
+
50
+ let generateProjectContext, updateProjectContextManifestPaths;
51
+ try {
52
+ ({ generateProjectContext, _updateProjectContextManifestPaths: updateProjectContextManifestPaths } = require('../lib/installer'));
53
+ } catch (e) {
54
+ console.error('\n FATAL: Cannot load installer module');
55
+ console.error(' ', e.message);
56
+ process.exit(1);
57
+ }
58
+
59
+ // Helper: agent stub with skillsDir property
60
+ function agent(skillsDir) {
61
+ return { skillsDir };
62
+ }
63
+
64
+ // Helper: count backtick-wrapped MANIFEST.yaml path entries in content
65
+ function countManifestPaths(content) {
66
+ const matches = content.match(/`[^`]+\/MANIFEST\.yaml`/g) || [];
67
+ return matches.length;
68
+ }
69
+
70
+ console.log('\n generate-project-context unit tests\n');
71
+
72
+ async function runAll() {
73
+ // 3.7 Template version comment present
74
+ await test('3.7 template contains version comment <!-- ma-agents-template-version: 1.0 -->', async () => {
75
+ const content = await generateProjectContext('/some/root', [agent('.claude/skills')]);
76
+ assert.ok(
77
+ content.includes('<!-- ma-agents-template-version: 1.0 -->'),
78
+ 'version comment not found in output'
79
+ );
80
+ });
81
+
82
+ // 3.1 Single platform → exactly one MANIFEST path
83
+ await test('3.1 single platform → exactly one MANIFEST path entry', async () => {
84
+ const content = await generateProjectContext('/some/root', [agent('.claude/skills')]);
85
+ const count = countManifestPaths(content);
86
+ assert.strictEqual(count, 1, `expected 1 MANIFEST.yaml path entry, got ${count}`);
87
+ assert.ok(content.includes(' - `.claude/skills/MANIFEST.yaml`'), 'expected .claude/skills path');
88
+ });
89
+
90
+ // 3.2 Multiple platforms → correct count and order
91
+ await test('3.2 multiple platforms → correct count and manifest order', async () => {
92
+ const agents = [
93
+ agent('.claude/skills'),
94
+ agent('.cursor/skills'),
95
+ agent('.gemini/skills')
96
+ ];
97
+ const content = await generateProjectContext('/some/root', agents);
98
+ const count = countManifestPaths(content);
99
+ assert.strictEqual(count, 3, `expected 3 MANIFEST.yaml path entries, got ${count}`);
100
+ const claudeIdx = content.indexOf('.claude/skills/MANIFEST.yaml');
101
+ const cursorIdx = content.indexOf('.cursor/skills/MANIFEST.yaml');
102
+ const geminiIdx = content.indexOf('.gemini/skills/MANIFEST.yaml');
103
+ assert.ok(claudeIdx < cursorIdx, 'claude should appear before cursor');
104
+ assert.ok(cursorIdx < geminiIdx, 'cursor should appear before gemini');
105
+ });
106
+
107
+ // 3.3 All relative paths — no absolute path segments
108
+ await test('3.3 all paths are relative — no absolute path segments in output', async () => {
109
+ const agents = [
110
+ agent('.claude/skills'),
111
+ agent('.cursor/skills')
112
+ ];
113
+ const content = await generateProjectContext('/some/root', agents);
114
+ // Absolute paths start with / (unix) or X:\ (windows) inside backticks
115
+ const hasAbsolute = /`\/[^`]+MANIFEST\.yaml`/.test(content) ||
116
+ /`[A-Za-z]:\\[^`]+MANIFEST\.yaml`/.test(content);
117
+ assert.ok(!hasAbsolute, 'output contains absolute path segments');
118
+ });
119
+
120
+ // 3.4 {{MANIFEST_PATHS_LIST}} placeholder fully replaced
121
+ await test('3.4 {{MANIFEST_PATHS_LIST}} placeholder fully replaced', async () => {
122
+ const content = await generateProjectContext('/some/root', [agent('.claude/skills')]);
123
+ assert.ok(
124
+ !content.includes('{{MANIFEST_PATHS_LIST}}'),
125
+ 'placeholder {{MANIFEST_PATHS_LIST}} still present in output'
126
+ );
127
+ });
128
+
129
+ // 3.5 Empty agents array → fallback message, no error
130
+ await test('3.5 empty agents array → fallback message, no error thrown', async () => {
131
+ let content;
132
+ try {
133
+ content = await generateProjectContext('/some/root', []);
134
+ } catch (err) {
135
+ assert.fail(`should not throw with empty agents, got: ${err.message}`);
136
+ }
137
+ assert.ok(
138
+ content.includes('no agents installed — run ma-agents to install skills'),
139
+ 'fallback message not found in output'
140
+ );
141
+ assert.ok(!content.includes('{{MANIFEST_PATHS_LIST}}'), 'placeholder not replaced in fallback case');
142
+ });
143
+
144
+ // 3.5b null installedAgents → fallback message
145
+ await test('3.5b null installedAgents → fallback message, no error thrown', async () => {
146
+ let content;
147
+ try {
148
+ content = await generateProjectContext('/some/root', null);
149
+ } catch (err) {
150
+ assert.fail(`should not throw with null agents, got: ${err.message}`);
151
+ }
152
+ assert.ok(
153
+ content.includes('no agents installed — run ma-agents to install skills'),
154
+ 'fallback message not found in output'
155
+ );
156
+ });
157
+
158
+ // 3.6 Missing template → Error thrown with path in message (runs after others to avoid race)
159
+ await test('3.6 missing template file → Error thrown with template path in message', async () => {
160
+ const backupPath = _TEMPLATE_PATH + '.bak';
161
+ const templateExists = fs.existsSync(_TEMPLATE_PATH);
162
+ if (templateExists) {
163
+ fs.renameSync(_TEMPLATE_PATH, backupPath);
164
+ }
165
+ try {
166
+ try {
167
+ await generateProjectContext('/some/root', [agent('.claude/skills')]);
168
+ assert.fail('Expected an Error to be thrown for missing template');
169
+ } catch (err) {
170
+ assert.ok(err instanceof Error, 'thrown value should be an Error instance');
171
+ assert.ok(
172
+ err.message.includes(_TEMPLATE_PATH),
173
+ `error message should include template path. Got: ${err.message}`
174
+ );
175
+ }
176
+ } finally {
177
+ if (templateExists) {
178
+ fs.renameSync(backupPath, _TEMPLATE_PATH);
179
+ }
180
+ }
181
+ });
182
+
183
+ // 3.8 Function returns string content — does NOT write any file
184
+ await test('3.8 function returns content string and does not write files', async () => {
185
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-test-'));
186
+ const beforeFiles = fs.readdirSync(tmpDir);
187
+
188
+ const result = await generateProjectContext(tmpDir, [agent('.claude/skills')]);
189
+
190
+ const afterFiles = fs.readdirSync(tmpDir);
191
+ assert.strictEqual(typeof result, 'string', 'function should return a string');
192
+ assert.ok(result.length > 0, 'returned string should not be empty');
193
+ assert.deepStrictEqual(beforeFiles, afterFiles, 'function should not write any files to disk');
194
+
195
+ fs.rmdirSync(tmpDir);
196
+ });
197
+
198
+ // 3.9 Agent without skillsDir is silently skipped (finding #4 guard)
199
+ await test('3.9 agent with undefined skillsDir is skipped — no undefined/MANIFEST.yaml in output', async () => {
200
+ const agents = [
201
+ agent('.claude/skills'),
202
+ { id: 'legacy-agent' }, // no skillsDir
203
+ agent('.cursor/skills')
204
+ ];
205
+ const content = await generateProjectContext('/some/root', agents);
206
+ assert.ok(!content.includes('undefined/MANIFEST.yaml'), 'output must not contain undefined/MANIFEST.yaml');
207
+ const count = countManifestPaths(content);
208
+ assert.strictEqual(count, 2, `expected 2 valid paths (legacy agent skipped), got ${count}`);
209
+ });
210
+
211
+ // 3.10 All agents without skillsDir → fallback message (no valid agents)
212
+ await test('3.10 all agents lack skillsDir → fallback message rather than empty list', async () => {
213
+ const content = await generateProjectContext('/some/root', [{ id: 'a' }, { id: 'b' }]);
214
+ assert.ok(
215
+ content.includes('no agents installed — run ma-agents to install skills'),
216
+ 'fallback message should appear when no valid agents have skillsDir'
217
+ );
218
+ assert.ok(!content.includes('undefined/MANIFEST.yaml'), 'output must not contain undefined path');
219
+ });
220
+
221
+ // ─── updateProjectContextManifestPaths tests (finding #5, pipeline coverage) ───
222
+
223
+ // 4.1 Markers present + new agent → file updated, returns true
224
+ await test('4.1 updateProjectContextManifestPaths: markers present, new agent added → returns true and updates file', async () => {
225
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-update-test-'));
226
+ const outputPath = path.join(tmpDir, 'project-context.md');
227
+
228
+ // Write a file that simulates a generated project-context.md with one agent
229
+ const initial = [
230
+ '# Project Context',
231
+ '<!-- ma-agents:manifest-paths-start -->',
232
+ ' - `.claude/skills/MANIFEST.yaml`',
233
+ '<!-- ma-agents:manifest-paths-end -->',
234
+ '## Project-Specific Rules',
235
+ '<!-- user rules here -->'
236
+ ].join('\n');
237
+ fs.writeFileSync(outputPath, initial, 'utf8');
238
+
239
+ const result = await updateProjectContextManifestPaths(outputPath, [
240
+ agent('.claude/skills'),
241
+ agent('.gemini/skills')
242
+ ]);
243
+
244
+ assert.strictEqual(result, true, 'should return true when file was updated');
245
+ const updated = fs.readFileSync(outputPath, 'utf8');
246
+ assert.ok(updated.includes(' - `.claude/skills/MANIFEST.yaml`'), 'claude path should be present');
247
+ assert.ok(updated.includes(' - `.gemini/skills/MANIFEST.yaml`'), 'gemini path should be added');
248
+ assert.ok(updated.includes('<!-- user rules here -->'), 'user-written content must be preserved');
249
+
250
+ fs.rmSync(tmpDir, { recursive: true });
251
+ });
252
+
253
+ // 4.2 Markers present, no change needed → returns false, no write
254
+ await test('4.2 updateProjectContextManifestPaths: already up to date → returns false without writing', async () => {
255
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-update-test-'));
256
+ const outputPath = path.join(tmpDir, 'project-context.md');
257
+
258
+ const initial = [
259
+ '# Project Context',
260
+ '<!-- ma-agents:manifest-paths-start -->',
261
+ ' - `.claude/skills/MANIFEST.yaml`',
262
+ '<!-- ma-agents:manifest-paths-end -->'
263
+ ].join('\n');
264
+ fs.writeFileSync(outputPath, initial, 'utf8');
265
+ const mtime1 = fs.statSync(outputPath).mtimeMs;
266
+
267
+ // Small delay to ensure mtime would differ if file were written
268
+ await new Promise(r => setTimeout(r, 50));
269
+ const result = await updateProjectContextManifestPaths(outputPath, [agent('.claude/skills')]);
270
+
271
+ assert.strictEqual(result, false, 'should return false when no change needed');
272
+ const mtime2 = fs.statSync(outputPath).mtimeMs;
273
+ assert.strictEqual(mtime1, mtime2, 'file should not have been written when content unchanged');
274
+
275
+ fs.rmSync(tmpDir, { recursive: true });
276
+ });
277
+
278
+ // 4.3 No markers (old-format file) → returns false, backward compatible
279
+ await test('4.3 updateProjectContextManifestPaths: no markers in file → returns false (backward compatible)', async () => {
280
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-update-test-'));
281
+ const outputPath = path.join(tmpDir, 'project-context.md');
282
+
283
+ // Old-format file without markers
284
+ const oldFormat = [
285
+ '# Project Context',
286
+ '1. Read the MANIFEST.yaml for your platform:',
287
+ ' - `.claude/skills/MANIFEST.yaml`',
288
+ '2. Load ALL skills...'
289
+ ].join('\n');
290
+ fs.writeFileSync(outputPath, oldFormat, 'utf8');
291
+
292
+ const result = await updateProjectContextManifestPaths(outputPath, [
293
+ agent('.claude/skills'),
294
+ agent('.gemini/skills')
295
+ ]);
296
+
297
+ assert.strictEqual(result, false, 'should return false for old-format files without markers');
298
+ const content = fs.readFileSync(outputPath, 'utf8');
299
+ assert.strictEqual(content, oldFormat, 'old-format file must not be modified');
300
+
301
+ fs.rmSync(tmpDir, { recursive: true });
302
+ });
303
+
304
+ // 4.4 Markers present, agent with undefined skillsDir is excluded from update
305
+ await test('4.4 updateProjectContextManifestPaths: agents without skillsDir are excluded', async () => {
306
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-update-test-'));
307
+ const outputPath = path.join(tmpDir, 'project-context.md');
308
+
309
+ const initial = [
310
+ '# Project Context',
311
+ '<!-- ma-agents:manifest-paths-start -->',
312
+ ' - `.claude/skills/MANIFEST.yaml`',
313
+ '<!-- ma-agents:manifest-paths-end -->'
314
+ ].join('\n');
315
+ fs.writeFileSync(outputPath, initial, 'utf8');
316
+
317
+ const result = await updateProjectContextManifestPaths(outputPath, [
318
+ agent('.claude/skills'),
319
+ { id: 'legacy' } // no skillsDir
320
+ ]);
321
+
322
+ assert.strictEqual(result, false, 'should return false — only valid agent already present');
323
+ const content = fs.readFileSync(outputPath, 'utf8');
324
+ assert.ok(!content.includes('undefined'), 'must not introduce undefined paths');
325
+
326
+ fs.rmSync(tmpDir, { recursive: true });
327
+ });
328
+
329
+ // ─── Report ────────────────────────────────────────────────────────────────
330
+ console.log(`\n ${passed} passed, ${failed} failed\n`);
331
+ if (failed > 0) {
332
+ errors.forEach(e => console.error(` ✗ ${e.name}: ${e.error}`));
333
+ process.exit(1);
334
+ }
335
+ }
336
+
337
+ runAll();