@vruum/skills 0.6.43 → 0.6.45

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vruum",
3
- "version": "0.6.43",
3
+ "version": "0.6.45",
4
4
  "description": "Vruum AI skills + remote MCP server for B2B GTM teams. Slash commands for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis, paired with the full Vruum MCP tool surface over OAuth 2.1.",
5
5
  "author": {
6
6
  "name": "Vruum AI",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vruum",
3
- "version": "0.6.43",
3
+ "version": "0.6.45",
4
4
  "description": "Vruum AI skills + remote MCP server for B2B GTM teams. Skills for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis, paired with the full Vruum MCP tool surface over OAuth 2.1.",
5
5
  "author": {
6
6
  "name": "Vruum AI",
package/install.js CHANGED
@@ -196,6 +196,45 @@ function syncVruumRoot({ dryRun }) {
196
196
  return rows;
197
197
  }
198
198
 
199
+ function pathApiFor(platform) {
200
+ return platform === 'win32' ? path.win32 : path.posix;
201
+ }
202
+
203
+ function normalizePathForComparison(value, platform = process.platform) {
204
+ const pathApi = pathApiFor(platform);
205
+ let normalized = pathApi.resolve(value);
206
+ if (platform === 'win32') {
207
+ // Junction readlink results may use a \\?\ namespace prefix and different
208
+ // casing from os.homedir(). Normalize both forms for identity/ownership.
209
+ normalized = pathApi.toNamespacedPath(normalized).toLowerCase();
210
+ }
211
+ const root = pathApi.parse(normalized).root;
212
+ while (normalized.length > root.length && normalized.endsWith(pathApi.sep)) {
213
+ normalized = normalized.slice(0, -1);
214
+ }
215
+ return normalized;
216
+ }
217
+
218
+ function pathsEqual(left, right, platform = process.platform) {
219
+ return normalizePathForComparison(left, platform)
220
+ === normalizePathForComparison(right, platform);
221
+ }
222
+
223
+ function pathIsWithin(candidate, root, platform = process.platform) {
224
+ const pathApi = pathApiFor(platform);
225
+ const normalizedCandidate = normalizePathForComparison(candidate, platform);
226
+ const normalizedRoot = normalizePathForComparison(root, platform);
227
+ return normalizedCandidate === normalizedRoot
228
+ || normalizedCandidate.startsWith(normalizedRoot + pathApi.sep);
229
+ }
230
+
231
+ function resolveLinkTarget(dst, linkTarget, platform = process.platform) {
232
+ const pathApi = pathApiFor(platform);
233
+ return pathApi.isAbsolute(linkTarget)
234
+ ? pathApi.resolve(linkTarget)
235
+ : pathApi.resolve(pathApi.dirname(dst), linkTarget);
236
+ }
237
+
199
238
  // A symlink is "ours" if its target resolves into ~/.vruum/skills/. Resolve
200
239
  // textually from the readlink string (relative links against the link's own
201
240
  // dir) — never realpathSync, which THROWS on a dangling link, i.e. exactly the
@@ -204,21 +243,14 @@ function syncVruumRoot({ dryRun }) {
204
243
  // ~/.vruum/skills-operator, so a bare startsWith would wrongly claim the
205
244
  // operator installer's links. Equality covers a link straight at the dir.
206
245
  function isOurLink(dst, linkTarget) {
207
- const resolved = path.isAbsolute(linkTarget)
208
- ? path.resolve(linkTarget)
209
- : path.resolve(path.dirname(dst), linkTarget);
210
- return resolved === VRUUM_SKILLS || resolved.startsWith(VRUUM_SKILLS + path.sep);
246
+ return pathIsWithin(resolveLinkTarget(dst, linkTarget), VRUUM_SKILLS);
211
247
  }
212
248
 
213
249
  // Operator skills are a strict superset with multi-company scoping. When both
214
250
  // installers are present, operator links win for overlapping names regardless
215
251
  // of install order. Public-only names still link normally.
216
252
  function isOperatorLink(dst, linkTarget) {
217
- const resolved = path.isAbsolute(linkTarget)
218
- ? path.resolve(linkTarget)
219
- : path.resolve(path.dirname(dst), linkTarget);
220
- return resolved === VRUUM_OPERATOR_ROOT
221
- || resolved.startsWith(VRUUM_OPERATOR_ROOT + path.sep);
253
+ return pathIsWithin(resolveLinkTarget(dst, linkTarget), VRUUM_OPERATOR_ROOT);
222
254
  }
223
255
 
224
256
  function hasSkillFile(dir) {
@@ -297,14 +329,12 @@ function linkSkill({ name, srcAbs, target, dryRun }) {
297
329
  if (err.code !== 'ENOENT') throw err;
298
330
  }
299
331
 
300
- if (existing?.kind === 'symlink' && existing.target === srcAbs) {
301
- return { name, target, action: 'already-linked' };
302
- }
303
332
  const resolvedExistingTarget = existing?.kind === 'symlink'
304
- ? (path.isAbsolute(existing.target)
305
- ? path.resolve(existing.target)
306
- : path.resolve(path.dirname(dst), existing.target))
333
+ ? resolveLinkTarget(dst, existing.target)
307
334
  : null;
335
+ if (resolvedExistingTarget && pathsEqual(resolvedExistingTarget, srcAbs)) {
336
+ return { name, target, action: 'already-linked' };
337
+ }
308
338
  const operatorSkillIsUsable = resolvedExistingTarget
309
339
  && isOperatorLink(dst, existing.target)
310
340
  && hasSkillFile(resolvedExistingTarget);
@@ -330,7 +360,9 @@ function linkSkill({ name, srcAbs, target, dryRun }) {
330
360
  if (existing?.kind === 'symlink') {
331
361
  fs.unlinkSync(dst);
332
362
  }
333
- fs.symlinkSync(srcAbs, dst, 'dir');
363
+ // Windows directory symlinks require Developer Mode or elevation. Directory
364
+ // junctions work for this absolute, same-machine target without either.
365
+ fs.symlinkSync(srcAbs, dst, process.platform === 'win32' ? 'junction' : 'dir');
334
366
  return { name, target, action: existing ? 'relinked' : 'linked' };
335
367
  }
336
368
 
@@ -545,7 +577,7 @@ function commandList({ targets: extraTargets }) {
545
577
  const lstat = fs.lstatSync(dst);
546
578
  if (lstat.isSymbolicLink()) {
547
579
  const linkTarget = fs.readlinkSync(dst);
548
- const match = linkTarget === srcAbs;
580
+ const match = pathsEqual(resolveLinkTarget(dst, linkTarget), srcAbs);
549
581
  console.log(` ${match ? 'installed' : 'other-link'.padEnd(10)} ${skillName}${match ? '' : ` -> ${linkTarget}`}`);
550
582
  } else {
551
583
  console.log(` present ${skillName} (not our symlink)`);
@@ -598,6 +630,8 @@ if (require.main === module) main();
598
630
  module.exports = {
599
631
  parseArgs,
600
632
  listAvailableSkills,
633
+ pathsEqual,
634
+ pathIsWithin,
601
635
  linkSkill,
602
636
  isOurLink,
603
637
  isOperatorLink,
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@vruum/skills",
3
- "version": "0.6.43",
3
+ "version": "0.6.45",
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": {
7
7
  "type": "git",
8
- "url": "https://github.com/vruum-gtm/vruum_ai.git",
9
- "directory": ".agents"
8
+ "url": "git+https://github.com/vruum-gtm/skills.git"
10
9
  },
11
10
  "homepage": "https://vruum.ai",
12
11
  "bugs": {
@@ -42,5 +41,5 @@
42
41
  "outreach",
43
42
  "gtm"
44
43
  ],
45
- "contentHash": "936d7001a7e17a1ce672e7fde300f4f6e10726a7148c792b06a1f59a3f1f6d5a"
44
+ "contentHash": "843a119196d95663bc210d5543eb53a5f48a2a2780d425afce7c713577d4b1ac"
46
45
  }
@@ -234,7 +234,9 @@ Apply the requested mode before any persistence:
234
234
  Per surviving prospect:
235
235
 
236
236
  ### a. Save company research (once per company)
237
- When Phase A produced any newly researched fixed fields, call `research(action="save_company", payload={idempotency_key: <stable run/company save key>, name: <Phase A COMPANY>, website: <Phase A DOMAIN or canonical URL>, company_summary, company_stage, funding_data, growth_metrics, current_priorities: <newline-joined descriptions>, sources_by_field})`. The API field is `name`, not `company_name`; it accepts `website`, not `domain`; and `current_priorities` is one string. Omit reusable fields that were not revalidated so the atomic patch preserves them. Explicit null deliberately clears a field, so do not send null merely because Phase A did not research it. `sources_by_field` keys must equal exactly the supplied non-null research fields. Preserve the identical idempotency key and payload for unknown-commit replay; every bulk item needs its own key.
237
+ When Phase A produced any newly researched fixed fields, call `research(action="save_company", payload={idempotency_key: <stable run/company save key>, name: <Phase A COMPANY>, website: <Phase A DOMAIN or canonical URL>, person_id: <the person's Vruum UUID, when researching an EXISTING person's employer>, company_summary, company_stage, funding_data, growth_metrics, current_priorities: <newline-joined descriptions>, sources_by_field})`. The API field is `name`, not `company_name`; it accepts `website`, not `domain`; and `current_priorities` is one string. Omit reusable fields that were not revalidated so the atomic patch preserves them. Explicit null deliberately clears a field, so do not send null merely because Phase A did not research it. `sources_by_field` keys must equal exactly the supplied non-null research fields. Preserve the identical idempotency key and payload for unknown-commit replay; every bulk item needs its own key.
238
+
239
+ **Person linkage check (VRU-767):** when the research is about a saved person's employer (triage/authoring-time refresh), ALWAYS pass their UUID as `payload.person_id` and read the response's `person_link`. `matched`/`linked`/`repointed` mean the person's future touches will see this research. `mismatch` means no pin was written because the evidence disagreed — the pin sits on a DIFFERENT company (anchored, or a stub whose name carries more identity than the researched one), or an unpinned person's known positions show no role at the researched company. People can hold multiple positions and researching a secondary employer never switches the primary pin. This research will NOT surface on their touches — verify which company they actually work for before authoring from it. `conflict` is a transient race (the pin changed mid-save): replay the identical payload with the same idempotency key once — `person_id` is exempt from the idempotency hash, so adding it to a replay of an earlier save is also the supported repair path. Never assume a bare `success` means the research reached the person.
238
240
 
239
241
  ### b. Identity prep (names + company linkage for the atomic save)
240
242