@vruum/skills 0.6.46 → 0.6.48
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +2 -2
- package/skills/campaign-builder/SKILL.md +9 -0
- package/skills/csv-pipeline-fill/SKILL.md +11 -0
- package/skills/outreach-triage/SKILL.md +2 -0
- package/skills/pipeline-fill/RESEARCH-ENGINE.md +33 -14
- package/skills/pipeline-fill/SKILL.md +3 -3
- package/skills/sales-nav-deep-fill/SKILL.md +5 -0
- package/skills/yc-pipeline-fill/SKILL.md +5 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vruum",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.48",
|
|
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.
|
|
3
|
+
"version": "0.6.48",
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vruum/skills",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.48",
|
|
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": {
|
|
@@ -41,5 +41,5 @@
|
|
|
41
41
|
"outreach",
|
|
42
42
|
"gtm"
|
|
43
43
|
],
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "531c180127ac433327c9639789d1845b98e18e7515db04dab9c9ee31dfee3be0"
|
|
45
45
|
}
|
|
@@ -52,6 +52,15 @@ Two paths — ask which:
|
|
|
52
52
|
- **Reuse messaging that works** (default when they name an existing campaign): `manage_campaign` action=clone id=<existing campaign uuid> payload={name: "<new name>"}. Cloning carries the messaging structure, tone, and CTA configuration. Find the source campaign with `search` type="campaigns" if you only have its name.
|
|
53
53
|
- **Fresh**: `manage_campaign` action=create payload={name, ...} — then offer to set tone/cadence via action=update once created.
|
|
54
54
|
|
|
55
|
+
### Exact wording: touch templates (optional)
|
|
56
|
+
|
|
57
|
+
If the seller wants the SAME proven email/message every time (instead of per-person AI drafting), set a **template** on the touch: `manage_campaign` action=update id=<campaign uuid> with the full `touch_sequence` where that step gains `{"template": {"subject": "...", "body": "..."}}`. Rules:
|
|
58
|
+
|
|
59
|
+
- Variables: `{{first_name}}`, `{{last_name}}`, `{{company}}`, `{{title}}` — nothing else. Substitution is deterministic; no AI touches the wording. The signature is plain text inside the body.
|
|
60
|
+
- Templates only render for content channels (email, linkedin_message, linkedin_inmail). Email and InMail templates REQUIRE a subject. Don't put templates on connection-request or phone steps.
|
|
61
|
+
- Templated touches mint as ready `draft` rows (generated_by_ai=false) straight into review; a person whose variables can't resolve stays `needs_draft` with `template_fallback_reason` — authored normally at triage.
|
|
62
|
+
- Adding a template never rewrites already-queued touches. To render it over EXISTING unauthored rows, call `manage_campaign` action=apply_template id=<campaign uuid> payload={step: N} and report rendered/skipped counts honestly.
|
|
63
|
+
|
|
55
64
|
## Step 4: Assign the cohort
|
|
56
65
|
|
|
57
66
|
Collect the person ids from the Step 2 preview (including IDs returned by the named-account handoff; re-run the same `search` with a higher `limit` to get the full cohort if needed — paginate with `offset` for big cohorts) and call `manage_campaign` action=members id=<campaign uuid> payload={action: "assign", person_ids: [...]}.
|
|
@@ -39,6 +39,10 @@ The first non-empty data row after the header offset is treated as the header. L
|
|
|
39
39
|
- `first_name` ← `first_name`, `first name`, `firstname`, `given name`
|
|
40
40
|
- `last_name` ← `last_name`, `last name`, `lastname`, `surname`, `family name`
|
|
41
41
|
- `company` ← `company`, `company name`, `account`, `organization`, `org`, `employer`
|
|
42
|
+
- `company_id` ← `company_id`, `company id`, `vruum company id` (only accept a valid UUID from a trusted Vruum export)
|
|
43
|
+
- `company_domain` ← `company_domain`, `company domain`, `domain`, `account domain`, `website domain`
|
|
44
|
+
- `company_website` ← `company_website`, `company website`, `account website`, `organization website`
|
|
45
|
+
- `company_linkedin_url` ← `company_linkedin_url`, `company linkedin`, `company linkedin url`, `account linkedin url`
|
|
42
46
|
- `linkedin_url` ← `linkedin`, `linkedin url`, `linkedin_url`, `profile`, `linkedin profile`, `linkedin_profile`, `li_url`
|
|
43
47
|
- `email` ← `email`, `email_address`, `work_email`, `business email`
|
|
44
48
|
- `title` ← `title`, `job_title`, `position`, `role`
|
|
@@ -59,6 +63,7 @@ Per row:
|
|
|
59
63
|
- Strip whitespace from all fields.
|
|
60
64
|
- Lowercase emails.
|
|
61
65
|
- Validate `linkedin_url` matches `^https?://(www\.)?linkedin\.com/in/[^/?]+/?(\?.*)?$`. If invalid (e.g. `https://linkedin.com/company/...`), set to null and log.
|
|
66
|
+
- Normalize `company_domain` to an apex domain (no scheme/path), `company_website` to an http(s) URL, and `company_linkedin_url` to a canonical LinkedIn `/company/` or `/school/` URL. Invalid values become null and are logged; never reinterpret a person LinkedIn URL as a company anchor.
|
|
62
67
|
- Strip query strings from LinkedIn URLs (`?utm_source=...` etc.) — canonicalize to `https://linkedin.com/in/<slug>/`.
|
|
63
68
|
- **Skip rows** where neither `linkedin_url` nor (`name` AND `company`) is present. Log the skipped count.
|
|
64
69
|
- **In-CSV dedup**: dedup the candidate list by lowercased `linkedin_url` (preferred), else by `<lowercased name> + <lowercased company>`. Log duplicates dropped.
|
|
@@ -77,6 +82,10 @@ Convert each row to the canonical shape (defined in `pipeline-fill/RESEARCH-ENGI
|
|
|
77
82
|
first_name: <first_name field if mapped, else null>,
|
|
78
83
|
last_name: <last_name field if mapped, else null>,
|
|
79
84
|
company: <company>,
|
|
85
|
+
company_id: <trusted Vruum company UUID or null>,
|
|
86
|
+
company_domain: <normalized apex or null>,
|
|
87
|
+
company_website: <canonical URL or null>,
|
|
88
|
+
company_linkedin_url: <canonical company/school URL or null>,
|
|
80
89
|
linkedin_url: <canonicalized URL or null>,
|
|
81
90
|
email: <email or null>,
|
|
82
91
|
person_id: null,
|
|
@@ -90,6 +99,8 @@ Convert each row to the canonical shape (defined in `pipeline-fill/RESEARCH-ENGI
|
|
|
90
99
|
}
|
|
91
100
|
```
|
|
92
101
|
|
|
102
|
+
Carry every valid employer anchor through to the engine. Rows with only a company name may still be researched, but they are not eligible for persistence unless Phase B resolves the current employer to a strong anchor.
|
|
103
|
+
|
|
93
104
|
The `csv_extra_columns` field keeps unmapped data on the candidate so an operator can later inspect it via `search` type=people if a question comes up about why a particular prospect was imported.
|
|
94
105
|
|
|
95
106
|
### Step 8: Hand off to /pipeline-fill (canonical handoff prompt)
|
|
@@ -86,6 +86,8 @@ Categorize into three processing groups:
|
|
|
86
86
|
2. **Follow-ups** (first_content_touch=false; fallback when null: sequence_number >= 2) — need research and quality check
|
|
87
87
|
3. **Openers** (first_content_touch=true; fallback when null: sequence_number = 1) — first real message regardless of T-number; full opener research standards, never "follow-up" framing
|
|
88
88
|
|
|
89
|
+
**Template-authored drafts (VRU-813).** A draft with `ai_decision_context.authored_by = "template"` is the OPERATOR'S OWN template rendered per person (deterministic `{{variable}}` substitution, `generated_by_ai=false`) — it arrives in the review lane already written. Review it for FIT (right person, correct variable values, thread coherence), not style: do NOT rewrite the operator's chosen wording per-row. If the prose itself is weak, that's a template-level problem — flag it once to the user so they fix the template in the campaign workbench (or via `manage_campaign` action=update), rather than editing N copies divergently. Conversely, a `needs_draft` row carrying `ai_decision_context.template_fallback_reason` is a person the template could NOT render for (missing variable value, or the channel fell back outside the template's step) — author it normally, and if many rows share one reason, surface the pattern (e.g. "31 people have no title on file") instead of silently hand-writing around a fixable data gap.
|
|
90
|
+
|
|
89
91
|
Present the queue composition before dispatching. For the counts in one call, use `search` with type=messages and `view=breakdown` — it returns grouped counts (by status, category, sequence/touch number, channel, and campaign) over the whole queue plus a compact items page, so you don't have to tally the pages yourself:
|
|
90
92
|
|
|
91
93
|
"Oaklet: 15 T1s, 5 T2s, 1 T3, 0 replies. How do you want to handle each group?"
|
|
@@ -19,6 +19,10 @@ All harness source skills produce candidate lists matching this shape exactly. T
|
|
|
19
19
|
first_name: string | null, // optional if full_name set; one of these MUST be present
|
|
20
20
|
last_name: string | null, // optional if full_name set
|
|
21
21
|
company: string | null, // "Acme Co" — null OK if linkedin_url is set
|
|
22
|
+
company_id: string | null, // canonical Vruum company UUID when already known
|
|
23
|
+
company_domain: string | null, // apex only, e.g. "acme.com"
|
|
24
|
+
company_website: string | null, // canonical http(s) company URL
|
|
25
|
+
company_linkedin_url: string | null, // canonical /company/ or /school/ URL
|
|
22
26
|
linkedin_url: string | null, // canonical /in/ URL — null OK if name+company set
|
|
23
27
|
email: string | null, // null = pending lookup; engine doesn't gate on email presence
|
|
24
28
|
person_id: string | null, // pre-existing Vruum person UUID; null for new (resolved in Step 7)
|
|
@@ -30,6 +34,8 @@ All harness source skills produce candidate lists matching this shape exactly. T
|
|
|
30
34
|
|
|
31
35
|
**Rules:**
|
|
32
36
|
- At minimum, each candidate needs **either** `linkedin_url` **or** (`name`-fields + `company`). Candidates with neither are skipped at Step 3.
|
|
37
|
+
- Company anchors are additive during sourcing/research: preserve every trustworthy `company_id`, `company_domain`, `company_website`, and `company_linkedin_url` through the handoff. A company name is useful research input but is **not** a strong identity anchor.
|
|
38
|
+
- A candidate may enter research without a strong company anchor because Phase B can recover one from the current LinkedIn work-experience record. It may **not** enter a save call without one; Step 7's company-binding invariant is absolute.
|
|
33
39
|
- **A company-only row (name/domain but no person) is not a valid candidate.** Sources holding companies must run the shared **Committee resolution** step in `SKILL.md` (companies → people, capped at `buyers_per_account`) before handing off to this engine. Do not improvise buyer selection out of company-research prose — that reintroduces marquee-name skew and an undocumented depth of 1 per account.
|
|
34
40
|
- `full_name` is a convenience for sources that don't pre-split. Engine's Step 7 splits via last-space heuristic (`Jane van der Merwe` → first=`Jane`, last=`van der Merwe`). Multi-token surnames like `Maria Del Carmen Garcia` may split imperfectly — Phase B's linkedin_fetch call (`research` action=linkedin_fetch) returns canonical first/last when `linkedin_url` is present and overrides the heuristic.
|
|
35
41
|
- Field additions are additive only. Removing a field is a breaking change for source skills.
|
|
@@ -103,7 +109,7 @@ Per campaign's candidate list:
|
|
|
103
109
|
|
|
104
110
|
1. **MCP precheck + ICP load** (above) — abort run on failure.
|
|
105
111
|
2. **Batch dedup against existing pipeline.** Call `search(type="people", query=[{name, company, linkedin_url} for each candidate])`. Returns one match record per candidate (in input order). Drop candidates with non-null `match` — they're already in pipeline.
|
|
106
|
-
3. **Batch company fixed-field reuse check.** Collect unique company domains from surviving candidates. Call `fetch(type="company_research", id=[the domains], filters={"requested_fields":["company_summary","company_stage","current_priorities","funding_data","growth_metrics"]})`.
|
|
112
|
+
3. **Batch company fixed-field reuse check.** Collect unique company domains from surviving candidates, deriving the apex from `company_website` when necessary. Call `fetch(type="company_research", id=[the domains], filters={"requested_fields":["company_summary","company_stage","current_priorities","funding_data","growth_metrics"]})`.
|
|
107
113
|
- Reuse only values whose field entry has `status="reusable"`.
|
|
108
114
|
- `core_reuse.reusable` means the shared summary core is reusable; it never means the campaign brief is complete.
|
|
109
115
|
- Missing, unsourced, stale, invalid, and absent fields remain null inputs. Never carry a raw stored value forward.
|
|
@@ -127,6 +133,8 @@ You are vruum-company-deep-researcher. Research this company against campaign "{
|
|
|
127
133
|
|
|
128
134
|
company_name: {name}
|
|
129
135
|
domain: {domain}
|
|
136
|
+
website: {company_website or null}
|
|
137
|
+
company_linkedin_url: {company_linkedin_url or null}
|
|
130
138
|
campaign_icp_summary: {one paragraph from the research_playbook fetch}
|
|
131
139
|
acv_floor: {dollars or default $10K}
|
|
132
140
|
|
|
@@ -164,6 +172,10 @@ full_name: {name}
|
|
|
164
172
|
first_name: {first_name or null}
|
|
165
173
|
last_name: {last_name or null}
|
|
166
174
|
company: {company}
|
|
175
|
+
company_id: {company_id or null}
|
|
176
|
+
company_domain: {company_domain or null}
|
|
177
|
+
company_website: {company_website or null}
|
|
178
|
+
company_linkedin_url: {company_linkedin_url or null}
|
|
167
179
|
linkedin_url: {url or null}
|
|
168
180
|
email: {email or null}
|
|
169
181
|
|
|
@@ -180,6 +192,8 @@ Run your workflow (a–k) and return the structured output block. Note: do NOT c
|
|
|
180
192
|
|
|
181
193
|
Each subagent returns: `first_name`, `last_name`, `email`, `linkedin_url`, `title`, `company_name`, `company_domain`, `company_website`, `company_linkedin_url`, `topics_of_interest`, `recent_posts`, `opening_hooks[]` (2–3, with source URLs), `decision_maker_level` (junior/mid/senior), `email_status` (found/pending), `role_start_date`, per-prospect `triggers[]`, `STATUS`. Every `recent_posts` item uses the backend shape `{text, posted_at?, share_url?, reaction_count?, comment_count?}`; never send the retired `content`, `url`, `excerpt`, or `date` keys. Note: `person_id` is NOT returned here — identity resolution happens in Step 7.
|
|
182
194
|
|
|
195
|
+
The Phase B result describes the prospect's **current** employer, not merely the company the source guessed. When LinkedIn shows a different current employer, replace the candidate's stale company fields with that current work-experience entry and its anchors before Step 7. If Phase B cannot produce either a `company_id` or `company_name` plus a valid anchor, return `STATUS: company_unresolved`; the orchestrator reports and skips that prospect instead of creating a company-less person.
|
|
196
|
+
|
|
183
197
|
**Inter-wave progress line:**
|
|
184
198
|
```
|
|
185
199
|
[PROGRESS] Phase B: {done}/{total} prospects researched, {dismissed_for_linkedin_unavailable} skipped, elapsed {M}m, eta {N}m
|
|
@@ -215,15 +229,18 @@ Per surviving prospect, evaluate four criteria using the campaign's playbook ICP
|
|
|
215
229
|
- `harness_gate_status: warming` — failed criterion 2 (no outbound motion)
|
|
216
230
|
- `harness_gate_status: low_priority` — failed criterion 4 (no recent trigger)
|
|
217
231
|
- `harness_gate_status: gate_inconclusive` — Phase A failed for this prospect's company (degraded mode)
|
|
232
|
+
- `harness_gate_status: company_unresolved` — Phase B could not verify a current employer with a canonical company anchor; skip pipeline persistence and enrollment
|
|
218
233
|
- `harness_gate_status: dismiss` — failed criterion 1 (acv) or 3 (junior, no senior swap available); skip backend call entirely
|
|
219
234
|
|
|
220
|
-
For non-dismiss outcomes, also set `dismiss_reason` to null and `flag` to the relevant reason (warming|low_priority|gate_inconclusive|null).
|
|
235
|
+
For non-dismiss outcomes, also set `dismiss_reason` to null and `flag` to the relevant reason (warming|low_priority|gate_inconclusive|company_unresolved|null).
|
|
221
236
|
|
|
222
237
|
**The gate is declarative prose — not a hardcoded function.** The orchestrator follows the rules above and tags each candidate. If a future criterion changes, edit this section.
|
|
223
238
|
|
|
224
239
|
---
|
|
225
240
|
|
|
226
|
-
## Step 7 — Save chain (
|
|
241
|
+
## Step 7 — Save chain (eligible outcomes only; dismiss and company_unresolved skip)
|
|
242
|
+
|
|
243
|
+
**Company-binding invariant (load-bearing):** every prospect that reaches pipeline persistence must have a verified current employer expressed as either `company_id` or `company_name` plus at least one strong anchor (`company_domain`, `company_website`, `company_linkedin_url`). A company name alone never qualifies. If the invariant cannot be satisfied after Phase B, mark the item `company_unresolved`, do not call `save_company` with a name-only identity, `save_discovered`, or `manage_outreach` for it, and surface it in Step 8. `save_person` may still refresh an already-saved person without changing their company, but that refresh does not make the item eligible to save or enroll. This is stricter than the candidate admission rule on purpose: research may resolve missing identity, pipeline persistence may not guess it.
|
|
227
244
|
|
|
228
245
|
Apply the requested mode before any persistence:
|
|
229
246
|
|
|
@@ -233,8 +250,8 @@ Apply the requested mode before any persistence:
|
|
|
233
250
|
|
|
234
251
|
Per surviving prospect:
|
|
235
252
|
|
|
236
|
-
### a.
|
|
237
|
-
|
|
253
|
+
### a. Resolve and save the company (once per unique current employer)
|
|
254
|
+
In `save` and `save-and-enroll` modes, reuse a trustworthy candidate `company_id` when one is already known. Otherwise call `research(action="save_company", payload={idempotency_key: <stable run/company save key>, name: <CURRENT COMPANY>, website: <canonical website or domain>, linkedin_url: <canonical LinkedIn company 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})` once per unique current employer and capture the returned `company_id`. This call is required for identity resolution even when Phase A produced no new fixed research fields; in that case omit those research fields and their evidence, but still send the strongest known company anchor. 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. Reuse the returned `company_id` for every prospect at that employer.
|
|
238
255
|
|
|
239
256
|
**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.
|
|
240
257
|
|
|
@@ -250,15 +267,15 @@ rejected save persists nothing. There is no create-then-adopt dance anymore.
|
|
|
250
267
|
- Last-space heuristic: split on the last space. `Jane Smith` → first=`Jane`, last=`Smith`. `Jane van der Merwe` → first=`Jane`, last=`van der Merwe`.
|
|
251
268
|
- **Override with Phase B canonical names** if the linkedin_fetch research call returned them. LinkedIn's `first_name`/`last_name` fields are authoritative; the heuristic is a fallback for candidates without `linkedin_url`.
|
|
252
269
|
|
|
253
|
-
2. **Prepare company linkage** —
|
|
270
|
+
2. **Prepare company linkage** — every save must identify the company unambiguously, ONE of:
|
|
254
271
|
|
|
255
272
|
**Path A (preferred): `company_id`.** Run the save_company call (`research` action=save_company) first, capture the returned `company_id`.
|
|
256
273
|
|
|
257
|
-
**Path B: `company_name` + at least one anchor** (`company_domain`, `company_website`, or `company_linkedin_url`). The data is in the LinkedIn payload you already fetched. The prospect's CURRENT employer is the entry in `work_experience[]` with `end_date: null` — that entry has `company_linkedin_url`. If you ran linkedin_fetch with `include_company: true`, the company response carries `website` and `industry`. **Anchor-less name-only saves are
|
|
274
|
+
**Path B: `company_name` + at least one anchor** (`company_domain`, `company_website`, or `company_linkedin_url`). The data is in the LinkedIn payload you already fetched. The prospect's CURRENT employer is the entry in `work_experience[]` with `end_date: null` — that entry has `company_linkedin_url`. If you ran linkedin_fetch with `include_company: true`, the company response carries `website` and `industry`. **Anchor-less name-only saves are forbidden even if a stale backend would accept them.**
|
|
258
275
|
|
|
259
|
-
|
|
276
|
+
For a **new** prospect, place the linkage inside `person`. For an **existing** `person_id`, send the same linkage at the top level of `save_discovered` (`company_id`, or `company_name` plus anchors). Never assume an existing person's prior membership is already bound.
|
|
260
277
|
|
|
261
|
-
|
|
278
|
+
3. **Refreshing someone ALREADY saved** (e.g. operator pasted a Vruum person UUID, or a triage-time research refresh): call `research(action="save_person", payload={person_id: <uuid>, ...fresh research fields})` — update-in-place, `researched_at` moves, and the response's `updated_fields`/`skipped_fields` tell you exactly what landed (contact fields are backfill-only; corrections go through `manage_person` action=update_contact). NEVER pass the UUID as the facade `id` argument — save_person takes no `id` and will 422. If save_person returns 404 `person_not_found_for_update`, the person isn't saved yet — use the step-c atomic save instead.
|
|
262
279
|
|
|
263
280
|
### c. Save discovered person — ONE atomic call (authoritative harness score)
|
|
264
281
|
|
|
@@ -322,7 +339,7 @@ manage_person(
|
|
|
322
339
|
)
|
|
323
340
|
```
|
|
324
341
|
|
|
325
|
-
**Person already saved:** `payload={person_id: <uuid>, assessment: <object above>, ...}` — applies the score update-in-place
|
|
342
|
+
**Person already saved:** `payload={person_id: <uuid>, company_id: <resolved company UUID>, assessment: <object above>, ...}` — applies the score update-in-place and atomically binds/promotes the current employer. If no `company_id` was resolved, pass `company_name` plus at least one top-level anchor instead. Never send a bare `person_id` from this harness.
|
|
326
343
|
|
|
327
344
|
- `mode == save`: add `assessment_campaign_id: <campaign>` so the score is recorded against the campaign ICP, and omit `campaign_id` so no assignment or move occurs. New rows remain unassigned; duplicates keep their existing campaign assignment.
|
|
328
345
|
- `mode == save-and-enroll`: add `campaign_id: <campaign>`; the backend uses it for both assessment provenance and assignment. Omit `assessment_campaign_id` unless it is the same campaign.
|
|
@@ -331,7 +348,7 @@ This:
|
|
|
331
348
|
- Creates person + research + pipeline membership in ONE transaction (person shape) — a failed or rejected save persists nothing, so there is no orphan window
|
|
332
349
|
- Records the harness assessment as authoritative and skips the backend LLM scorer
|
|
333
350
|
- Dedupes on canonical anchors: if the person block's email/linkedin match someone already saved (any URL variant — www, trailing slash, encoding), the call continues as a duplicate update instead of creating
|
|
334
|
-
- Returns `person_id` (capture it for step d), `match_score` (0–100), `quality_gate_pass` (bool, true iff `match_score >= 70`), and `warnings[]` naming any failed best-effort side effects
|
|
351
|
+
- Returns `person_id` (capture it for step d), `company_id`, `company_bound`, `match_score` (0–100), `quality_gate_pass` (bool, true iff `match_score >= 70`), and `warnings[]` naming any failed best-effort side effects. Require `company_bound == true` before adding the person to Step 7d's enrollment list; a false value is `company_binding_failed`, must be surfaced, and must never be described as a successful save-and-enroll outcome.
|
|
335
352
|
|
|
336
353
|
**Distinguish two failure modes (Codex Finding #9):**
|
|
337
354
|
- **Request failure (5xx, timeout, network):** retry once with 2s backoff. If still failing, leave the prospect in `discovery_failed` status and surface in the final report. **Don't** claim "saved as gate-fail" — the row was never written.
|
|
@@ -339,7 +356,7 @@ This:
|
|
|
339
356
|
|
|
340
357
|
### d. Bulk enrollment (only after all prospects saved)
|
|
341
358
|
|
|
342
|
-
Collect all `person_id`s where `harness_gate_status == pass` AND backend `quality_gate_pass == true` AND `mode == save-and-enroll`. Then call `manage_outreach(action="start", id=[those person_ids], payload={campaign_id: ...})` ONCE at the end of Step 7.
|
|
359
|
+
Collect all `person_id`s where `harness_gate_status == pass` AND backend `quality_gate_pass == true` AND backend `company_bound == true` AND `mode == save-and-enroll`. Then call `manage_outreach(action="start", id=[those person_ids], payload={campaign_id: ...})` ONCE at the end of Step 7.
|
|
343
360
|
|
|
344
361
|
- Per-prospect outcomes are returned (enrolled | skipped | failed). Surface per-prospect failures in the report.
|
|
345
362
|
- If `harness_gate_status` is `warming` or `low_priority`, exclude from the bulk enroll list. Operator decides on review.
|
|
@@ -370,13 +387,15 @@ Harness pre-filter gate:
|
|
|
370
387
|
low_priority : {N}
|
|
371
388
|
gate_inconclusive : {N}
|
|
372
389
|
dismiss : {N} (top reasons: acv_too_low={N}, decision_maker_junior={N})
|
|
390
|
+
company_unresolved : {N} (not saved — list the people and missing anchors)
|
|
373
391
|
|
|
374
392
|
Backend-enforced gate using the authoritative harness score (match_score >= 70):
|
|
375
393
|
passed : {N}
|
|
376
394
|
failed : {N} (saved with research; operator can review via /enrich-prospect)
|
|
377
395
|
request_failed : {N} (retry candidates — surface in next run)
|
|
396
|
+
company_binding_failed : {N} (saved response was not company-bound; never enrolled)
|
|
378
397
|
|
|
379
|
-
Enrolled (
|
|
398
|
+
Enrolled (harness gate + backend score gate + company binding pass, in auto-enroll mode): {N}
|
|
380
399
|
Saved but not enrolled: {N}
|
|
381
400
|
|
|
382
401
|
Triggers detected (top 5):
|
|
@@ -398,7 +417,7 @@ For multi-campaign runs, group the report by campaign and include a totals summa
|
|
|
398
417
|
- **Source returns empty after dedup** — orchestrator says "All {N} candidates already in pipeline, nothing to research" and exits cleanly.
|
|
399
418
|
- **Mid-flight cancellation** (operator Ctrl+C before Step 7) — no new Phase A/B research has been persisted. Re-running `/pipeline-fill` reuses pre-existing fresh cache entries but repeats unfinished research waves. Note this honestly in the cancellation message.
|
|
400
419
|
- **Subagent timeout cascade** — Phase A failed for a company → Phase B runs degraded → harness marks `gate_inconclusive` → Step 7 score is capped below the backend threshold. See Step 4.
|
|
401
|
-
- **Categorical/numeric divergence** — a categorical `pass` can still score below 70 when evidence strength is weak. Enrollment requires
|
|
420
|
+
- **Categorical/numeric divergence** — a categorical `pass` can still score below 70 when evidence strength is weak. Enrollment requires all three confirmations: `harness_gate_status == pass`, backend `quality_gate_pass == true`, and backend `company_bound == true`; surface all three states.
|
|
402
421
|
- **Cached company research >90 days old** — Phase A re-runs the company subagent. Don't trust stale signals for an active fill.
|
|
403
422
|
- **Manual-list cap** — if >100 lines pasted, orchestrator asks "{N} prospects pasted — process all, or first M? (a/N)".
|
|
404
423
|
- **CSV >200 rows** — same prompt at Step 5 of csv-pipeline-fill.
|
|
@@ -140,8 +140,8 @@ Per source pick, dispatch:
|
|
|
140
140
|
The canonical candidate shape in `RESEARCH-ENGINE.md` is person-shaped — a company-only row is invalid by construction. This step is the single route from companies to people. **Every source that ends up holding companies runs it** (`account_list` always; `discovery` Path B after sourcing companies; a company-only CSV redirected from `/csv-pipeline-fill`). Sources that produce people directly skip it, and YC is a deliberate exemption: it is founder-first by design — the founder *is* the buyer, so `/yc-pipeline-fill` keeps its own founder extraction. Never improvise around this step by hand-picking a buyer out of research prose — see the anti-skew rule below.
|
|
141
141
|
|
|
142
142
|
**Contract:**
|
|
143
|
-
- **Input:** a list of companies, each with `company_name` and/or `domain` (at least one), plus the campaign's ICP target titles/seniority and a resolved `buyers_per_account` (see Inputs).
|
|
144
|
-
- **Output:** the canonical person-shaped candidate list defined in `RESEARCH-ENGINE.md`, ready for Step 3.
|
|
143
|
+
- **Input:** a list of companies, each with `company_name` and/or `domain` (at least one), plus any known `company_id`, `company_website`, or `company_linkedin_url`, the campaign's ICP target titles/seniority, and a resolved `buyers_per_account` (see Inputs).
|
|
144
|
+
- **Output:** the canonical person-shaped candidate list defined in `RESEARCH-ENGINE.md`, ready for Step 3. Copy every trustworthy company anchor onto every resolved person (`company_id`, `company_domain`, `company_website`, `company_linkedin_url`) and set `raw_signals.source_company` so the report can group by account. Do not reduce a strongly identified account back to a name-only company during committee resolution.
|
|
145
145
|
|
|
146
146
|
**Per company:**
|
|
147
147
|
1. Pull up to `buyers_per_account` people matching the campaign's ICP titles/seniority, using the first available provider in this order (same order as discovery sourcing; apply `source_policy` before any call):
|
|
@@ -200,7 +200,7 @@ Emit progress objects matching `contracts/run-progress.schema.json` after every
|
|
|
200
200
|
|
|
201
201
|
Defaults when `source_policy` is omitted: `selected_source: null` (inventory connected discovery tools), `source_mode: "preferred"`, `prohibited_sources: []`, `allowed_fallbacks: ["web"]`, `company_wave_size: 10`, `person_wave_size: 5`, and `transient_retry_attempts: 2`. Operator language such as "no Sales Nav" or "no CSV" is parsed into `prohibited_sources` before validation and overrides defaults.
|
|
202
202
|
|
|
203
|
-
Discovery-path candidates produced in either path use the canonical shape in `RESEARCH-ENGINE.md` and feed into Step 3 the same way.
|
|
203
|
+
Discovery-path candidates produced in either path use the canonical shape in `RESEARCH-ENGINE.md` and feed into Step 3 the same way. Preserve any company anchors returned by the selected provider. Name-only discoveries may proceed to Phase B, but the engine will not save them unless current-employer research supplies a strong anchor.
|
|
204
204
|
|
|
205
205
|
**Path detection:** if the first non-comment line looks like a URL or has commas (paste-shaped), use Path A. If it's prose without URLs/commas and >40 chars, use Path B. If ambiguous, ask: "paste, or describe the ICP and I discover?"
|
|
206
206
|
|
|
@@ -34,12 +34,17 @@ This is the cheap pre-filter — it removes obvious mismatches (titled-wrong, in
|
|
|
34
34
|
Take the APPROVED profiles from Step 2 and convert them to the canonical candidate-list shape. From each profile, capture:
|
|
35
35
|
- `full_name` (from profile name)
|
|
36
36
|
- `company` (from profile current company)
|
|
37
|
+
- `company_id` (when the preview already includes the canonical Vruum company UUID; otherwise null)
|
|
38
|
+
- `company_domain` / `company_website` (when present in preview company metadata)
|
|
39
|
+
- `company_linkedin_url` (from the current employer/company result, not the person's `/in/` URL)
|
|
37
40
|
- `linkedin_url` (canonicalize via the LinkedIn URL in the profile)
|
|
38
41
|
- `title` (current title — useful for downstream classification but Phase B re-fetches authoritatively)
|
|
39
42
|
- `email`: null (Phase B finds it)
|
|
40
43
|
- `person_id`: null (resolved in Step 7 of engine flow)
|
|
41
44
|
- `raw_signals`: `{source: "sales-nav-deep", search_id: ..., preview_metadata: {...}}`
|
|
42
45
|
|
|
46
|
+
Preserve all available employer anchors even though Phase B re-fetches the profile. A name-only `company` is research context, not sufficient save identity; if neither the preview nor Phase B yields a strong current-employer anchor, the engine reports `company_unresolved` and does not save or enroll that prospect.
|
|
47
|
+
|
|
43
48
|
### Step 4: Hand off to /pipeline-fill (canonical handoff prompt)
|
|
44
49
|
|
|
45
50
|
Emit the canonical handoff prompt (defined in `pipeline-fill/RESEARCH-ENGINE.md` — the canonical handoff section):
|
|
@@ -115,6 +115,10 @@ Convert each surviving company into a candidate matching the canonical shape (de
|
|
|
115
115
|
first_name: null, // engine resolves in Step 7
|
|
116
116
|
last_name: null,
|
|
117
117
|
company: <yc company name>,
|
|
118
|
+
company_id: null,
|
|
119
|
+
company_domain: <apex derived from YC website or null>,
|
|
120
|
+
company_website: <canonical YC website URL or null>,
|
|
121
|
+
company_linkedin_url: null, // Phase B may fill from founder's current role
|
|
118
122
|
linkedin_url: <founder linkedin_url, canonicalized>,
|
|
119
123
|
email: null, // Phase B finds it
|
|
120
124
|
person_id: null, // engine resolves in Step 7
|
|
@@ -132,7 +136,7 @@ Convert each surviving company into a candidate matching the canonical shape (de
|
|
|
132
136
|
}
|
|
133
137
|
```
|
|
134
138
|
|
|
135
|
-
The `yc_news` entries are pre-loaded triggers — Phase A's company subagent uses them in lieu of an extra WebSearch.
|
|
139
|
+
The `yc_news` entries are pre-loaded triggers — Phase A's company subagent uses them in lieu of an extra WebSearch. The YC `website` field is also a first-party company identity anchor: normalize and preserve it instead of handing the engine only the company name. If it is absent or malformed, Phase B must recover a current-employer anchor before the founder can be saved.
|
|
136
140
|
|
|
137
141
|
### Step 7: Pool exhaustion check
|
|
138
142
|
|