@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vruum",
3
- "version": "0.6.46",
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.46",
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.46",
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": "642c88b2d663edb9fc50184409cd7f569f412bc8e64bed2961c1802e85d19246"
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 (everyone except harness-gate dismisses)
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. 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>, 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.
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** — the `person` block must identify the company unambiguously, ONE of:
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 rejected with HTTP 422.**
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
- 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.
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
- **Old-backend fallback (rollout window only):** if `save_discovered` answers with a bare `person_id: field required` 422, the backend predates this flow fall back to the old two-step dance (save_person to create, then save_discovered with the returned person_id) until the promote lands.
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 (THE path to score an existing stub).
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 (both gates pass + auto-enroll mode): {N}
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 both `harness_gate_status == pass` and backend `quality_gate_pass == true`; surface both states.
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. Set `raw_signals.source_company` so the report can group by account.
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