@vruum/skills 0.6.1 → 0.6.3
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.
- package/install.js +87 -21
- package/package.json +5 -2
- package/skills/campaign-doctor/SKILL.md +17 -17
package/install.js
CHANGED
|
@@ -191,6 +191,59 @@ function syncVruumRoot({ dryRun }) {
|
|
|
191
191
|
return rows;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// A symlink is "ours" if its target resolves into ~/.vruum/skills/. Resolve
|
|
195
|
+
// textually from the readlink string (relative links against the link's own
|
|
196
|
+
// dir) — never realpathSync, which THROWS on a dangling link, i.e. exactly the
|
|
197
|
+
// renamed/removed-skill bug case we need to prune. The trailing path.sep
|
|
198
|
+
// boundary matters: ~/.vruum/skills is a string prefix of
|
|
199
|
+
// ~/.vruum/skills-operator, so a bare startsWith would wrongly claim the
|
|
200
|
+
// operator installer's links. Equality covers a link straight at the dir.
|
|
201
|
+
function isOurLink(dst, linkTarget) {
|
|
202
|
+
const resolved = path.isAbsolute(linkTarget)
|
|
203
|
+
? path.resolve(linkTarget)
|
|
204
|
+
: path.resolve(path.dirname(dst), linkTarget);
|
|
205
|
+
return resolved === VRUUM_SKILLS || resolved.startsWith(VRUUM_SKILLS + path.sep);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Prune pass — after relinking current skills, remove links for skills no
|
|
209
|
+
// longer in the package (renamed or deleted), but ONLY symlinks this installer
|
|
210
|
+
// owns (target under ~/.vruum/skills/). Non-symlinks (real user dirs/files) and
|
|
211
|
+
// foreign symlinks (the operator installer's links, arbitrary user links) are
|
|
212
|
+
// never touched, so the two npm installers + the bash setup coexist in the same
|
|
213
|
+
// harness dir without pruning each other.
|
|
214
|
+
function pruneStaleLinks({ target, skills, dryRun }) {
|
|
215
|
+
const results = [];
|
|
216
|
+
let entries;
|
|
217
|
+
try {
|
|
218
|
+
entries = fs.readdirSync(target);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (err.code === 'ENOENT') return results;
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
const current = new Set(skills);
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (current.has(entry)) continue; // current skill — keep
|
|
226
|
+
const dst = path.join(target, entry);
|
|
227
|
+
let linkTarget;
|
|
228
|
+
try {
|
|
229
|
+
const lstat = fs.lstatSync(dst);
|
|
230
|
+
if (!lstat.isSymbolicLink()) continue; // never touch non-symlinks
|
|
231
|
+
linkTarget = fs.readlinkSync(dst);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err.code === 'ENOENT') continue; // raced away
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
236
|
+
if (!isOurLink(dst, linkTarget)) continue; // foreign symlink — keep
|
|
237
|
+
if (dryRun) {
|
|
238
|
+
results.push({ name: entry, target, action: 'would-prune' });
|
|
239
|
+
} else {
|
|
240
|
+
fs.unlinkSync(dst);
|
|
241
|
+
results.push({ name: entry, target, action: 'pruned' });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
|
|
194
247
|
function ensureTargetDir(target, dryRun) {
|
|
195
248
|
if (fs.existsSync(target)) return { created: false };
|
|
196
249
|
if (dryRun) return { created: 'would' };
|
|
@@ -283,6 +336,11 @@ function commandInstall({ targets: extraTargets, dryRun }) {
|
|
|
283
336
|
const srcAbs = path.join(VRUUM_SKILLS, skillName);
|
|
284
337
|
summary.push(linkSkill({ name: skillName, srcAbs, target: target.dir, dryRun }));
|
|
285
338
|
}
|
|
339
|
+
// Prune links we own for skills no longer in the package (renamed/removed),
|
|
340
|
+
// so installed clients don't keep a broken slash command.
|
|
341
|
+
for (const row of pruneStaleLinks({ target: target.dir, skills, dryRun })) {
|
|
342
|
+
summary.push(row);
|
|
343
|
+
}
|
|
286
344
|
}
|
|
287
345
|
|
|
288
346
|
const prefix = dryRun ? '[dry-run] ' : '';
|
|
@@ -313,7 +371,6 @@ function commandInstall({ targets: extraTargets, dryRun }) {
|
|
|
313
371
|
}
|
|
314
372
|
|
|
315
373
|
function commandUninstall({ targets: extraTargets, dryRun }) {
|
|
316
|
-
const skills = listAvailableSkills();
|
|
317
374
|
const targets = detectTargets(extraTargets);
|
|
318
375
|
const prefix = dryRun ? '[dry-run] ' : '';
|
|
319
376
|
console.log(`${prefix}@vruum/skills v${VERSION} uninstall`);
|
|
@@ -322,18 +379,21 @@ function commandUninstall({ targets: extraTargets, dryRun }) {
|
|
|
322
379
|
console.error('No AI harness skill directories detected.');
|
|
323
380
|
}
|
|
324
381
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
const resolved = path.resolve(linkTarget);
|
|
330
|
-
return resolved.startsWith(VRUUM_SKILLS + path.sep) || resolved === VRUUM_SKILLS;
|
|
331
|
-
};
|
|
332
|
-
|
|
382
|
+
// Scan each target dir and remove every symlink we own — not just the
|
|
383
|
+
// current skill names — so stale links left by renamed/removed skills are
|
|
384
|
+
// cleaned up too. Foreign symlinks and non-symlinks are left untouched
|
|
385
|
+
// (shared `isOurLink` ownership predicate).
|
|
333
386
|
for (const target of targets) {
|
|
334
387
|
console.log(`${prefix}target: ${target.dir}`);
|
|
335
|
-
|
|
336
|
-
|
|
388
|
+
let entries;
|
|
389
|
+
try {
|
|
390
|
+
entries = fs.readdirSync(target.dir);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (err.code === 'ENOENT') continue;
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
for (const entry of entries) {
|
|
396
|
+
const dst = path.join(target.dir, entry);
|
|
337
397
|
let existing = null;
|
|
338
398
|
try {
|
|
339
399
|
const lstat = fs.lstatSync(dst);
|
|
@@ -346,19 +406,14 @@ function commandUninstall({ targets: extraTargets, dryRun }) {
|
|
|
346
406
|
if (err.code !== 'ENOENT') throw err;
|
|
347
407
|
}
|
|
348
408
|
|
|
349
|
-
if (!existing) {
|
|
350
|
-
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
if (existing.kind !== 'symlink' || !isOurLink(existing.target)) {
|
|
354
|
-
console.log(` ${prefix}skipped ${skillName} [not our symlink]`);
|
|
355
|
-
continue;
|
|
409
|
+
if (!existing || existing.kind !== 'symlink' || !isOurLink(dst, existing.target)) {
|
|
410
|
+
continue; // not our symlink — leave it
|
|
356
411
|
}
|
|
357
412
|
if (dryRun) {
|
|
358
|
-
console.log(` ${prefix}would-remove ${
|
|
413
|
+
console.log(` ${prefix}would-remove ${entry}`);
|
|
359
414
|
} else {
|
|
360
415
|
fs.unlinkSync(dst);
|
|
361
|
-
console.log(` ${prefix}removed ${
|
|
416
|
+
console.log(` ${prefix}removed ${entry}`);
|
|
362
417
|
}
|
|
363
418
|
}
|
|
364
419
|
}
|
|
@@ -461,4 +516,15 @@ function main() {
|
|
|
461
516
|
}
|
|
462
517
|
}
|
|
463
518
|
|
|
464
|
-
main();
|
|
519
|
+
if (require.main === module) main();
|
|
520
|
+
|
|
521
|
+
module.exports = {
|
|
522
|
+
parseArgs,
|
|
523
|
+
listAvailableSkills,
|
|
524
|
+
linkSkill,
|
|
525
|
+
isOurLink,
|
|
526
|
+
pruneStaleLinks,
|
|
527
|
+
commandInstall,
|
|
528
|
+
commandUninstall,
|
|
529
|
+
commandList,
|
|
530
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vruum/skills",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Vruum AI skills for Claude Code, Claude Desktop, Codex CLI, and any AI assistant with a skill directory. Slash commands for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis. Pairs with the Vruum MCP server at https://api.vruum.ai/mcp.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=18"
|
|
28
28
|
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test __tests__/*.test.js"
|
|
31
|
+
},
|
|
29
32
|
"keywords": [
|
|
30
33
|
"vruum",
|
|
31
34
|
"mcp",
|
|
@@ -36,5 +39,5 @@
|
|
|
36
39
|
"outreach",
|
|
37
40
|
"gtm"
|
|
38
41
|
],
|
|
39
|
-
"contentHash": "
|
|
42
|
+
"contentHash": "84f0242c92db500ae002eaf510f32f78dd1cad34c25af6849a16e42032b33d5d"
|
|
40
43
|
}
|
|
@@ -5,24 +5,24 @@ description: >-
|
|
|
5
5
|
diagnose campaign, why is my campaign not working, campaign health, low reply
|
|
6
6
|
rate, check campaigns, which campaigns need help.
|
|
7
7
|
---
|
|
8
|
-
#
|
|
8
|
+
# Campaign Doctor
|
|
9
9
|
|
|
10
|
-
You are a
|
|
10
|
+
You are a campaign diagnostics and optimization agent. Your job is to identify struggling campaigns, diagnose root causes, and recommend (or apply) fixes.
|
|
11
11
|
|
|
12
12
|
## Workflow
|
|
13
13
|
|
|
14
14
|
### Step 1: Triage — health + trends
|
|
15
15
|
|
|
16
|
-
Call `get_campaigns` to list all
|
|
16
|
+
Call `get_campaigns` to list all campaigns. For each campaign, dispatch three calls in parallel:
|
|
17
17
|
- `diagnose_campaign(campaign_id=X)` — returns `health_score` (0–100), reply-rate vs company average, and ranked root causes (targeting, messaging, channel, saturation, cadence, timing).
|
|
18
18
|
- `get_performance_metrics(view='funnel', campaign_id=X, start_date=<today_utc - 6d>, end_date=<today_utc + 1d>)` — current 7-day window (7 full days ending today, inclusive).
|
|
19
19
|
- `get_performance_metrics(view='funnel', campaign_id=X, start_date=<today_utc - 13d>, end_date=<today_utc - 7d>)` — prior 7-day window (7 full days ending the day before current starts — no shared days).
|
|
20
20
|
|
|
21
21
|
Use **UTC** dates in `YYYY-MM-DD` format. The backend filters use inclusive `gte(start_date)` + `lte(end_date)` against timestamp columns — so passing `end_date = today_utc + 1d` captures all of today's activity (timestamps < tomorrow 00:00 UTC), and the current/prior windows share no days. Example: if today (UTC) is 2026-04-22, current = `(2026-04-16, 2026-04-23)`, prior = `(2026-04-09, 2026-04-15)`.
|
|
22
22
|
|
|
23
|
-
If `get_campaigns` returns no
|
|
23
|
+
If `get_campaigns` returns no campaigns, tell the user "No campaigns yet — create one in the Vruum app before running diagnosis" and stop.
|
|
24
24
|
|
|
25
|
-
Classify each
|
|
25
|
+
Classify each campaign by reply rate (from `diagnose_campaign` output). Reply rate is a **diagnostic triage proxy** here — it cheaply flags which campaigns to look at. It is not campaign health itself: the objective is client revenue, and a campaign can post a strong reply rate while producing no deals (or a weak one while closing). Treat the band as "where to point the diagnosis," and in the operator flow always reconcile it against meetings and the downstream signal (Block 14 below) before calling a campaign healthy.
|
|
26
26
|
|
|
27
27
|
- **CRITICAL** — 30-day reply rate < 5% with ≥20 sent
|
|
28
28
|
- **WARNING** — 30-day reply rate 5–10% with ≥20 sent
|
|
@@ -30,38 +30,38 @@ Classify each segment by reply rate (from `diagnose_campaign` output). Reply rat
|
|
|
30
30
|
- **INSUFFICIENT DATA** — `diagnose_campaign` returned `insufficient_data: true` (fewer than 20 sent in 30d)
|
|
31
31
|
|
|
32
32
|
For WoW delta, compute `(current_reply_rate - prior_reply_rate) / prior_reply_rate`. Guards:
|
|
33
|
-
- **Brand-new
|
|
33
|
+
- **Brand-new campaign** (prior window sent = 0): show "new campaign, WoW N/A".
|
|
34
34
|
- **Low-volume** (prior window sent < 5): show "low volume — WoW unreliable" instead of a percentage.
|
|
35
35
|
- **Zero-baseline** (prior sent ≥ 5 but prior reply_rate = 0, so denominator would be 0): show the absolute change as percentage points, e.g. "0% → 3.2% (first replies this week)" instead of dividing.
|
|
36
36
|
|
|
37
37
|
Present results grouped by urgency:
|
|
38
38
|
|
|
39
|
-
"
|
|
39
|
+
"Campaign health across N campaigns:
|
|
40
40
|
|
|
41
41
|
CRITICAL:
|
|
42
42
|
- 'IT Directors' — 2.1% reply rate (30d), 145 sent, 1 reply — WoW: -18%
|
|
43
|
-
- 'CFO Northeast' — 3.5% reply rate (30d), 28 sent, 1 reply — new
|
|
43
|
+
- 'CFO Northeast' — 3.5% reply rate (30d), 28 sent, 1 reply — new campaign, WoW N/A
|
|
44
44
|
|
|
45
45
|
WARNING:
|
|
46
46
|
- 'VP Engineering' — 7.2% reply rate (30d), trending down from 11% (WoW -34%)
|
|
47
47
|
|
|
48
48
|
INSUFFICIENT DATA (< 20 sends in 30d):
|
|
49
|
-
- 'New
|
|
49
|
+
- 'New Campaign' — only 8 sends. Need 20+ for diagnosis. Run /pipeline-fill to add volume, check back in a few days.
|
|
50
50
|
|
|
51
51
|
HEALTHY:
|
|
52
52
|
- 'DFW CFOs' — 14.3% reply rate (WoW +4%)
|
|
53
53
|
- 'Startup Founders' — 18.1% reply rate (low volume — WoW unreliable)
|
|
54
54
|
|
|
55
|
-
Want me to diagnose the critical and warning
|
|
55
|
+
Want me to diagnose the critical and warning campaigns?"
|
|
56
56
|
|
|
57
57
|
Key behaviors:
|
|
58
|
-
- Never auto-diagnose `insufficient_data`
|
|
59
|
-
- If `get_performance_metrics` returns an empty funnel for the prior window, treat it as "new
|
|
58
|
+
- Never auto-diagnose `insufficient_data` campaigns. They need more volume first.
|
|
59
|
+
- If `get_performance_metrics` returns an empty funnel for the prior window, treat it as "new campaign, WoW N/A" (not -100%).
|
|
60
60
|
- If the funnel is empty for the current window too, fall back to the 30-day reply rate from `diagnose_campaign` output — don't show a fake zero.
|
|
61
61
|
|
|
62
62
|
### Step 2: Diagnose root causes
|
|
63
63
|
|
|
64
|
-
For each
|
|
64
|
+
For each campaign the user wants to diagnose, you already have the `diagnose_campaign` output from Step 1's parallel calls. Present the findings:
|
|
65
65
|
|
|
66
66
|
"**'IT Directors'** — Health score: 25/100
|
|
67
67
|
|
|
@@ -69,7 +69,7 @@ Root causes (ranked):
|
|
|
69
69
|
1. **TARGETING (high)**: Average match score 58/100. Replied prospects average 82. Targeting is too broad.
|
|
70
70
|
→ Recommendation: Tighten target titles, add industry filters
|
|
71
71
|
|
|
72
|
-
2. **MESSAGING (high)**: Reply rate 2.1% vs company average 9.4% —
|
|
72
|
+
2. **MESSAGING (high)**: Reply rate 2.1% vs company average 9.4% — campaign performing at 22% of baseline.
|
|
73
73
|
→ Recommendation: Review tone instructions, consider A/B test
|
|
74
74
|
|
|
75
75
|
3. **CHANNEL (medium)**: Email 1.2%, LinkedIn 4.8% — LinkedIn is 4x more effective.
|
|
@@ -82,7 +82,7 @@ Want me to apply any of these fixes?"
|
|
|
82
82
|
|
|
83
83
|
Key behaviors:
|
|
84
84
|
- If `diagnose_campaign` returned `insufficient_data`, surface the tool's own `message` field verbatim. Don't re-derive the threshold logic.
|
|
85
|
-
- When multiple
|
|
85
|
+
- When multiple campaigns share the same root cause dimension (e.g., all have messaging issues), recommend a cross-campaign fix first.
|
|
86
86
|
|
|
87
87
|
### Step 3: Apply fixes (with approval)
|
|
88
88
|
|
|
@@ -105,7 +105,7 @@ Always confirm before applying. Show the exact fields that will change.
|
|
|
105
105
|
|
|
106
106
|
After all fixes are applied:
|
|
107
107
|
|
|
108
|
-
"
|
|
108
|
+
"Campaign doctor complete:
|
|
109
109
|
- 'IT Directors': Tightened target titles (removed 3 generic titles), shifted to LinkedIn-first channel mix
|
|
110
110
|
- 'VP Engineering': Updated tone instructions
|
|
111
111
|
- 'CFO Northeast': Suggested broader saved search; run /pipeline-fill once updated
|
|
@@ -114,7 +114,7 @@ Monitor results over the next 7 days. Run /campaign-doctor again next week to ch
|
|
|
114
114
|
|
|
115
115
|
## Notes
|
|
116
116
|
|
|
117
|
-
- `diagnose_campaign` requires 20+ sent touches in 30 days for meaningful analysis. For newer
|
|
117
|
+
- `diagnose_campaign` requires 20+ sent touches in 30 days for meaningful analysis. For newer campaigns, wait — do not attempt diagnosis.
|
|
118
118
|
- Reply-rate thresholds for health bands match `diagnose_campaign.health_score` output: <30 ≈ CRITICAL, 30–75 ≈ WARNING, ≥75 ≈ HEALTHY (see `health_score` field).
|
|
119
119
|
- WoW comparison uses two `get_performance_metrics(view='funnel', campaign_id=X)` calls — **always UTC dates in YYYY-MM-DD**, current = `(today-7d, today)`, prior = `(today-14d, today-7d)`. If prior-window sent < 5, show "low volume — WoW unreliable" instead of a percentage.
|
|
120
120
|
- Root causes are ranked by severity. Focus on the highest-severity issues first.
|