@rune-kit/rune 2.3.3 → 2.4.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 (53) hide show
  1. package/README.md +51 -1
  2. package/compiler/__tests__/scripts-bundling.test.js +284 -0
  3. package/compiler/__tests__/tier-override.test.js +41 -0
  4. package/compiler/adapters/antigravity.js +4 -0
  5. package/compiler/adapters/codex.js +4 -0
  6. package/compiler/adapters/cursor.js +4 -0
  7. package/compiler/adapters/generic.js +4 -0
  8. package/compiler/adapters/openclaw.js +4 -0
  9. package/compiler/adapters/opencode.js +4 -0
  10. package/compiler/adapters/windsurf.js +4 -0
  11. package/compiler/emitter.js +85 -5
  12. package/compiler/transforms/scripts-path.js +18 -0
  13. package/extensions/zalo/PACK.md +20 -1
  14. package/extensions/zalo/references/conversation-management.md +214 -0
  15. package/extensions/zalo/references/eval-scenarios.md +157 -0
  16. package/extensions/zalo/references/listen-mode.md +237 -0
  17. package/extensions/zalo/references/mcp-production.md +274 -0
  18. package/extensions/zalo/references/multi-account-proxy.md +224 -0
  19. package/extensions/zalo/references/vietqr-banking.md +160 -0
  20. package/package.json +2 -3
  21. package/skills/marketing/SKILL.md +3 -0
  22. package/skills/sentinel/SKILL.md +4 -1
  23. package/skills/sentinel/references/auth-crypto-reference.md +192 -0
  24. package/skills/sentinel/references/desktop-security.md +201 -0
  25. package/skills/sentinel/references/supply-chain.md +160 -0
  26. package/skills/slides/SKILL.md +142 -0
  27. package/skills/slides/scripts/build-deck.js +158 -0
  28. package/docs/ANTIGRAVITY-GAP-ANALYSIS.md +0 -369
  29. package/docs/ARCHITECTURE.md +0 -332
  30. package/docs/COMMUNITY-PACKS.md +0 -109
  31. package/docs/CONTRIBUTING-L4.md +0 -215
  32. package/docs/CROSS-IDE-ANALYSIS.md +0 -164
  33. package/docs/EXTENSION-TEMPLATE.md +0 -126
  34. package/docs/MESH-RULES.md +0 -34
  35. package/docs/MULTI-PLATFORM.md +0 -804
  36. package/docs/SKILL-DEPTH-AUDIT.md +0 -191
  37. package/docs/SKILL-TEMPLATE.md +0 -118
  38. package/docs/TRADE-MATRIX.md +0 -327
  39. package/docs/VERSIONING.md +0 -91
  40. package/docs/VISION.md +0 -263
  41. package/docs/assets/demo-subtitles.srt +0 -215
  42. package/docs/assets/end-card.html +0 -276
  43. package/docs/assets/mesh-diagram.html +0 -654
  44. package/docs/assets/thumbnail.html +0 -295
  45. package/docs/guides/cli.md +0 -403
  46. package/docs/guides/index.html +0 -1450
  47. package/docs/index.html +0 -1005
  48. package/docs/references/claudekit-analysis.md +0 -414
  49. package/docs/references/voltagent-analysis.md +0 -189
  50. package/docs/script.js +0 -495
  51. package/docs/skills/index.html +0 -832
  52. package/docs/style.css +0 -958
  53. package/docs/video-demo-plan.md +0 -172
package/README.md CHANGED
@@ -32,7 +32,57 @@ Mesh: A ↔ B ↔ C (B fails = A reaches C via D→E)
32
32
  D ↔ E ↔ F
33
33
  ```
34
34
 
35
- ## What's New (v2.3.3)
35
+ ## Benchmark: With Rune vs Without Rune
36
+
37
+ We ran 10 standardized coding tasks on Claude Code — once **without** Rune (vanilla), once **with** Rune — and measured tokens, cost, duration, and correctness.
38
+
39
+ ### Headline Results
40
+
41
+ ```
42
+ Without Rune With Rune Delta
43
+ Avg Tokens: 541,400 454,491 ↓ 16%
44
+ Avg Cost: $0.69 $0.65 ↓ 6%
45
+ Avg Duration: 2.3 min 2.1 min ↓ 9%
46
+ Avg Tool Calls: 14 13 ↓ 7%
47
+ Correctness: 9/10 9/10 =
48
+ ```
49
+
50
+ ### Where Rune Shines: Complex Tasks
51
+
52
+ | Task | Difficulty | Tokens | Cost | Duration | Tools |
53
+ |------|-----------|--------|------|----------|-------|
54
+ | Refactor 450-line component | Medium | **-62%** | **-17%** | **-32%** | **-27%** |
55
+ | Full feature (auth + API + tests) | Complex | **-36%** | **-29%** | **-31%** | **-27%** |
56
+ | Add Zod validation | Easy | -9% | **-28%** | **-32%** | 0% |
57
+ | Dark mode across 6 components | Hard | ~0% | +10% | -7% | -6% |
58
+
59
+ Rune doesn't make Claude smarter — Claude already knows how to code. Rune makes Claude **disciplined**. The more complex the task, the more discipline matters.
60
+
61
+ > _"Without Rune, Claude writes code that works. With Rune, Claude writes code that lasts."_
62
+
63
+ <details>
64
+ <summary>Full 10-task breakdown</summary>
65
+
66
+ | # | Task | Diff | Tokens | Cost | Time | Correct |
67
+ |---|------|------|--------|------|------|---------|
68
+ | 1 | Zod Validation | Easy | -9% | -28% | -32% | ✅ → ✅ |
69
+ | 2 | Fix N+1 Query | Easy | +12% | +25% | +3% | ❌ → ❌ |
70
+ | 3 | Cursor Pagination | Med | +12% | +19% | -9% | ✅ → ✅ |
71
+ | 4 | Security Review | Med | +13% | +32% | +3% | ✅ → ✅ |
72
+ | 5 | Rate Limiting | Med | +12% | +5% | +5% | ✅ → ✅ |
73
+ | 6 | Refactor Component | Med | **-62%** | **-17%** | **-32%** | ✅ → ✅ |
74
+ | 7 | Dark Mode (6 files) | Hard | ~0% | +10% | -7% | ✅ → ✅ |
75
+ | 8 | DB Migration | Hard | +52% | +11% | +49% | ✅ → ✅ |
76
+ | 9 | Memory Leak Debug | Hard | +13% | +28% | -2% | ✅ → ✅ |
77
+ | 10 | Full Auth System | Complex | **-36%** | **-29%** | **-31%** | ✅ → ✅ |
78
+
79
+ _Methodology: Claude Code CLI headless mode (`claude -p --output-format json`), 10 tasks with fixture code, pattern-based correctness evaluation. Source: [`Benchmark/`](Benchmark/)_
80
+
81
+ </details>
82
+
83
+ ---
84
+
85
+ ## What's New (v2.4.0)
36
86
 
37
87
  - **UI/UX Pro Max Integration** — 161 palettes, 84 styles, 73 font pairings, 161 reasoning rules from [UI/UX Pro Max](https://github.com/nextlevelbuilder/ui-ux-pro-max-skill) (MIT, 42.8k★) meshed into `design` skill + `@rune/ui` pack
38
88
  - **11 Core Skill Enrichments** — deviation rules, repair operators, evidence quality gate, decision classification, debug knowledge base, CSO discipline, YAGNI pushback, formal pause/resume
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Scripts Bundling Tests
3
+ *
4
+ * Tests the scripts/ directory copy pipeline:
5
+ * - copyScriptsDir helper
6
+ * - scriptsDir adapter method
7
+ * - {scripts_dir} placeholder resolution
8
+ * - End-to-end buildAll with scripts
9
+ */
10
+
11
+ import assert from 'node:assert';
12
+ import { existsSync } from 'node:fs';
13
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { describe, test } from 'node:test';
17
+ import { getAdapter } from '../adapters/index.js';
18
+ import { buildAll } from '../emitter.js';
19
+ import { resolveScriptsPath } from '../transforms/scripts-path.js';
20
+
21
+ // --- resolveScriptsPath transform ---
22
+
23
+ describe('resolveScriptsPath', () => {
24
+ test('replaces {scripts_dir} with resolved path', () => {
25
+ const body = 'Run: `node {scripts_dir}/build-deck.js`';
26
+ const result = resolveScriptsPath(body, '.cursor/rules/rune-slides-scripts');
27
+ assert.strictEqual(result, 'Run: `node .cursor/rules/rune-slides-scripts/build-deck.js`');
28
+ });
29
+
30
+ test('replaces multiple occurrences', () => {
31
+ const body = '{scripts_dir}/a.js and {scripts_dir}/b.js';
32
+ const result = resolveScriptsPath(body, 'scripts');
33
+ assert.strictEqual(result, 'scripts/a.js and scripts/b.js');
34
+ });
35
+
36
+ test('returns body unchanged when no placeholder', () => {
37
+ const body = 'No placeholders here';
38
+ const result = resolveScriptsPath(body, 'some/path');
39
+ assert.strictEqual(result, body);
40
+ });
41
+
42
+ test('returns body unchanged when scriptsPath is null', () => {
43
+ const body = 'Has {scripts_dir} but no path';
44
+ const result = resolveScriptsPath(body, null);
45
+ assert.strictEqual(result, body);
46
+ });
47
+
48
+ test('returns body unchanged when body is empty', () => {
49
+ assert.strictEqual(resolveScriptsPath('', 'path'), '');
50
+ });
51
+ });
52
+
53
+ // --- scriptsDir adapter method ---
54
+
55
+ describe('adapter scriptsDir', () => {
56
+ test('flat-file adapters return sibling dir pattern', () => {
57
+ for (const name of ['cursor', 'windsurf', 'antigravity', 'generic', 'openclaw']) {
58
+ const adapter = getAdapter(name);
59
+ assert.ok(adapter.scriptsDir, `${name} should have scriptsDir`);
60
+ assert.strictEqual(adapter.scriptsDir('slides'), 'rune-slides-scripts', `${name} scriptsDir mismatch`);
61
+ }
62
+ });
63
+
64
+ test('directory-per-skill adapters return nested dir pattern', () => {
65
+ for (const name of ['codex', 'opencode']) {
66
+ const adapter = getAdapter(name);
67
+ assert.ok(adapter.scriptsDir, `${name} should have scriptsDir`);
68
+ assert.strictEqual(adapter.scriptsDir('slides'), 'rune-slides/scripts', `${name} scriptsDir mismatch`);
69
+ }
70
+ });
71
+
72
+ test('claude adapter has no scriptsDir (passthrough)', () => {
73
+ const adapter = getAdapter('claude');
74
+ assert.ok(!adapter.scriptsDir, 'claude should not have scriptsDir');
75
+ });
76
+ });
77
+
78
+ // --- Integration: buildAll with scripts ---
79
+
80
+ describe('buildAll with scripts', () => {
81
+ async function createTempSkillTree() {
82
+ const tmp = path.join(tmpdir(), `rune-scripts-test-${Date.now()}`);
83
+ const skillsDir = path.join(tmp, 'skills');
84
+ const slideSkillDir = path.join(skillsDir, 'test-slide');
85
+ const scriptsDir = path.join(slideSkillDir, 'scripts');
86
+ const plainSkillDir = path.join(skillsDir, 'test-plain');
87
+
88
+ await mkdir(scriptsDir, { recursive: true });
89
+ await mkdir(plainSkillDir, { recursive: true });
90
+
91
+ // Skill with scripts/
92
+ await writeFile(
93
+ path.join(slideSkillDir, 'SKILL.md'),
94
+ [
95
+ '---',
96
+ 'name: test-slide',
97
+ 'description: "Test slide skill"',
98
+ 'layer: L3',
99
+ 'group: utility',
100
+ 'connections: []',
101
+ 'tags: [test]',
102
+ '---',
103
+ '',
104
+ '# test-slide',
105
+ '',
106
+ 'Run: `node {scripts_dir}/build-deck.js --input slides.json`',
107
+ '',
108
+ 'Also: `{scripts_dir}/helper.py`',
109
+ ].join('\n'),
110
+ 'utf-8',
111
+ );
112
+
113
+ await writeFile(path.join(scriptsDir, 'build-deck.js'), '// build deck script\nconsole.log("hello");\n', 'utf-8');
114
+ await writeFile(path.join(scriptsDir, 'helper.py'), '# helper\nprint("hi")\n', 'utf-8');
115
+
116
+ // Skill without scripts/
117
+ await writeFile(
118
+ path.join(plainSkillDir, 'SKILL.md'),
119
+ [
120
+ '---',
121
+ 'name: test-plain',
122
+ 'description: "Test plain skill"',
123
+ 'layer: L3',
124
+ 'group: utility',
125
+ 'connections: []',
126
+ 'tags: [test]',
127
+ '---',
128
+ '',
129
+ '# test-plain',
130
+ '',
131
+ 'No scripts here.',
132
+ ].join('\n'),
133
+ 'utf-8',
134
+ );
135
+
136
+ // Minimal extensions dir (empty — no packs needed for test)
137
+ await mkdir(path.join(tmp, 'extensions'), { recursive: true });
138
+
139
+ // Minimal .claude-plugin for openclaw
140
+ await mkdir(path.join(tmp, '.claude-plugin'), { recursive: true });
141
+ await writeFile(
142
+ path.join(tmp, '.claude-plugin', 'plugin.json'),
143
+ JSON.stringify({ version: '0.0.1' }),
144
+ 'utf-8',
145
+ );
146
+
147
+ return tmp;
148
+ }
149
+
150
+ test('cursor: scripts copied to sibling dir, placeholder resolved', async () => {
151
+ const tmp = await createTempSkillTree();
152
+ try {
153
+ const outputRoot = path.join(tmp, 'out');
154
+ const adapter = getAdapter('cursor');
155
+ const stats = await buildAll({ runeRoot: tmp, outputRoot, adapter });
156
+
157
+ // Scripts copied
158
+ assert.ok(stats.scriptsCopied >= 2, `expected 2+ scripts copied, got ${stats.scriptsCopied}`);
159
+
160
+ // Scripts dir exists with files
161
+ const scriptsOut = path.join(outputRoot, adapter.outputDir, 'rune-test-slide-scripts');
162
+ assert.ok(existsSync(path.join(scriptsOut, 'build-deck.js')), 'build-deck.js missing in output');
163
+ assert.ok(existsSync(path.join(scriptsOut, 'helper.py')), 'helper.py missing in output');
164
+
165
+ // Placeholder resolved in .mdc output
166
+ const mdc = await readFile(path.join(outputRoot, adapter.outputDir, 'rune-test-slide.mdc'), 'utf-8');
167
+ assert.ok(!mdc.includes('{scripts_dir}'), 'placeholder should be resolved');
168
+ assert.ok(mdc.includes('.cursor/rules/rune-test-slide-scripts/build-deck.js'), 'resolved path missing');
169
+
170
+ // Plain skill: no scripts dir created
171
+ assert.ok(!existsSync(path.join(outputRoot, adapter.outputDir, 'rune-test-plain-scripts')), 'plain skill should not have scripts dir');
172
+ } finally {
173
+ await rm(tmp, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ test('codex: scripts inside skill directory', async () => {
178
+ const tmp = await createTempSkillTree();
179
+ try {
180
+ const outputRoot = path.join(tmp, 'out');
181
+ const adapter = getAdapter('codex');
182
+ const stats = await buildAll({ runeRoot: tmp, outputRoot, adapter });
183
+
184
+ assert.ok(stats.scriptsCopied >= 2);
185
+
186
+ // Scripts inside skill dir: .codex/skills/rune-test-slide/scripts/
187
+ const scriptsOut = path.join(outputRoot, adapter.outputDir, 'rune-test-slide', 'scripts');
188
+ assert.ok(existsSync(path.join(scriptsOut, 'build-deck.js')), 'build-deck.js missing');
189
+ assert.ok(existsSync(path.join(scriptsOut, 'helper.py')), 'helper.py missing');
190
+
191
+ // Placeholder resolved
192
+ const md = await readFile(
193
+ path.join(outputRoot, adapter.outputDir, 'rune-test-slide', 'SKILL.md'),
194
+ 'utf-8',
195
+ );
196
+ assert.ok(!md.includes('{scripts_dir}'), 'placeholder should be resolved');
197
+ assert.ok(md.includes('.codex/skills/rune-test-slide/scripts/build-deck.js'), 'resolved path missing');
198
+ } finally {
199
+ await rm(tmp, { recursive: true, force: true });
200
+ }
201
+ });
202
+
203
+ test('skill without scripts: no error, no empty dirs', async () => {
204
+ const tmp = await createTempSkillTree();
205
+ try {
206
+ const outputRoot = path.join(tmp, 'out');
207
+ const adapter = getAdapter('windsurf');
208
+ const stats = await buildAll({ runeRoot: tmp, outputRoot, adapter });
209
+
210
+ // Should still build both skills
211
+ assert.strictEqual(stats.skillCount, 2);
212
+
213
+ // No scripts dir for plain skill
214
+ assert.ok(!existsSync(path.join(outputRoot, adapter.outputDir, 'rune-test-plain-scripts')));
215
+ } finally {
216
+ await rm(tmp, { recursive: true, force: true });
217
+ }
218
+ });
219
+
220
+ test('stats include scriptsCopied count', async () => {
221
+ const tmp = await createTempSkillTree();
222
+ try {
223
+ const outputRoot = path.join(tmp, 'out');
224
+ const adapter = getAdapter('generic');
225
+ const stats = await buildAll({ runeRoot: tmp, outputRoot, adapter });
226
+
227
+ assert.strictEqual(typeof stats.scriptsCopied, 'number');
228
+ assert.strictEqual(stats.scriptsCopied, 2);
229
+ } finally {
230
+ await rm(tmp, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ test('claude adapter: passthrough, no scripts processing', async () => {
235
+ const tmp = await createTempSkillTree();
236
+ try {
237
+ const outputRoot = path.join(tmp, 'out');
238
+ const adapter = getAdapter('claude');
239
+ const stats = await buildAll({ runeRoot: tmp, outputRoot, adapter });
240
+
241
+ // Claude is passthrough — no build
242
+ assert.strictEqual(stats.platform, 'claude');
243
+ assert.strictEqual(stats.skillCount, 0);
244
+ } finally {
245
+ await rm(tmp, { recursive: true, force: true });
246
+ }
247
+ });
248
+
249
+ test('scripts content copied as-is (byte-for-byte)', async () => {
250
+ const tmp = await createTempSkillTree();
251
+ try {
252
+ const outputRoot = path.join(tmp, 'out');
253
+ const adapter = getAdapter('cursor');
254
+ await buildAll({ runeRoot: tmp, outputRoot, adapter });
255
+
256
+ const original = await readFile(path.join(tmp, 'skills', 'test-slide', 'scripts', 'build-deck.js'), 'utf-8');
257
+ const copied = await readFile(path.join(outputRoot, adapter.outputDir, 'rune-test-slide-scripts', 'build-deck.js'), 'utf-8');
258
+ assert.strictEqual(copied, original, 'script content should be identical');
259
+ } finally {
260
+ await rm(tmp, { recursive: true, force: true });
261
+ }
262
+ });
263
+
264
+ test('multiple adapters resolve different paths', async () => {
265
+ const tmp = await createTempSkillTree();
266
+ try {
267
+ // Cursor (flat)
268
+ const cursorOut = path.join(tmp, 'out-cursor');
269
+ const cursorAdapter = getAdapter('cursor');
270
+ await buildAll({ runeRoot: tmp, outputRoot: cursorOut, adapter: cursorAdapter });
271
+ const cursorMdc = await readFile(path.join(cursorOut, '.cursor/rules/rune-test-slide.mdc'), 'utf-8');
272
+ assert.ok(cursorMdc.includes('.cursor/rules/rune-test-slide-scripts/'), 'cursor path');
273
+
274
+ // Codex (directory)
275
+ const codexOut = path.join(tmp, 'out-codex');
276
+ const codexAdapter = getAdapter('codex');
277
+ await buildAll({ runeRoot: tmp, outputRoot: codexOut, adapter: codexAdapter });
278
+ const codexMd = await readFile(path.join(codexOut, '.codex/skills/rune-test-slide/SKILL.md'), 'utf-8');
279
+ assert.ok(codexMd.includes('.codex/skills/rune-test-slide/scripts/'), 'codex path');
280
+ } finally {
281
+ await rm(tmp, { recursive: true, force: true });
282
+ }
283
+ });
284
+ });
@@ -155,4 +155,45 @@ describe('discoverTieredPacks', () => {
155
155
  rmSync(root, { recursive: true, force: true });
156
156
  }
157
157
  });
158
+
159
+ test('pro override tracks free pack in overrides array', async () => {
160
+ const { freePacks, proPacks, root } = createTierFixture();
161
+ try {
162
+ const packs = await discoverTieredPacks(freePacks, { pro: proPacks });
163
+ const saasPack = packs.find((p) => p.dirName === 'pro-saas');
164
+ assert.ok(saasPack, 'pro-saas should exist');
165
+ assert.strictEqual(saasPack.overrides.length, 1);
166
+ assert.strictEqual(saasPack.overrides[0].tier, 'free');
167
+ assert.strictEqual(saasPack.overrides[0].dirName, 'saas');
168
+ } finally {
169
+ rmSync(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ test('business override tracks both pro and free in overrides array', async () => {
174
+ const { freePacks, proPacks, bizPacks, root } = createTierFixture();
175
+ try {
176
+ const packs = await discoverTieredPacks(freePacks, { pro: proPacks, business: bizPacks });
177
+ const saasPack = packs.find((p) => p.dirName === 'business-saas');
178
+ assert.ok(saasPack, 'business-saas should exist');
179
+ // Should have both free and pro overrides (free was overridden by pro, then pro by business)
180
+ assert.strictEqual(saasPack.overrides.length, 2);
181
+ const overrideTiers = saasPack.overrides.map((o) => o.tier).sort();
182
+ assert.deepStrictEqual(overrideTiers, ['free', 'pro']);
183
+ } finally {
184
+ rmSync(root, { recursive: true, force: true });
185
+ }
186
+ });
187
+
188
+ test('free-only packs have empty overrides array', async () => {
189
+ const { freePacks, proPacks, root } = createTierFixture();
190
+ try {
191
+ const packs = await discoverTieredPacks(freePacks, { pro: proPacks });
192
+ const tradingPack = packs.find((p) => p.dirName === 'trading');
193
+ assert.ok(tradingPack, 'trading should exist');
194
+ assert.deepStrictEqual(tradingPack.overrides, []);
195
+ } finally {
196
+ rmSync(root, { recursive: true, force: true });
197
+ }
198
+ });
158
199
  });
@@ -47,6 +47,10 @@ export default {
47
47
  return text;
48
48
  },
49
49
 
50
+ scriptsDir(skillName) {
51
+ return `rune-${skillName}-scripts`;
52
+ },
53
+
50
54
  postProcess(content) {
51
55
  return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
52
56
  },
@@ -58,6 +58,10 @@ export default {
58
58
  return text;
59
59
  },
60
60
 
61
+ scriptsDir(skillName) {
62
+ return `rune-${skillName}/scripts`;
63
+ },
64
+
61
65
  postProcess(content) {
62
66
  return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
63
67
  },
@@ -55,6 +55,10 @@ export default {
55
55
  return text;
56
56
  },
57
57
 
58
+ scriptsDir(skillName) {
59
+ return `rune-${skillName}-scripts`;
60
+ },
61
+
58
62
  postProcess(content) {
59
63
  return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
60
64
  },
@@ -48,6 +48,10 @@ export default {
48
48
  return text;
49
49
  },
50
50
 
51
+ scriptsDir(skillName) {
52
+ return `rune-${skillName}-scripts`;
53
+ },
54
+
51
55
  postProcess(content) {
52
56
  return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
53
57
  },
@@ -52,6 +52,10 @@ export default {
52
52
  return text;
53
53
  },
54
54
 
55
+ scriptsDir(skillName) {
56
+ return `rune-${skillName}-scripts`;
57
+ },
58
+
55
59
  postProcess(content) {
56
60
  return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
57
61
  },
@@ -67,6 +67,10 @@ export default {
67
67
  return text;
68
68
  },
69
69
 
70
+ scriptsDir(skillName) {
71
+ return `rune-${skillName}/scripts`;
72
+ },
73
+
70
74
  postProcess(content) {
71
75
  return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
72
76
  },
@@ -48,6 +48,10 @@ export default {
48
48
  return text;
49
49
  },
50
50
 
51
+ scriptsDir(skillName) {
52
+ return `rune-${skillName}-scripts`;
53
+ },
54
+
51
55
  postProcess(content) {
52
56
  return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
53
57
  },
@@ -6,10 +6,11 @@
6
6
  */
7
7
 
8
8
  import { existsSync } from 'node:fs';
9
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import { extractCrossRefs, extractToolRefs, parsePack, parseSkill } from './parser.js';
12
12
  import { transformSkill } from './transformer.js';
13
+ import { resolveScriptsPath } from './transforms/scripts-path.js';
13
14
 
14
15
  /**
15
16
  * Discover all SKILL.md files in the skills directory
@@ -59,6 +60,29 @@ async function discoverPacks(extensionsDir, enabledPacks = null) {
59
60
  return paths.sort();
60
61
  }
61
62
 
63
+ /**
64
+ * Copy scripts directory from skill source to output.
65
+ *
66
+ * @param {string} sourceScriptsDir - e.g. skills/slides/scripts/
67
+ * @param {string} outputScriptsDir - e.g. .cursor/rules/rune-slides-scripts/
68
+ * @returns {Promise<string[]>} list of copied file paths
69
+ */
70
+ async function copyScriptsDir(sourceScriptsDir, outputScriptsDir) {
71
+ if (!existsSync(sourceScriptsDir)) return [];
72
+
73
+ const entries = await readdir(sourceScriptsDir, { recursive: true, withFileTypes: true });
74
+ const files = entries.filter((e) => e.isFile());
75
+ if (entries.length === 0) return [];
76
+
77
+ await cp(sourceScriptsDir, outputScriptsDir, { recursive: true });
78
+
79
+ // Return relative paths within the scripts dir (same structure as source after recursive cp)
80
+ return files.map((e) => {
81
+ const parent = e.parentPath || e.path;
82
+ return path.relative(sourceScriptsDir, path.join(parent, e.name));
83
+ });
84
+ }
85
+
62
86
  /**
63
87
  * Tier priority: higher number = higher priority (wins override)
64
88
  */
@@ -104,9 +128,12 @@ export async function discoverTieredPacks(freeExtDir, tierSources = {}, enabledP
104
128
  const priority = TIER_PRIORITY[tier] ?? 0;
105
129
  const existing = packMap.get(normalized);
106
130
 
107
- // Higher priority tier wins
131
+ // Higher priority tier wins — track overridden lower-tier entries for skill-level merging
108
132
  if (!existing || priority > existing.priority) {
109
- packMap.set(normalized, { path: packFile, tier, priority, dirName: entry.name });
133
+ const overrides = existing
134
+ ? [...(existing.overrides || []), { path: existing.path, tier: existing.tier, dirName: existing.dirName }]
135
+ : [];
136
+ packMap.set(normalized, { path: packFile, tier, priority, dirName: entry.name, overrides });
110
137
  }
111
138
  }
112
139
  }
@@ -187,6 +214,7 @@ export async function buildAll({
187
214
  packCount: 0,
188
215
  crossRefsResolved: 0,
189
216
  toolRefsResolved: 0,
217
+ scriptsCopied: 0,
190
218
  files: [],
191
219
  skipped: [],
192
220
  errors: [],
@@ -205,7 +233,25 @@ export async function buildAll({
205
233
  continue;
206
234
  }
207
235
 
208
- const { header, body, footer } = transformSkill(parsed, adapter);
236
+ const { header, body: rawBody, footer } = transformSkill(parsed, adapter);
237
+
238
+ // Resolve {scripts_dir} placeholder if adapter supports scripts
239
+ const skillSourceDir = path.dirname(skillPath);
240
+ const scriptsSource = path.join(skillSourceDir, 'scripts');
241
+ const hasScripts = existsSync(scriptsSource) && adapter.scriptsDir;
242
+ const scriptsRelPath = hasScripts
243
+ ? path.join(adapter.outputDir, adapter.scriptsDir(parsed.name)).replaceAll('\\', '/')
244
+ : null;
245
+ const body = hasScripts ? resolveScriptsPath(rawBody, scriptsRelPath) : rawBody;
246
+
247
+ // Warn if {scripts_dir} placeholder exists but no scripts/ folder to resolve it
248
+ if (!hasScripts && rawBody.includes('{scripts_dir}')) {
249
+ stats.errors.push({
250
+ file: skillPath,
251
+ error: `{scripts_dir} placeholder found but no scripts/ directory exists for skill "${parsed.name}"`,
252
+ });
253
+ }
254
+
209
255
  const output = [header, body, footer].filter(Boolean).join('\n');
210
256
 
211
257
  let outputPath;
@@ -226,6 +272,13 @@ export async function buildAll({
226
272
 
227
273
  await writeFile(outputPath, output, 'utf-8');
228
274
 
275
+ // Copy scripts/ directory if present
276
+ if (hasScripts) {
277
+ const scriptsOutput = path.join(outputDir, adapter.scriptsDir(parsed.name));
278
+ const copied = await copyScriptsDir(scriptsSource, scriptsOutput);
279
+ stats.scriptsCopied += copied.length;
280
+ }
281
+
229
282
  stats.skillCount++;
230
283
  stats.crossRefsResolved += parsed.crossRefs.length;
231
284
  stats.toolRefsResolved += parsed.toolRefs.length;
@@ -249,11 +302,38 @@ export async function buildAll({
249
302
  stats.tierOverrides.push({ pack: packName, tier: packEntry.tier });
250
303
  }
251
304
 
305
+ // Tier Override: merge skill manifests from lower tiers
306
+ // If a Pro/Business pack overrides a Free pack, inherit skills the higher tier doesn't provide
307
+ if (packEntry.overrides?.length > 0 && parsed.isSplit && parsed.skillManifest.length > 0) {
308
+ const winnerSkillNames = new Set(parsed.skillManifest.map((s) => s.name));
309
+ for (const lower of packEntry.overrides) {
310
+ try {
311
+ const lowerContent = await readFile(lower.path, 'utf-8');
312
+ const lowerParsed = parsePack(lowerContent, lower.path);
313
+ if (lowerParsed.isSplit) {
314
+ const lowerPackDir = path.dirname(lower.path);
315
+ for (const lowerSkill of lowerParsed.skillManifest) {
316
+ if (!winnerSkillNames.has(lowerSkill.name)) {
317
+ // Inherit skill from lower tier — track source directory for file resolution
318
+ parsed.skillManifest.push({ ...lowerSkill, _sourceDir: lowerPackDir });
319
+ winnerSkillNames.add(lowerSkill.name);
320
+ stats.tierOverrides.push({ pack: packName, skill: lowerSkill.name, inherited: lower.tier });
321
+ }
322
+ }
323
+ }
324
+ } catch {
325
+ // Lower-tier pack unreadable — skip gracefully
326
+ }
327
+ }
328
+ }
329
+
252
330
  // For split packs, load individual skill files and concatenate into body
253
331
  if (parsed.isSplit && parsed.skillManifest.length > 0) {
254
332
  const skillBodies = [];
255
333
  for (const skill of parsed.skillManifest) {
256
- const skillPath = path.join(packDir, skill.file);
334
+ // Resolve skill file path — use _sourceDir for inherited lower-tier skills
335
+ const sourceDir = skill._sourceDir || packDir;
336
+ const skillPath = path.join(sourceDir, skill.file);
257
337
  if (existsSync(skillPath)) {
258
338
  const skillContent = await readFile(skillPath, 'utf-8');
259
339
  // Strip frontmatter from skill file — we only need the body
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Scripts Path Transform
3
+ *
4
+ * Replaces {scripts_dir} placeholder in skill body with the
5
+ * platform-resolved scripts directory path.
6
+ */
7
+
8
+ /**
9
+ * Replace {scripts_dir} placeholder with platform-resolved path.
10
+ *
11
+ * @param {string} body - skill body text
12
+ * @param {string} scriptsPath - resolved path for this platform
13
+ * @returns {string} body with placeholders resolved
14
+ */
15
+ export function resolveScriptsPath(body, scriptsPath) {
16
+ if (!body || !scriptsPath) return body;
17
+ return body.replaceAll('{scripts_dir}', scriptsPath);
18
+ }
@@ -3,7 +3,7 @@ name: "@rune/zalo"
3
3
  description: Zalo platform integration — Official Account API (OAuth2, messaging, webhooks, MCP server) and personal account automation (zca-js). Dual-track with explicit risk gating.
4
4
  metadata:
5
5
  author: runedev
6
- version: "0.1.0"
6
+ version: "0.2.0"
7
7
  layer: L4
8
8
  price: free
9
9
  target: Vietnamese developers building Zalo bots, OA automation, and AI agent integrations
@@ -116,6 +116,25 @@ Called By ← mcp-builder (L2): when building Zalo-specific MCP server
116
116
  | Webhook signature verification skipped in development, then deployed to production unsigned | HIGH | Always validate `X-Zalo-Signature` header from first commit — skip in dev only via explicit `SKIP_WEBHOOK_VERIFY=true` env flag |
117
117
  | Rate limit hit causes account ban with no warning (HTTP 429 mishandled as transient error) | HIGH | Implement token bucket per endpoint; treat sustained 429s as ban-risk signal and back off for 60+ seconds |
118
118
 
119
+ ## References
120
+
121
+ | Reference | Trigger |
122
+ |-----------|---------|
123
+ | [VietQR & Banking](references/vietqr-banking.md) | Payment, bank transfer, QR code patterns detected |
124
+ | [Conversation Management](references/conversation-management.md) | Polls, auto-reply, mute/archive, advanced messaging |
125
+ | [MCP Production](references/mcp-production.md) | MCP server deployment, cursor pagination, pm2 setup |
126
+ | [Multi-Account & Proxy](references/multi-account-proxy.md) | Multi-account setup, proxy configuration, VPS deployment |
127
+ | [Listen Mode](references/listen-mode.md) | WebSocket listener, real-time events, webhook forwarding |
128
+ | [Eval Scenarios](references/eval-scenarios.md) | Quality gate — 24 test scenarios (functional + security) |
129
+
130
+ ## Credits
131
+
132
+ This pack was originally inspired by and incorporates patterns from:
133
+
134
+ - **[zalo-agent-cli](https://github.com/PhucMPham/zalo-agent-cli)** by PhucMPham (MIT) — CLI tool for Zalo automation, 90+ commands, MCP server, VietQR banking integration
135
+ - **[openzalo](https://github.com/darkamenosa/openzalo)** by darkamenosa — OpenClaw channel plugin for Zalo personal accounts via openzca CLI
136
+ - **[zca-js](https://github.com/RFS-ADRENO/zca-js)** — Unofficial Zalo client library (reverse-engineered API)
137
+
119
138
  ## Done When
120
139
 
121
140
  - OA OAuth2 flow working with auto-refresh