@vruum/skills 0.6.0 → 0.6.2

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/README.md CHANGED
@@ -75,7 +75,7 @@ npx @vruum/skills install --target /path/to/skills/dir
75
75
  - `/yc-pipeline-fill` — YC harness source for /pipeline-fill. Scrapes YC's public Algolia index, extracts founder LinkedIn URLs, dedups, hands a candidate list to /pipeline-fill for deep research and import. Use when: YC pipeline fill, source from YC, fill segment with YC founders, sales nav dried up, source YC.
76
76
  - `/csv-pipeline-fill` — CSV harness source for /pipeline-fill. Reads a CSV, auto-detects headers, maps columns, hands off to /pipeline-fill for harness deep research and import. Use when: import CSV, paste a CSV, csv import, prospect list from CSV, csv harness mode.
77
77
  - `/create-content` — Co-produce an on-voice LinkedIn content post — pull your own signal, steer the angle conversationally, draft in your voice, then save as draft, schedule, or publish. Use when: write a post, draft LinkedIn content, create content, post about, content co-production, help me write a post.
78
- - `/segment-doctor` — Diagnose and fix struggling outreach segments. Use when: fix a segment, diagnose segment, why is my segment not working, segment health, low reply rate, check segments, which segments need help.
78
+ - `/campaign-doctor` — Diagnose and fix struggling outreach campaigns. Use when: fix a campaign, diagnose campaign, why is my campaign not working, campaign health, low reply rate, check campaigns, which campaigns need help.
79
79
  - `/vruum-skills-upgrade` — Upgrade @vruum/skills to the latest npm version and re-sync ~/.vruum/. Use when: upgrade vruum skills, update vruum, pull latest vruum skills, or when the preamble reports UPGRADE_AVAILABLE.
80
80
  - `/winback-fill` — Source winback candidates from closed-lost deals or churned customers. Surfaces people who went silent or lost a deal >90 days ago, where the loss reason wasn't 'no_fit'. Use when: winback, win back churned, reactivate, revive cold deals, 90-day silent revival, lost deal recovery, lost customer outreach.
81
81
  <!-- generated:skills-end -->
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
- // A symlink is "ours" if it points into ~/.vruum/skills/. We accept any
326
- // target there (not just the current skill name) so stale links from
327
- // renamed skills still get cleaned up.
328
- const isOurLink = (linkTarget) => {
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
- for (const skillName of skills) {
336
- const dst = path.join(target.dir, skillName);
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
- console.log(` ${prefix}not-installed ${skillName}`);
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 ${skillName}`);
413
+ console.log(` ${prefix}would-remove ${entry}`);
359
414
  } else {
360
415
  fs.unlinkSync(dst);
361
- console.log(` ${prefix}removed ${skillName}`);
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.0",
3
+ "version": "0.6.2",
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": "5f98cbfea97d8be6ad68c50e12dca22533d20dcae067a0d5558d06e173ac1c72"
42
+ "contentHash": "753b0d94ad80acaf1986cadaa6502df2a6b6f32a913ec9b39349922331378345"
40
43
  }
@@ -1,9 +1,9 @@
1
1
  ---
2
- name: segment-doctor
2
+ name: campaign-doctor
3
3
  description: >-
4
- Diagnose and fix struggling outreach segments. Use when: fix a segment,
5
- diagnose segment, why is my segment not working, segment health, low reply
6
- rate, check segments, which segments need help.
4
+ Diagnose and fix struggling outreach campaigns. Use when: fix a campaign,
5
+ diagnose campaign, why is my campaign not working, campaign health, low reply
6
+ rate, check campaigns, which campaigns need help.
7
7
  ---
8
8
  # Segment Doctor
9
9
 
@@ -13,21 +13,21 @@ You are a segment diagnostics and optimization agent. Your job is to identify st
13
13
 
14
14
  ### Step 1: Triage — health + trends
15
15
 
16
- Call `get_segments` to list all segments. For each segment, dispatch three calls in parallel:
17
- - `diagnose_segment(segment_id=X)` — returns `health_score` (0–100), reply-rate vs company average, and ranked root causes (targeting, messaging, channel, saturation, cadence, timing).
18
- - `get_performance_metrics(view='funnel', segment_id=X, start_date=<today_utc - 6d>, end_date=<today_utc + 1d>)` — current 7-day window (7 full days ending today, inclusive).
19
- - `get_performance_metrics(view='funnel', segment_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).
16
+ Call `get_campaigns` to list all segments. For each segment, dispatch three calls in parallel:
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
+ - `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
+ - `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_segments` returns no segments, tell the user "No segments yet — create one in the Vruum app before running diagnosis" and stop.
23
+ If `get_campaigns` returns no segments, tell the user "No segments yet — create one in the Vruum app before running diagnosis" and stop.
24
24
 
25
- Classify each segment by reply rate (from `diagnose_segment` output). Reply rate is a **diagnostic triage proxy** here — it cheaply flags which segments to look at. It is not segment health itself: the objective is client revenue, and a segment 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 segment healthy.
25
+ Classify each segment by reply rate (from `diagnose_campaign` output). Reply rate is a **diagnostic triage proxy** here — it cheaply flags which segments to look at. It is not segment health itself: the objective is client revenue, and a segment 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 segment 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
29
29
  - **HEALTHY** — 30-day reply rate ≥ 10% (reply-rate-healthy — confirm it also produces meetings/deals before treating it as truly healthy)
30
- - **INSUFFICIENT DATA** — `diagnose_segment` returned `insufficient_data: true` (fewer than 20 sent in 30d)
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
33
  - **Brand-new segment** (prior window sent = 0): show "new segment, WoW N/A".
@@ -57,11 +57,11 @@ Want me to diagnose the critical and warning segments?"
57
57
  Key behaviors:
58
58
  - Never auto-diagnose `insufficient_data` segments. They need more volume first.
59
59
  - If `get_performance_metrics` returns an empty funnel for the prior window, treat it as "new segment, WoW N/A" (not -100%).
60
- - If the funnel is empty for the current window too, fall back to the 30-day reply rate from `diagnose_segment` output — don't show a fake zero.
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 segment the user wants to diagnose, you already have the `diagnose_segment` output from Step 1's parallel calls. Present the findings:
64
+ For each segment 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
 
@@ -81,18 +81,18 @@ Root causes (ranked):
81
81
  Want me to apply any of these fixes?"
82
82
 
83
83
  Key behaviors:
84
- - If `diagnose_segment` returned `insufficient_data`, surface the tool's own `message` field verbatim. Don't re-derive the threshold logic.
84
+ - If `diagnose_campaign` returned `insufficient_data`, surface the tool's own `message` field verbatim. Don't re-derive the threshold logic.
85
85
  - When multiple segments share the same root cause dimension (e.g., all have messaging issues), recommend a cross-segment fix first.
86
86
 
87
87
  ### Step 3: Apply fixes (with approval)
88
88
 
89
89
  For each recommended fix the user approves:
90
90
 
91
- - **Targeting fix**: Suggest specific ICP field changes and call `update_segment` with new `target_titles`, `target_industries`, or `positioning_angle`.
91
+ - **Targeting fix**: Suggest specific ICP field changes and call `update_campaign` with new `target_titles`, `target_industries`, or `positioning_angle`.
92
92
 
93
- - **Messaging fix**: Suggest revised `ai_tone_instructions` or `ai_selling_strategy` and call `update_segment`.
93
+ - **Messaging fix**: Suggest revised `ai_tone_instructions` or `ai_selling_strategy` and call `update_campaign`.
94
94
 
95
- - **Channel fix**: Call `update_segment` with adjusted `allowed_channels`.
95
+ - **Channel fix**: Call `update_campaign` with adjusted `allowed_channels`.
96
96
 
97
97
  - **Saturation fix (recommend only)**: The client flow doesn't manage pipeline sources directly. Instead:
98
98
  1. Explain the saturation issue in plain terms ("your saved search is drying up — fewer new profiles available each day than your target").
@@ -110,12 +110,12 @@ After all fixes are applied:
110
110
  - 'VP Engineering': Updated tone instructions
111
111
  - 'CFO Northeast': Suggested broader saved search; run /pipeline-fill once updated
112
112
 
113
- Monitor results over the next 7 days. Run /segment-doctor again next week to check progress."
113
+ Monitor results over the next 7 days. Run /campaign-doctor again next week to check progress."
114
114
 
115
115
  ## Notes
116
116
 
117
- - `diagnose_segment` requires 20+ sent touches in 30 days for meaningful analysis. For newer segments, wait — do not attempt diagnosis.
118
- - Reply-rate thresholds for health bands match `diagnose_segment.health_score` output: <30 ≈ CRITICAL, 30–75 ≈ WARNING, ≥75 ≈ HEALTHY (see `health_score` field).
119
- - WoW comparison uses two `get_performance_metrics(view='funnel', segment_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.
117
+ - `diagnose_campaign` requires 20+ sent touches in 30 days for meaningful analysis. For newer segments, wait — do not attempt diagnosis.
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
+ - 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.
121
121
  - Saturation fixes in the client flow are text recommendations only — clients cannot manage pipeline sources directly via MCP; they adjust Sales Nav and run `/pipeline-fill`.
@@ -56,7 +56,7 @@ The orchestrator's MCP precheck at the top of Step 3 (`get_research_playbook` ca
56
56
 
57
57
  ## Workflow — Step 1: Show pipeline status & pick segments
58
58
 
59
- Call `manage_sales_nav_searches(action="list")` + `get_outreach_stats` for queue depth + `get_segments` for non-Sales-Nav segments. Present a numbered table with **per-segment ETA**:
59
+ Call `manage_sales_nav_searches(action="list")` + `get_outreach_stats` for queue depth + `get_campaigns` for non-Sales-Nav segments. Present a numbered table with **per-segment ETA**:
60
60
 
61
61
  ```
62
62
  Pipeline status:
@@ -127,7 +127,7 @@ Drop blank lines and lines starting with `#` (treat as comments).
127
127
  **Path B — operator describes an ICP** (you want the harness to discover candidates)
128
128
  Operator gives a brief like "Series A-C SaaS founders, US, 50-500 ppl" or "directors of operations at MSPs in DFW, recently posted about hiring". Harness sources candidates from scratch:
129
129
 
130
- 1. **Anchor on segment ICP** — read the segment's existing ICP/company profile (via `get_segment` and `get_company_profile`) and merge with the operator's brief. Show a one-line synthesis ("OK so: Series A-C SaaS, US, 50-500 ppl, founder/CEO/CTO titles") and confirm before sourcing.
130
+ 1. **Anchor on segment ICP** — read the segment's existing ICP/company profile (via `get_campaign` and `get_company_profile`) and merge with the operator's brief. Show a one-line synthesis ("OK so: Series A-C SaaS, US, 50-500 ppl, founder/CEO/CTO titles") and confirm before sourcing.
131
131
  2. **Source companies first** — use harness tools to find candidate companies matching the brief:
132
132
  - `WebSearch` for funding announcements, news, lists ("Series A SaaS 2026", "TechCrunch Series B SaaS announcements")
133
133
  - `WebFetch` on Crunchbase / PitchBook / company directories