superlocalmemory 3.6.13 → 3.6.15

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 (147) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +189 -740
  4. package/package.json +12 -5
  5. package/plugin/.claude-plugin/plugin.json +20 -0
  6. package/plugin/.mcp.json +12 -0
  7. package/plugin/CLAUDE.md +44 -0
  8. package/plugin/_GENERATED.md +6 -0
  9. package/plugin/agents/slm-memory-advisor.md +44 -0
  10. package/plugin/agents/slm-optimize-advisor.md +38 -0
  11. package/plugin/hooks/hooks.json +14 -0
  12. package/plugin/requirements.txt +1 -0
  13. package/plugin/scripts/ensure-venv.bat +122 -0
  14. package/plugin/scripts/ensure-venv.sh +105 -0
  15. package/plugin/scripts/slm-launch +15 -0
  16. package/plugin/scripts/slm-launch.bat +17 -0
  17. package/plugin/settings.json +16 -0
  18. package/plugin/skills/slm-cache/SKILL.md +140 -0
  19. package/plugin/skills/slm-compress/SKILL.md +143 -0
  20. package/plugin/skills/slm-graph/SKILL.md +300 -0
  21. package/plugin/skills/slm-recall/SKILL.md +204 -0
  22. package/plugin/skills/slm-remember/SKILL.md +194 -0
  23. package/plugin/skills/slm-session/SKILL.md +207 -0
  24. package/plugin/skills/slm-status/SKILL.md +149 -0
  25. package/plugin-src/.mcp.json +12 -0
  26. package/plugin-src/agents/slm-memory-advisor.md +44 -0
  27. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  28. package/plugin-src/commands/slm-optimize.md +22 -0
  29. package/plugin-src/commands/slm-recall.md +16 -0
  30. package/plugin-src/commands/slm-remember.md +16 -0
  31. package/plugin-src/commands/slm-status.md +15 -0
  32. package/plugin-src/hooks/.gitkeep +0 -0
  33. package/plugin-src/hooks/hooks.json +14 -0
  34. package/plugin-src/manifest.json +25 -0
  35. package/plugin-src/requirements.txt +1 -0
  36. package/plugin-src/rules/AGENTS.md +91 -0
  37. package/plugin-src/rules/CLAUDE.md.fragment +44 -0
  38. package/plugin-src/scripts/ensure-venv.bat +122 -0
  39. package/plugin-src/scripts/ensure-venv.sh +105 -0
  40. package/plugin-src/scripts/slm-launch +15 -0
  41. package/plugin-src/scripts/slm-launch.bat +17 -0
  42. package/plugin-src/settings.json +16 -0
  43. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  44. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  45. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  46. package/plugin-src/skills/slm-recall/SKILL.md +204 -0
  47. package/plugin-src/skills/slm-remember/SKILL.md +194 -0
  48. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  49. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  50. package/pyproject.toml +6 -2
  51. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  52. package/scripts/_savings_math.py +270 -0
  53. package/scripts/build-plugin.js +742 -0
  54. package/scripts/dogfood_savings.py +490 -0
  55. package/scripts/install-skills.ps1 +4 -334
  56. package/scripts/install-skills.sh +4 -435
  57. package/scripts/postinstall-interactive.js +0 -27
  58. package/scripts/postinstall.js +21 -2
  59. package/src/superlocalmemory/__init__.py +1 -1
  60. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  61. package/src/superlocalmemory/cli/commands.py +439 -41
  62. package/src/superlocalmemory/cli/main.py +92 -4
  63. package/src/superlocalmemory/cli/setup_wizard.py +47 -6
  64. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  65. package/src/superlocalmemory/core/config.py +194 -9
  66. package/src/superlocalmemory/core/embeddings.py +10 -5
  67. package/src/superlocalmemory/core/engine.py +76 -5
  68. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  69. package/src/superlocalmemory/core/platform_utils.py +8 -0
  70. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  71. package/src/superlocalmemory/core/recall_worker.py +7 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  73. package/src/superlocalmemory/core/worker_pool.py +14 -2
  74. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  75. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  76. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  77. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  78. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  79. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  80. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  81. package/src/superlocalmemory/mcp/server.py +75 -4
  82. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  83. package/src/superlocalmemory/mcp/tools_core.py +37 -4
  84. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  85. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  86. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  87. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  88. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  89. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  90. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  91. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  92. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  93. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  94. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  95. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  96. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  97. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  98. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  99. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  100. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  101. package/src/superlocalmemory/retrieval/engine.py +36 -3
  102. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  103. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  105. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  106. package/src/superlocalmemory/server/unified_daemon.py +156 -16
  107. package/src/superlocalmemory/storage/database.py +215 -43
  108. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  109. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  110. package/src/superlocalmemory/storage/models.py +10 -0
  111. package/src/superlocalmemory/storage/schema.py +15 -10
  112. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  113. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  114. package/src/superlocalmemory/ui/index.html +2 -2
  115. package/src/superlocalmemory/ui/js/core.js +98 -0
  116. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  117. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  118. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  119. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  120. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  121. package/src/superlocalmemory.egg-info/PKG-INFO +191 -741
  122. package/src/superlocalmemory.egg-info/SOURCES.txt +7 -9
  123. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  124. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  125. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  126. package/ide/skills/slm-recall/SKILL.md +0 -326
  127. package/ide/skills/slm-remember/SKILL.md +0 -194
  128. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  129. package/ide/skills/slm-status/SKILL.md +0 -363
  130. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  131. package/skills/slm-build-graph/SKILL.md +0 -423
  132. package/skills/slm-list-recent/SKILL.md +0 -348
  133. package/skills/slm-optimize/README.md +0 -55
  134. package/skills/slm-optimize/SKILL.md +0 -139
  135. package/skills/slm-recall/SKILL.md +0 -343
  136. package/skills/slm-remember/SKILL.md +0 -194
  137. package/skills/slm-show-patterns/SKILL.md +0 -224
  138. package/skills/slm-status/SKILL.md +0 -363
  139. package/skills/slm-switch-profile/SKILL.md +0 -442
  140. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  141. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  142. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  143. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  144. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  145. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  146. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  147. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +0 -442
@@ -0,0 +1,613 @@
1
+ /**
2
+ * build-plugin.test.mjs — DOC-CORRECT layout TDD test suite (node:test, mkdtemp fixtures)
3
+ *
4
+ * v3.6.14: single plugin/ target, 7 new skills, no ide/pkg/commands targets.
5
+ * Layout: .claude-plugin/marketplace.json (repo root) + plugin/ (plugin root).
6
+ *
7
+ * Run: node --test scripts/__tests__/build-plugin.test.mjs
8
+ */
9
+
10
+ import { test, describe, before, after, beforeEach } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import crypto from 'node:crypto';
16
+ import { spawnSync } from 'node:child_process';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Module path (relative to this file location: scripts/__tests__/)
20
+ // ---------------------------------------------------------------------------
21
+ const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
22
+ const SCRIPT_PATH = path.resolve(import.meta.dirname, '../build-plugin.js');
23
+
24
+ // Lazy-import the module (it exports pure functions)
25
+ let mod;
26
+ async function getModule() {
27
+ if (!mod) {
28
+ mod = await import(SCRIPT_PATH);
29
+ }
30
+ return mod;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+ function makeTmp() {
37
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'slm-wp04-'));
38
+ }
39
+
40
+ function sha256(content) {
41
+ return crypto.createHash('sha256').update(content).digest('hex');
42
+ }
43
+
44
+ function writeFile(dir, rel, content) {
45
+ const full = path.join(dir, rel);
46
+ fs.mkdirSync(path.dirname(full), { recursive: true });
47
+ fs.writeFileSync(full, content, 'utf8');
48
+ return full;
49
+ }
50
+
51
+ function readFile(p) {
52
+ return fs.readFileSync(p, 'utf8');
53
+ }
54
+
55
+ // The 7 new skills
56
+ const NEW_SKILLS = [
57
+ 'slm-cache',
58
+ 'slm-compress',
59
+ 'slm-graph',
60
+ 'slm-recall',
61
+ 'slm-remember',
62
+ 'slm-session',
63
+ 'slm-status',
64
+ ];
65
+
66
+ // Minimal fixture manifest — DOC-CORRECT layout
67
+ function makeManifest(overrides = {}) {
68
+ return {
69
+ version: '3.6.14',
70
+ pluginName: 'superlocalmemory',
71
+ displayName: 'SuperLocalMemory',
72
+ repository: 'https://github.com/qualixar/superlocalmemory',
73
+ keywords: ['memory', 'mcp', 'agents', 'local-first', 'context-compression'],
74
+ skills: NEW_SKILLS.map(name => ({ name, hasReadme: false })),
75
+ targets: {
76
+ plugin: 'plugin/.claude-plugin/plugin.json',
77
+ },
78
+ marketplace: {
79
+ owner: 'qualixar',
80
+ ownerEmail: 'varun.pratap.bhardwaj@gmail.com',
81
+ repo: 'superlocalmemory',
82
+ description: 'Local-first agent memory + reversible context compression and KV cache, as an MCP server. 20-tool code profile with graph intelligence.',
83
+ },
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ // Minimal skill body with frontmatter version (for stamp tests)
89
+ const OLD_ATTRIBUTION_BLOCK = `
90
+ **Created by:** [Varun Pratap Bhardwaj](https://github.com/varun369) (Solution Architect)
91
+ **Project:** SuperLocalMemory V2
92
+ **License:** MIT (see [LICENSE](../../LICENSE))
93
+ **Repository:** https://github.com/varun369/SuperLocalMemoryV2
94
+
95
+ *Open source doesn't mean removing credit. Attribution must be preserved per MIT License terms.*
96
+ `.trimStart();
97
+
98
+ function makeSkillBody(name, version = '3.4.23') {
99
+ return [
100
+ '---',
101
+ `name: ${name}`,
102
+ `description: Test skill ${name}`,
103
+ `version: "${version}"`,
104
+ 'license: AGPL-3.0-or-later',
105
+ `compatibility: "SuperLocalMemory V2 installed"`,
106
+ 'attribution:',
107
+ ' creator: Varun Pratap Bhardwaj',
108
+ ' role: Solution Architect',
109
+ ` project: SuperLocalMemory V2`,
110
+ '---',
111
+ '',
112
+ `# ${name}`,
113
+ '',
114
+ 'Some body text. API v2 endpoint. Uses the MIT license approach.',
115
+ '',
116
+ '## Usage',
117
+ '',
118
+ 'Run the thing.',
119
+ '',
120
+ OLD_ATTRIBUTION_BLOCK,
121
+ ].join('\n');
122
+ }
123
+
124
+ // Skill body WITHOUT a version line (new-style skills)
125
+ function makeSkillBodyNoVersion(name) {
126
+ return [
127
+ '---',
128
+ `name: ${name}`,
129
+ `description: Test skill ${name}`,
130
+ 'allowed-tools: Bash',
131
+ '---',
132
+ '',
133
+ `# ${name}`,
134
+ '',
135
+ 'Body text.',
136
+ ].join('\n');
137
+ }
138
+
139
+ // Setup fixture with all 7 skills + optional extras
140
+ function setupFixture(tmp, overrides = {}) {
141
+ const manifest = makeManifest(overrides);
142
+ for (const skill of manifest.skills) {
143
+ writeFile(tmp, `plugin-src/skills/${skill.name}/SKILL.md`, makeSkillBody(skill.name));
144
+ }
145
+ writeFile(tmp, 'plugin-src/agents/slm-memory-advisor.md', '# Advisor\n');
146
+ writeFile(tmp, 'plugin-src/hooks/hooks.json', '{"hooks":{"SessionStart":[]}}\n');
147
+ writeFile(tmp, 'plugin-src/.mcp.json', '{"mcpServers":{"superlocalmemory":{"command":"${CLAUDE_PLUGIN_DATA}/venv/bin/slm","args":["mcp"],"env":{"SLM_MCP_PROFILE":"code","SLM_DATA_DIR":"${CLAUDE_PLUGIN_DATA}"}}}}\n');
148
+ writeFile(tmp, 'plugin-src/settings.json', '{"permissions":{"allow":[]}}\n');
149
+ writeFile(tmp, 'plugin-src/requirements.txt', 'superlocalmemory>=3.6.14\n');
150
+ writeFile(tmp, 'plugin-src/rules/CLAUDE.md.fragment', '<!-- SLM -->\n');
151
+ return manifest;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // TEST 1 — stampVersion replaces only frontmatter version line
156
+ // ---------------------------------------------------------------------------
157
+ describe('stampVersion', () => {
158
+ test('replaces only frontmatter version line (body v3.4.22 untouched)', async () => {
159
+ const { stampVersion } = await getModule();
160
+ const body = makeSkillBody('slm-recall', '3.4.22');
161
+ const bodyWithVersion = body + '\n\nSome note about (v3.4.22) API\n';
162
+ const result = stampVersion(bodyWithVersion, '3.6.14');
163
+ assert.match(result, /^---\n[\s\S]*?version: "3\.6\.14"/m);
164
+ assert.match(result, /\(v3\.4\.22\)/);
165
+ });
166
+
167
+ test('stampVersion is idempotent', async () => {
168
+ const { stampVersion } = await getModule();
169
+ const body = makeSkillBody('slm-recall', '3.4.23');
170
+ const once = stampVersion(body, '3.6.14');
171
+ const twice = stampVersion(once, '3.6.14');
172
+ assert.equal(once, twice);
173
+ });
174
+
175
+ test('throws if frontmatter has no version line', async () => {
176
+ const { stampVersion } = await getModule();
177
+ const noVersion = '---\nname: foo\n---\n\n# Foo\n';
178
+ assert.throws(() => stampVersion(noVersion, '3.6.14'), /version/i);
179
+ });
180
+ });
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // TEST — renderSkillFile: skills without version line are copied verbatim
184
+ // ---------------------------------------------------------------------------
185
+ describe('renderSkillFile no-version', () => {
186
+ test('skill without version: line in frontmatter copied verbatim (no stamp error)', async () => {
187
+ const { renderSkillFile } = await getModule();
188
+ const body = makeSkillBodyNoVersion('slm-graph');
189
+ // Must not throw
190
+ const result = renderSkillFile(body, '3.6.14');
191
+ assert.ok(result.includes('slm-graph'), 'skill name preserved');
192
+ assert.ok(!result.includes('version: "3.6.14"'), 'no version stamp injected');
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // TEST 2 — renderPluginJson: sorted, author.url, mcpServers/hooks pointers
198
+ // ---------------------------------------------------------------------------
199
+ describe('renderPluginJson', () => {
200
+ test('deterministic sorted output + author.url + pointers', async () => {
201
+ const { renderPluginJson } = await getModule();
202
+ const manifest = makeManifest();
203
+ const json = renderPluginJson(manifest);
204
+ const parsed = JSON.parse(json);
205
+ assert.equal(parsed.name, 'superlocalmemory');
206
+ assert.equal(parsed.version, '3.6.14');
207
+ assert.equal(parsed.author.name, 'Qualixar');
208
+ // REGRESSION: author.url must NOT be silently dropped
209
+ assert.equal(parsed.author.url, 'https://github.com/qualixar/superlocalmemory');
210
+ // pointers relative to plugin root
211
+ assert.ok(parsed.hooks && parsed.hooks.includes('hooks.json'), 'hooks pointer present');
212
+ assert.ok(parsed.mcpServers && parsed.mcpServers.includes('.mcp.json'), 'mcpServers pointer present');
213
+ // keys must be sorted at top level
214
+ const keys = Object.keys(parsed);
215
+ assert.deepEqual(keys, [...keys].sort());
216
+ });
217
+
218
+ test('renderPluginJson is deterministic (call twice same output)', async () => {
219
+ const { renderPluginJson } = await getModule();
220
+ const manifest = makeManifest();
221
+ assert.equal(renderPluginJson(manifest), renderPluginJson(manifest));
222
+ });
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // TEST 3 — renderMarketplaceJson: source="./plugin", no version in plugin entry
227
+ // ---------------------------------------------------------------------------
228
+ describe('renderMarketplaceJson', () => {
229
+ test('source="./plugin", no version in plugin entry, owner.name=Qualixar', async () => {
230
+ const { renderMarketplaceJson } = await getModule();
231
+ const manifest = makeManifest();
232
+ const json = renderMarketplaceJson(manifest);
233
+ const parsed = JSON.parse(json);
234
+ assert.equal(parsed.name, 'qualixar');
235
+ assert.equal(parsed.owner.name, 'Qualixar');
236
+ assert.ok(Array.isArray(parsed.plugins));
237
+ assert.equal(parsed.plugins.length, 1);
238
+ assert.equal(parsed.plugins[0].name, 'superlocalmemory');
239
+ // DOC-CORRECT: source must be "./plugin" not "./"
240
+ assert.equal(parsed.plugins[0].source, './plugin', 'source must be ./plugin');
241
+ // AC-3: NO version key in plugin entry
242
+ assert.equal(parsed.plugins[0].version, undefined, 'plugin entry must have no version key');
243
+ });
244
+ });
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // TEST 4 — buildPlan: 7 skills in plugin/skills/, no commands/, marketplace at root
248
+ // ---------------------------------------------------------------------------
249
+ describe('buildPlan', () => {
250
+ test('builds 7 skills in plugin/skills/ + plugin.json + marketplace.json + agents + extras', async () => {
251
+ const { buildPlan } = await getModule();
252
+ const tmp = makeTmp();
253
+ const manifest = setupFixture(tmp);
254
+ const plan = buildPlan(tmp, manifest);
255
+
256
+ // 7 skill SKILL.md files
257
+ const skillFiles = [...plan.keys()].filter(k => k.endsWith('SKILL.md'));
258
+ assert.equal(skillFiles.length, 7, `expected 7 SKILL.md, got ${skillFiles.length}`);
259
+
260
+ // All in plugin/skills/
261
+ for (const f of skillFiles) {
262
+ assert.ok(f.includes(`plugin${path.sep}skills${path.sep}`), `SKILL.md must be in plugin/skills/: ${f}`);
263
+ }
264
+
265
+ // plugin/.claude-plugin/plugin.json
266
+ const pluginJsonFiles = [...plan.keys()].filter(k => k.endsWith('plugin.json'));
267
+ assert.equal(pluginJsonFiles.length, 1);
268
+ assert.ok(pluginJsonFiles[0].includes(`plugin${path.sep}.claude-plugin`), 'plugin.json must be in plugin/.claude-plugin/');
269
+
270
+ // marketplace.json at repo .claude-plugin/ (not plugin/)
271
+ const marketplaceFiles = [...plan.keys()].filter(k => k.endsWith('marketplace.json'));
272
+ assert.equal(marketplaceFiles.length, 1);
273
+ // must be in <root>/.claude-plugin/marketplace.json
274
+ const mf = marketplaceFiles[0];
275
+ assert.ok(mf.startsWith(path.join(tmp, '.claude-plugin')), `marketplace.json must be at repo root .claude-plugin/, got ${mf}`);
276
+
277
+ // NO commands/ entries
278
+ const commandFiles = [...plan.keys()].filter(k => k.includes(`${path.sep}commands${path.sep}`));
279
+ assert.equal(commandFiles.length, 0, 'no commands/ dir in output');
280
+
281
+ // _GENERATED.md banner
282
+ const banners = [...plan.keys()].filter(k => k.endsWith('_GENERATED.md'));
283
+ assert.equal(banners.length, 1, 'exactly 1 _GENERATED.md banner in plugin root');
284
+ });
285
+
286
+ test('manifest.targets.plugin validation: throws on missing plugin key', async () => {
287
+ const { loadManifest } = await getModule();
288
+ const tmp = makeTmp();
289
+ const bad = makeManifest({ targets: {} });
290
+ writeFile(tmp, 'plugin-src/manifest.json', JSON.stringify(bad, null, 2));
291
+ assert.throws(() => loadManifest(tmp), /targets\.plugin/i);
292
+ });
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // TEST 5 — derivePluginRoot returns plugin/ given targets.plugin
297
+ // ---------------------------------------------------------------------------
298
+ describe('derivePluginRoot', () => {
299
+ test('derives plugin/ from targets.plugin="plugin/.claude-plugin/plugin.json"', async () => {
300
+ const { derivePluginRoot } = await getModule();
301
+ const manifest = makeManifest();
302
+ const tmp = '/tmp/testrepo';
303
+ const result = derivePluginRoot(tmp, manifest);
304
+ assert.equal(result, path.resolve(tmp, 'plugin'));
305
+ });
306
+ });
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // TEST 6 — build-twice = 0 writes (idempotent applyPlan)
310
+ // ---------------------------------------------------------------------------
311
+ describe('applyPlan idempotent', () => {
312
+ test('second build writes 0 files', async () => {
313
+ const { buildPlan, applyPlan } = await getModule();
314
+ const tmp = makeTmp();
315
+ const manifest = setupFixture(tmp);
316
+ const plan1 = buildPlan(tmp, manifest);
317
+ const wrote1 = applyPlan(plan1);
318
+ assert.ok(wrote1 > 0, 'first build should write something');
319
+
320
+ const plan2 = buildPlan(tmp, manifest);
321
+ const wrote2 = applyPlan(plan2);
322
+ assert.equal(wrote2, 0, 'second build must write 0 files (idempotent)');
323
+ });
324
+ });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // TEST 7 — checkPlan: in-sync, stale, missing, extra
328
+ // ---------------------------------------------------------------------------
329
+ describe('checkPlan', () => {
330
+ test('checkPlan returns empty arrays when in sync', async () => {
331
+ const { buildPlan, applyPlan, checkPlan } = await getModule();
332
+ const tmp = makeTmp();
333
+ const manifest = setupFixture(tmp);
334
+ applyPlan(buildPlan(tmp, manifest));
335
+ const { stale, missing, extra } = checkPlan(buildPlan(tmp, manifest), tmp, manifest);
336
+ assert.equal(stale.length, 0);
337
+ assert.equal(missing.length, 0);
338
+ assert.equal(extra.length, 0);
339
+ });
340
+
341
+ test('checkPlan detects stale file', async () => {
342
+ const { buildPlan, applyPlan, checkPlan } = await getModule();
343
+ const tmp = makeTmp();
344
+ const manifest = setupFixture(tmp);
345
+ applyPlan(buildPlan(tmp, manifest));
346
+ // tamper with a generated file
347
+ const skillFile = path.join(tmp, 'plugin', 'skills', 'slm-recall', 'SKILL.md');
348
+ fs.writeFileSync(skillFile, 'STALE CONTENT\n', 'utf8');
349
+ const { stale } = checkPlan(buildPlan(tmp, manifest), tmp, manifest);
350
+ assert.ok(stale.length > 0, 'should detect stale file');
351
+ });
352
+
353
+ test('checkPlan detects missing file', async () => {
354
+ const { buildPlan, applyPlan, checkPlan } = await getModule();
355
+ const tmp = makeTmp();
356
+ const manifest = setupFixture(tmp);
357
+ applyPlan(buildPlan(tmp, manifest));
358
+ fs.unlinkSync(path.join(tmp, 'plugin', 'skills', 'slm-recall', 'SKILL.md'));
359
+ const { missing } = checkPlan(buildPlan(tmp, manifest), tmp, manifest);
360
+ assert.ok(missing.length > 0, 'should detect missing file');
361
+ });
362
+
363
+ test('checkPlan detects extra orphan file in plugin/', async () => {
364
+ const { buildPlan, applyPlan, checkPlan } = await getModule();
365
+ const tmp = makeTmp();
366
+ const manifest = setupFixture(tmp);
367
+ applyPlan(buildPlan(tmp, manifest));
368
+ // add orphan in plugin/skills/
369
+ writeFile(tmp, 'plugin/skills/slm-orphan/SKILL.md', '# orphan\n');
370
+ const { extra } = checkPlan(buildPlan(tmp, manifest), tmp, manifest);
371
+ assert.ok(extra.length > 0, 'should detect extra orphan');
372
+ });
373
+ });
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // TEST 8 — pruneOrphans: removes orphan, escape guard holds
377
+ // ---------------------------------------------------------------------------
378
+ describe('pruneOrphans', () => {
379
+ test('prunes removed skill from plugin/, plugin-src untouched', async () => {
380
+ const { buildPlan, applyPlan, pruneOrphans, deriveManagedRoots } = await getModule();
381
+ const tmp = makeTmp();
382
+ const manifest = setupFixture(tmp);
383
+ applyPlan(buildPlan(tmp, manifest));
384
+
385
+ // Simulate skill removed: build without slm-status
386
+ const reducedManifest = makeManifest({
387
+ skills: manifest.skills.filter(s => s.name !== 'slm-status'),
388
+ });
389
+ // Copy remaining skill sources (slm-status source stays — plugin-src untouched)
390
+ const reducedPlan = buildPlan(tmp, reducedManifest);
391
+ const roots = deriveManagedRoots(tmp, reducedManifest);
392
+ assert.ok(reducedPlan.size > 0, 'plan must not be empty');
393
+
394
+ pruneOrphans(tmp, reducedPlan, roots);
395
+
396
+ // slm-status removed from plugin/
397
+ assert.ok(!fs.existsSync(path.join(tmp, 'plugin', 'skills', 'slm-status')));
398
+ // plugin-src untouched
399
+ assert.ok(fs.existsSync(path.join(tmp, 'plugin-src', 'skills', 'slm-status', 'SKILL.md')));
400
+ });
401
+
402
+ test('pruneOrphans: escape guard never exits plugin/ root', async () => {
403
+ const { deriveManagedRoots, pruneOrphans } = await getModule();
404
+ const tmp = makeTmp();
405
+ const manifest = makeManifest();
406
+ // Plant sentinel outside managed roots
407
+ writeFile(tmp, 'ide/configs/settings.json', '{"protected":true}');
408
+ const sentinelPath = path.join(tmp, 'ide', 'configs', 'settings.json');
409
+ const before = fs.readFileSync(sentinelPath, 'utf8');
410
+
411
+ // Empty plugin/ (no orphans, roots don't exist yet — prune is a no-op)
412
+ const roots = deriveManagedRoots(tmp, manifest);
413
+ // Build a minimal non-empty plan
414
+ const fakePlan = new Map([[path.join(tmp, 'plugin', 'dummy.txt'), 'x\n']]);
415
+ pruneOrphans(tmp, fakePlan, roots);
416
+
417
+ // sentinel untouched
418
+ assert.equal(fs.readFileSync(sentinelPath, 'utf8'), before);
419
+ });
420
+ });
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // TEST 9 — SLM_MCP_PROFILE=code in .mcp.json
424
+ // ---------------------------------------------------------------------------
425
+ describe('.mcp.json profile=code', () => {
426
+ test('plan includes .mcp.json with SLM_MCP_PROFILE=code', async () => {
427
+ const { buildPlan } = await getModule();
428
+ const tmp = makeTmp();
429
+ const manifest = setupFixture(tmp);
430
+ const plan = buildPlan(tmp, manifest);
431
+
432
+ const mcpEntry = [...plan.entries()].find(([k]) => k.endsWith('.mcp.json') && k.includes(`plugin${path.sep}.mcp`));
433
+ assert.ok(mcpEntry, 'plan must include plugin/.mcp.json');
434
+ const content = JSON.parse(mcpEntry[1]);
435
+ const server = Object.values(content.mcpServers)[0];
436
+ assert.equal(server.env.SLM_MCP_PROFILE, 'code', 'SLM_MCP_PROFILE must be "code"');
437
+ });
438
+ });
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // TEST 10 — renderSkillFile golden snapshot
442
+ // ---------------------------------------------------------------------------
443
+ describe('renderSkillFile', () => {
444
+ test('stamps version 3.6.14 and normalizes OQ-2 attribution', async () => {
445
+ const { renderSkillFile } = await getModule();
446
+ const body = makeSkillBody('slm-recall', '3.4.23');
447
+ const result = renderSkillFile(body, '3.6.14');
448
+ assert.match(result, /version: "3\.6\.14"/);
449
+ assert.match(result, /SuperLocalMemory v3\.6\.14/);
450
+ assert.match(result, /AGPL-3\.0-or-later/);
451
+ assert.match(result, /qualixar\/superlocalmemory/);
452
+ assert.match(result, /API v2/);
453
+ assert.match(result, /Uses the MIT license approach/);
454
+ });
455
+
456
+ test('renderSkillFile is idempotent', async () => {
457
+ const { renderSkillFile } = await getModule();
458
+ const body = makeSkillBody('slm-recall', '3.4.23');
459
+ const once = renderSkillFile(body, '3.6.14');
460
+ const twice = renderSkillFile(once, '3.6.14');
461
+ assert.equal(once, twice);
462
+ });
463
+ });
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // TEST 11 — main exits 1 on missing manifest
467
+ // ---------------------------------------------------------------------------
468
+ describe('main exit codes', () => {
469
+ test('main exits 1 when manifest is missing', async () => {
470
+ const tmp = makeTmp();
471
+ const result = spawnSync(
472
+ process.execPath,
473
+ [SCRIPT_PATH, '--quiet'],
474
+ { cwd: tmp, encoding: 'utf8' }
475
+ );
476
+ assert.equal(result.status, 1, 'should exit 1 on missing manifest');
477
+ });
478
+ });
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // TEST 12 — LF + single trailing newline
482
+ // ---------------------------------------------------------------------------
483
+ describe('output format', () => {
484
+ test('generated SKILL.md uses LF and ends with single newline', async () => {
485
+ const { renderSkillFile } = await getModule();
486
+ const body = makeSkillBody('slm-recall', '3.4.23');
487
+ const result = renderSkillFile(body, '3.6.14');
488
+ assert.ok(!result.includes('\r'), 'must not contain CR');
489
+ assert.ok(result.endsWith('\n'), 'must end with newline');
490
+ assert.ok(!result.endsWith('\n\n'), 'must not end with double newline');
491
+ });
492
+ });
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // TEST 13 — REGRESSION: marketplace.json source is "./plugin" not "./"
496
+ // ---------------------------------------------------------------------------
497
+ describe('REGRESSION: marketplace source="./plugin"', () => {
498
+ test('renderMarketplaceJson source is ./plugin (DOC-CORRECT)', async () => {
499
+ const { renderMarketplaceJson } = await getModule();
500
+ const manifest = makeManifest();
501
+ const parsed = JSON.parse(renderMarketplaceJson(manifest));
502
+ assert.equal(parsed.plugins[0].source, './plugin', 'source must be ./plugin per DOC-CORRECT layout');
503
+ });
504
+ });
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // TEST 14 — REGRESSION: no ide/ or src/ targets in plan
508
+ // ---------------------------------------------------------------------------
509
+ describe('REGRESSION: no multi-skill-target', () => {
510
+ test('plan contains NO ide/skills or src/superlocalmemory/skills entries', async () => {
511
+ const { buildPlan } = await getModule();
512
+ const tmp = makeTmp();
513
+ const manifest = setupFixture(tmp);
514
+ const plan = buildPlan(tmp, manifest);
515
+
516
+ const ideFiles = [...plan.keys()].filter(k => k.includes('ide/skills') || k.includes('ide\\skills'));
517
+ const srcFiles = [...plan.keys()].filter(k =>
518
+ k.includes('src/superlocalmemory/skills') || k.includes('src\\superlocalmemory\\skills')
519
+ );
520
+ assert.equal(ideFiles.length, 0, 'no ide/skills entries allowed');
521
+ assert.equal(srcFiles.length, 0, 'no src/superlocalmemory/skills entries allowed');
522
+ });
523
+ });
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // TEST 15 — REGRESSION: partial-target no cross-delete (now: prune scoped to plugin/)
527
+ // ---------------------------------------------------------------------------
528
+ describe('REGRESSION: prune scoped to plugin/ only', () => {
529
+ test('pruneOrphans only touches plugin/, not other dirs', async () => {
530
+ const { buildPlan, applyPlan, pruneOrphans, deriveManagedRoots } = await getModule();
531
+ const tmp = makeTmp();
532
+ const manifest = setupFixture(tmp);
533
+
534
+ // Plant files in a non-managed dir
535
+ writeFile(tmp, 'ide/configs/settings.json', '{"protected":true}');
536
+ writeFile(tmp, 'some-other-dir/file.txt', 'content\n');
537
+
538
+ applyPlan(buildPlan(tmp, manifest));
539
+
540
+ const plan = buildPlan(tmp, manifest);
541
+ const roots = deriveManagedRoots(tmp, manifest);
542
+ pruneOrphans(tmp, plan, roots);
543
+
544
+ // Non-managed dirs untouched
545
+ assert.ok(fs.existsSync(path.join(tmp, 'ide', 'configs', 'settings.json')));
546
+ assert.ok(fs.existsSync(path.join(tmp, 'some-other-dir', 'file.txt')));
547
+ });
548
+ });
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // TEST 16 — Full build on real REPO_ROOT succeeds + --check exits 0
552
+ // ---------------------------------------------------------------------------
553
+ describe('Integration: real repo build', () => {
554
+ test('node build-plugin.js succeeds on real repo', () => {
555
+ const result = spawnSync(
556
+ process.execPath,
557
+ [SCRIPT_PATH, '--quiet'],
558
+ { cwd: REPO_ROOT, encoding: 'utf8' }
559
+ );
560
+ if (result.status !== 0) {
561
+ console.error('STDOUT:', result.stdout);
562
+ console.error('STDERR:', result.stderr);
563
+ }
564
+ assert.equal(result.status, 0, `build exited ${result.status}: ${result.stderr}`);
565
+ });
566
+
567
+ test('--check exits 0 after build', () => {
568
+ const result = spawnSync(
569
+ process.execPath,
570
+ [SCRIPT_PATH, '--check', '--quiet'],
571
+ { cwd: REPO_ROOT, encoding: 'utf8' }
572
+ );
573
+ assert.equal(result.status, 0, `--check exited ${result.status}: ${result.stdout}${result.stderr}`);
574
+ });
575
+
576
+ test('plugin/.claude-plugin/plugin.json exists with version=3.6.14', () => {
577
+ const p = path.join(REPO_ROOT, 'plugin', '.claude-plugin', 'plugin.json');
578
+ assert.ok(fs.existsSync(p), 'plugin/.claude-plugin/plugin.json must exist');
579
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
580
+ assert.equal(parsed.version, '3.6.14');
581
+ assert.equal(parsed.author.name, 'Qualixar');
582
+ assert.ok(parsed.author.url, 'author.url must be present');
583
+ });
584
+
585
+ test('.claude-plugin/marketplace.json has source="./plugin"', () => {
586
+ const p = path.join(REPO_ROOT, '.claude-plugin', 'marketplace.json');
587
+ assert.ok(fs.existsSync(p), '.claude-plugin/marketplace.json must exist');
588
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
589
+ assert.equal(parsed.plugins[0].source, './plugin');
590
+ assert.equal(parsed.plugins[0].version, undefined, 'no version in marketplace plugin entry');
591
+ });
592
+
593
+ test('plugin/.mcp.json has SLM_MCP_PROFILE=code', () => {
594
+ const p = path.join(REPO_ROOT, 'plugin', '.mcp.json');
595
+ assert.ok(fs.existsSync(p), 'plugin/.mcp.json must exist');
596
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
597
+ const server = Object.values(parsed.mcpServers)[0];
598
+ assert.equal(server.env.SLM_MCP_PROFILE, 'code', 'SLM_MCP_PROFILE must be code');
599
+ });
600
+
601
+ test('all 7 skills exist in plugin/skills/', () => {
602
+ const skills = ['slm-cache', 'slm-compress', 'slm-graph', 'slm-recall', 'slm-remember', 'slm-session', 'slm-status'];
603
+ for (const s of skills) {
604
+ const p = path.join(REPO_ROOT, 'plugin', 'skills', s, 'SKILL.md');
605
+ assert.ok(fs.existsSync(p), `plugin/skills/${s}/SKILL.md must exist`);
606
+ }
607
+ });
608
+
609
+ test('no commands/ dir exists in plugin/', () => {
610
+ const commandsDir = path.join(REPO_ROOT, 'plugin', 'commands');
611
+ assert.ok(!fs.existsSync(commandsDir), 'plugin/commands/ must not exist');
612
+ });
613
+ });