@vruum/skills 0.6.39 → 0.6.41
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/README.md +1 -1
- package/package.json +2 -2
- package/skills/csv-pipeline-fill/SKILL.md +3 -0
- package/skills/outreach-triage/SKILL.md +1 -1
- package/skills/outreach-triage/references/subagent-instructions.md +1 -1
- package/skills/pipeline-fill/RESEARCH-ENGINE.md +47 -30
- package/skills/pipeline-fill/SKILL.md +58 -13
- package/skills/pipeline-fill/contracts/examples.json +35 -0
- package/skills/pipeline-fill/contracts/run-progress.schema.json +1 -0
- package/skills/pipeline-fill/contracts/source-policy.schema.json +7 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vruum",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.41",
|
|
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.41",
|
|
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/README.md
CHANGED
|
@@ -86,7 +86,7 @@ the public skill instead of leaving a broken link.
|
|
|
86
86
|
- `/expansion-fill` — Source expansion-ready customers for outreach. Finds closed-won customers >60 days old with no open follow-on deal and surfaces them for an expansion play. Use when: expand customer, find upsell opportunities, NRR play, expansion opportunities, customers ripe for expansion, who can we expand to.
|
|
87
87
|
- `/ingest-meetings` — Pull meeting transcripts from your connected Google Drive into Vruum. Attaches each transcript to the right person and deal as a meeting on their timeline, and turns its action items into tasks that surface in your daily briefing. You review every attach before anything is written; safe to re-run. Use when: ingest meetings, import meeting notes, pull transcripts, log my meetings, action items from meetings, Gemini notes, Read.ai transcripts, turn meetings into tasks.
|
|
88
88
|
- `/outreach-triage` — Review and approve your pending outreach drafts across LinkedIn and email. Use when: triage, review queue, morning review, check messages, approve outreach, what needs review.
|
|
89
|
-
- `/pipeline-fill` — Source-agnostic pipeline orchestrator. Picks a source per campaign (Sales Nav / YC / CSV / discovery), runs harness deep research, applies a pre-filter gate, then saves into the campaign via the backend authoritative match_score>=70 gate. Use when: fill pipeline, import prospects, daily imports, need more prospects, discover prospects from scratch, deep research before import.
|
|
89
|
+
- `/pipeline-fill` — Source-agnostic pipeline orchestrator. Picks a source per campaign (Sales Nav / YC / CSV / account list / discovery), runs harness deep research, applies a pre-filter gate, then saves into the campaign via the backend authoritative match_score>=70 gate. Use when: fill pipeline, import prospects, daily imports, need more prospects, discover prospects from scratch, find buyers at these companies, deep research before import.
|
|
90
90
|
- `/sales-nav-deep-fill` — Sales Nav harness source for /pipeline-fill. Pre-filters Sales Nav profiles via vruum-pipeline-filter, produces a candidate list, hands off to /pipeline-fill for deep research and import. Use when: sales nav with deep research, sales nav harness mode, in-chat sales nav.
|
|
91
91
|
- `/vruum-guide` — Guide to running Vruum from your own AI harness. First run: guided onboarding from empty account to first reviewed outreach draft. After: reads live account state, recommends the single next most valuable action, hands off to the right skill. Use when: get started, onboarding, how do I use vruum, what should I do next, where do I start.
|
|
92
92
|
- `/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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vruum/skills",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.41",
|
|
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": {
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"outreach",
|
|
43
43
|
"gtm"
|
|
44
44
|
],
|
|
45
|
-
"contentHash": "
|
|
45
|
+
"contentHash": "136aaeab3eea579f6774b30b180e8826a14365664c52c2778ec8f0f3af80fb6a"
|
|
46
46
|
}
|
|
@@ -47,6 +47,8 @@ If `name` is mapped, defer the first/last split to the engine doc's Step 7 (cano
|
|
|
47
47
|
|
|
48
48
|
### Step 4: Resolve ambiguous mappings
|
|
49
49
|
|
|
50
|
+
**Company-only CSV → route to the `account_list` source.** If `company` maps but no person-identity column does (no `name`/first+last, no `linkedin_url`, no `email`), this is an account list, not a contact list. Don't ask the operator to force a mapping — hand the company column to `/pipeline-fill`'s `account-list` source, whose committee-resolution step resolves `buyers_per_account` ICP-matching buyers per company. Say so in one line: "No contact columns — treating this as an account list ({N} companies); /pipeline-fill will resolve the buying committee."
|
|
51
|
+
|
|
50
52
|
If any required field can't be auto-mapped (`name`/(first+last) AND `company`, OR `linkedin_url`), show the operator the detected headers + sample row and ask which column maps to which field. Don't guess silently — silent guessing is the source of "why did my CSV import 50 prospects with the wrong company" bugs.
|
|
51
53
|
|
|
52
54
|
For multi-column ambiguity (e.g. two columns matching `email`), pick the leftmost match and note it in the operator output.
|
|
@@ -113,6 +115,7 @@ Continue automatically? (y/n)
|
|
|
113
115
|
- **Duplicate rows** — in-CSV dedup before handoff.
|
|
114
116
|
- **LinkedIn URLs with tracking params** — query string stripped during canonicalization.
|
|
115
117
|
- **Mixed name format** (some rows have full_name, others have first/last) — engine handles both via Step 7's identity-resolution flow.
|
|
118
|
+
- **Company-only CSV** (no contact columns) — routed to `/pipeline-fill`'s `account-list` source at Step 4; never imported as contacts.
|
|
116
119
|
- **Empty CSV** — orchestrator says "CSV has no data rows after dedup; nothing to research" and exits.
|
|
117
120
|
|
|
118
121
|
## Notes
|
|
@@ -64,7 +64,7 @@ Once the user says go (or picks a focus area), pull the lightweight message queu
|
|
|
64
64
|
|
|
65
65
|
**Frame by conversation state, not sequence number.** `first_content_touch=true` means this person has NEVER received a content-bearing message — their history is only connection requests (blank or noted). That item is an OPENER no matter its T-number (a T3 email that fell back from a never-accepted LinkedIn connect is still the first thing this person reads from the sender). `first_content_touch=false` means real prior contact exists — follow-up framing is legitimate. If the field is null/absent (rows drafted before this signal shipped, or an older backend), fall back to the sequence_number heuristic: seq 1 = opener, seq ≥2 = follow-up. The full block lives on the `get_outreach_review` item as `conversation_state` (`{v, first_content_touch, prior_context: none|note_only|inbound_only|content, connection_note_text, last_delivered_channel, designed_channel, channel_rewrite_reason, channel_fallback}`) — same absent-block fallback applies when `v` is unrecognized.
|
|
66
66
|
|
|
67
|
-
**Authoring mode (needs_draft items).** The backend no longer writes outreach prose — touches arrive as `needs_draft` items carrying the decision context (channel, touch number, signals) and no content. These are not rewrites; they are blank pages. For each needs_draft item the subagent AUTHORS the message: check the person's research freshness from the review item itself — `person_researched_at` / `company_researched_at` / `research_status` are on the payload, no extra fetch needed (older than ~14 days or missing → research first with WebSearch + the research reads, and persist what you learn via `research` action=save_person, plus action=save_company when you learned something about the company, so it compounds), then write the touch from scratch in the seller's voice against the same quality standards as any review, then check it — call `check_prose` with `{item_id: <message id>, item_type: "message", content: <draft>}` and treat the `failures[]` as an advisory checklist: fix what you agree with; severity `block` failures are hard stops that bounce at post time — today: channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener) — then submit it via `manage_messages` action=edit with the content and the returned `rules_version` as `client_rules_version` — that transitions the item to a normal draft — and approve only what the user's standing instructions allow. (The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — a `block`-severity draft bounces with `prose_gate_blocked` + `failures[].fix` and a `span` naming the exact offending text.) Inbound replies also arrive as needs_draft (category inbound_reply, with the conversation attached): author the reply with full thread context. If a prospect turns out to be a bad fit at authoring time, skip the item and say why — authoring is the second qualification gate, not an obligation to write.
|
|
67
|
+
**Authoring mode (needs_draft items).** The backend no longer writes outreach prose — touches arrive as `needs_draft` items carrying the decision context (channel, touch number, signals) and no content. These are not rewrites; they are blank pages. For each needs_draft item the subagent AUTHORS the message: check the person's research freshness from the review item itself — `person_researched_at` / `company_researched_at` / `research_status` are on the payload, no extra fetch needed (older than ~14 days or missing → research first with WebSearch + the research reads, and persist what you learn via `research` action=save_person with `payload.person_id` set to the review item's person UUID (update-only — the response's `updated_fields` confirms what landed; NEVER pass the UUID as the facade `id` argument), plus action=save_company when you learned something about the company, so it compounds), then write the touch from scratch in the seller's voice against the same quality standards as any review, then check it — call `check_prose` with `{item_id: <message id>, item_type: "message", content: <draft>}` and treat the `failures[]` as an advisory checklist: fix what you agree with; severity `block` failures are hard stops that bounce at post time — today: channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener) — then submit it via `manage_messages` action=edit with the content and the returned `rules_version` as `client_rules_version` — that transitions the item to a normal draft — and approve only what the user's standing instructions allow. (The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — a `block`-severity draft bounces with `prose_gate_blocked` + `failures[].fix` and a `span` naming the exact offending text.) Inbound replies also arrive as needs_draft (category inbound_reply, with the conversation attached): author the reply with full thread context. If a prospect turns out to be a bad fit at authoring time, skip the item and say why — authoring is the second qualification gate, not an obligation to write.
|
|
68
68
|
|
|
69
69
|
**Dispatch order within the authoring lane.** Pull the WARM lane directly — `search` type=messages with `warm_only=true, status=needs_draft, sort_by=expiring, fields=compact` returns exactly the LinkedIn follow-ups to accepted connections, nearest sweep deadline first, immune to page-1 truncation (warm rows are old by construction, so the default newest-first sort buries them under the cold backlog). Dispatch that lane first — they accepted the invite and the follow-up is the first real message they read. For the remaining lanes, compact rows carry `connection_status` if you need to spot stragglers, and `sort_by=expiring` works on any needs_draft pull.
|
|
70
70
|
|
|
@@ -4,7 +4,7 @@ You are reviewing and improving outreach messages before they go to a human oper
|
|
|
4
4
|
|
|
5
5
|
You do NOT approve or send messages. You review, edit if needed, and return a structured summary.
|
|
6
6
|
|
|
7
|
-
**needs_draft items are authoring jobs, not reviews.** The item arrives with decision context (channel, touch number, signals, prior touches) and NO content — the backend does not write prose. Author the message from scratch: verify research freshness first — the review item carries `person_researched_at`, `company_researched_at`, and `research_status`, so no extra fetch is needed (missing or >14 days stale → research with WebSearch and the research reads, persist findings via `research` action=save_person, and action=save_company for company-level findings), then write the touch in the seller's voice to the same standards below, check it with `check_prose` (`{item_id: <message id>, item_type: "message", content: <draft>}` — the `failures[]` are an advisory checklist: fix what you agree with; severity `block` failures are hard stops — channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener)), and submit it with `manage_messages` action=edit (content + the returned `rules_version` as `client_rules_version`) — that turns it into a normal draft for the orchestrator's approval flow. If the prospect is a bad fit on fresh evidence, recommend skip with a one-line `reason` instead of writing (reasons feed the prose_labels corpus).
|
|
7
|
+
**needs_draft items are authoring jobs, not reviews.** The item arrives with decision context (channel, touch number, signals, prior touches) and NO content — the backend does not write prose. Author the message from scratch: verify research freshness first — the review item carries `person_researched_at`, `company_researched_at`, and `research_status`, so no extra fetch is needed (missing or >14 days stale → research with WebSearch and the research reads, persist findings via `research` action=save_person with `payload.person_id` set to the review item's person UUID (update-only; the response's `updated_fields` confirms what landed — never pass the UUID as the facade `id` argument), and action=save_company for company-level findings), then write the touch in the seller's voice to the same standards below, check it with `check_prose` (`{item_id: <message id>, item_type: "message", content: <draft>}` — the `failures[]` are an advisory checklist: fix what you agree with; severity `block` failures are hard stops — channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener)), and submit it with `manage_messages` action=edit (content + the returned `rules_version` as `client_rules_version`) — that turns it into a normal draft for the orchestrator's approval flow. If the prospect is a bad fit on fresh evidence, recommend skip with a one-line `reason` instead of writing (reasons feed the prose_labels corpus).
|
|
8
8
|
|
|
9
9
|
**Frame by conversation state, not touch number.** The review item's `conversation_state` block (when present) is the truth about what this person has actually received: `first_content_touch=true` means their entire history is connection requests — the message is an OPENER whatever its T-number; never reference prior outreach ("my last note", "since I haven't heard back", "bumping this" bounce at the gate). `channel_fallback=true` means the step was designed for `designed_channel` — author for the item's actual `channel` and make the copy stand alone. `prior_context='note_only'` → don't repeat the `connection_note_text`; `prior_context='inbound_only'` → continue THEIR conversation (their message is referenceable; "my previous outreach" is not). The block is frozen at draft time — if the conversation_thread shows newer inbound or delivered content, trust the thread. Block absent or `v` unrecognized → fall back to the T-number heuristic. If prior contact happened outside Vruum, flag it to the user instead of resubmitting — the gate override is restricted and your resubmission will bounce again.
|
|
10
10
|
|
|
@@ -30,6 +30,7 @@ All harness source skills produce candidate lists matching this shape exactly. T
|
|
|
30
30
|
|
|
31
31
|
**Rules:**
|
|
32
32
|
- At minimum, each candidate needs **either** `linkedin_url` **or** (`name`-fields + `company`). Candidates with neither are skipped at Step 3.
|
|
33
|
+
- **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.
|
|
33
34
|
- `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.
|
|
34
35
|
- Field additions are additive only. Removing a field is a breaking change for source skills.
|
|
35
36
|
- `source_policy`, candidate examples, and progress events have executable schemas under `contracts/`. Validate handoffs against them before provider calls.
|
|
@@ -61,6 +62,9 @@ Stable operator-visible codes:
|
|
|
61
62
|
| `company_resolution_failed` | company resolver returned no canonical row | retry once, then inspect resolver logs and evidence |
|
|
62
63
|
| `company_research_save_failed` | company research persistence failed with unknown commit status | inspect stored rows before any replay |
|
|
63
64
|
| `person_not_visible` | supplied person is outside the caller's tenant | use a tenant-visible person or omit `person_id` |
|
|
65
|
+
| `person_not_found_for_update` | save_person anchors matched no one in your pipeline (update-only) | search and pass `payload.person_id`, or use save_discovered's `person` block for a new prospect |
|
|
66
|
+
| `identity_unverifiable` | person has neither a LinkedIn URL nor an email | include `person.linkedin_url` or `person.email` |
|
|
67
|
+
| `discovery_daily_cap` | rolling 24h discovery-save cap reached | resume tomorrow; do not retry this session |
|
|
64
68
|
| `person_not_found` | tenant membership points to a missing person | refresh the candidate list |
|
|
65
69
|
| `person_identity_conflict` | fallback identifier belongs to another person | remove the conflicting identifier and review the provider result |
|
|
66
70
|
| `person_research_save_failed` | person research persistence failed with unknown commit status | inspect stored rows before any replay |
|
|
@@ -140,9 +144,9 @@ Helps operators distinguish "still working" from "stuck."
|
|
|
140
144
|
|
|
141
145
|
**Concurrency cap: 5 parallel** (lowered from Phase A's 10 because Phase B subagents call `research` action=linkedin_fetch and the Unipile rate limiter throws over cap — see `backend/app/domains/channels/services/unipile/rate_limiter.py:36`. Lower concurrency keeps us under the per-account window.)
|
|
142
146
|
|
|
143
|
-
**Malformed LinkedIn fallback:** if the selected candidate already has a Vruum `person_id` and LinkedIn returns an invalid/malformed-profile result, preserve that `person_id` and retry the enrichment once through the first allowed structured provider in `source_policy` (Clay when selected/connected). Pass the same `person_id` to `research(action="save_person")
|
|
147
|
+
**Malformed LinkedIn fallback:** if the selected candidate already has a Vruum `person_id` and LinkedIn returns an invalid/malformed-profile result, preserve that `person_id` and retry the enrichment once through the first allowed structured provider in `source_policy` (Clay when selected/connected). Pass the same `person_id` in the PAYLOAD to `research(action="save_person", payload={person_id: ..., ...})` — update-only; never as the facade `id` argument. This is a provider fallback for one identity, not a new-person discovery. Never fall back on LinkedIn 429/rate-limit responses or timeouts; surface those for a later retry. If the fallback's email or LinkedIn URL belongs to another person, the backend returns `person_identity_conflict`; stop and surface it rather than dropping `person_id` and creating a duplicate.
|
|
144
148
|
|
|
145
|
-
Dispatch one `vruum-prospect-deep-researcher` per surviving candidate. Subagent file at `.claude/agents/vruum-prospect-deep-researcher.md`.
|
|
149
|
+
Dispatch one `vruum-prospect-deep-researcher` per surviving candidate. Subagent file at `.claude/agents/vruum-prospect-deep-researcher.md`. The fan-out is strictly 1:1 with the surviving candidate list — depth per account is decided upstream by `buyers_per_account` in the committee-resolution step, never by Phase B adding or trimming people per company.
|
|
146
150
|
|
|
147
151
|
Dispatch prompt template:
|
|
148
152
|
|
|
@@ -225,42 +229,29 @@ Per surviving prospect:
|
|
|
225
229
|
### a. Save company research (once per company)
|
|
226
230
|
If the prospect's company isn't already cached and Phase A produced fresh research, call `research(action="save_company", payload={name: <Phase A COMPANY>, website: <Phase A DOMAIN or canonical URL>, funding_data, growth_metrics, current_priorities: <newline-joined descriptions + source URLs>})`. The API field is `name`, not `company_name`; it accepts `website`, not `domain`; and `current_priorities` is one string, so serialize the Phase A object list instead of passing the list through. Skip if `CACHE_HIT: true` for that company.
|
|
227
231
|
|
|
228
|
-
### b. Identity
|
|
232
|
+
### b. Identity prep (names + company linkage for the atomic save)
|
|
229
233
|
|
|
230
|
-
|
|
234
|
+
**VRU-722 atomic flow:** `research` action=save_person is UPDATE-ONLY (refreshing
|
|
235
|
+
research on someone already saved). New prospects are created by ONE
|
|
236
|
+
`manage_person` action=save_discovered call carrying a `person` block — person,
|
|
237
|
+
research, and pipeline membership land in a single transaction, so a crashed or
|
|
238
|
+
rejected save persists nothing. There is no create-then-adopt dance anymore.
|
|
231
239
|
|
|
232
240
|
1. **Split full_name** if `first_name`/`last_name` aren't already set:
|
|
233
241
|
- Last-space heuristic: split on the last space. `Jane Smith` → first=`Jane`, last=`Smith`. `Jane van der Merwe` → first=`Jane`, last=`van der Merwe`.
|
|
234
242
|
- **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`.
|
|
235
243
|
|
|
236
|
-
2. **
|
|
244
|
+
2. **Prepare company linkage** — the `person` block must identify the company unambiguously, ONE of:
|
|
237
245
|
|
|
238
|
-
**Path A (preferred):
|
|
246
|
+
**Path A (preferred): `company_id`.** Run the save_company call (`research` action=save_company) first, capture the returned `company_id`.
|
|
239
247
|
|
|
240
|
-
**Path B
|
|
248
|
+
**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.**
|
|
241
249
|
|
|
242
|
-
|
|
250
|
+
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.
|
|
243
251
|
|
|
244
|
-
|
|
245
|
-
```
|
|
246
|
-
research(
|
|
247
|
-
action="save_person",
|
|
248
|
-
payload={
|
|
249
|
-
first_name=..., last_name=...,
|
|
250
|
-
email=..., linkedin_url=...,
|
|
251
|
-
# ONE of:
|
|
252
|
-
company_id=<from the save_company call>
|
|
253
|
-
# OR:
|
|
254
|
-
company_name=..., company_linkedin_url=..., # at least one anchor
|
|
255
|
-
# ...rest of research fields
|
|
256
|
-
}
|
|
257
|
-
)
|
|
258
|
-
```
|
|
252
|
+
**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.
|
|
259
253
|
|
|
260
|
-
|
|
261
|
-
- The response includes the `person_id`. Capture it for step c.
|
|
262
|
-
|
|
263
|
-
### c. Save discovered person (authoritative harness score, backend-enforced gate)
|
|
254
|
+
### c. Save discovered person — ONE atomic call (authoritative harness score)
|
|
264
255
|
|
|
265
256
|
Build the authoritative `assessment` from the campaign playbook plus Phase A/B evidence. Score mechanically so reruns agree:
|
|
266
257
|
|
|
@@ -301,15 +292,37 @@ The score is the sum (0–100); 70+ passes. Send this exact shape:
|
|
|
301
292
|
|
|
302
293
|
`match_summary` must be non-empty. Alignment items require `point` and `evidence`; concern items require `concern` and `evidence`. Confidence values are 0–1 and concern severity is exactly `blocker`, `warning`, or `minor`.
|
|
303
294
|
|
|
304
|
-
Then call `manage_person(action="save_discovered", payload
|
|
295
|
+
Then call `manage_person(action="save_discovered", ...)` with ONE of two payload shapes (pass exactly one of `person` / `person_id`):
|
|
296
|
+
|
|
297
|
+
**NEW prospect (the normal Step 7 case):**
|
|
298
|
+
```
|
|
299
|
+
manage_person(
|
|
300
|
+
action="save_discovered",
|
|
301
|
+
payload={
|
|
302
|
+
person={
|
|
303
|
+
first_name=..., last_name=...,
|
|
304
|
+
email=..., linkedin_url=...,
|
|
305
|
+
# company linkage from step b (ONE of):
|
|
306
|
+
company_id=<from save_company> # OR company_name + an anchor
|
|
307
|
+
# ...rest of research fields (headline, seniority_level,
|
|
308
|
+
# topics_of_interest, recent_posts, role_start_date, ...)
|
|
309
|
+
},
|
|
310
|
+
assessment=<object above>, # REQUIRED with person
|
|
311
|
+
campaign_id=... or assessment_campaign_id=... # a campaign ref is REQUIRED
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Person already saved:** `payload={person_id: <uuid>, assessment: <object above>, ...}` — applies the score update-in-place (THE path to score an existing stub).
|
|
305
317
|
|
|
306
318
|
- `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.
|
|
307
319
|
- `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.
|
|
308
320
|
|
|
309
321
|
This:
|
|
322
|
+
- Creates person + research + pipeline membership in ONE transaction (person shape) — a failed or rejected save persists nothing, so there is no orphan window
|
|
310
323
|
- Records the harness assessment as authoritative and skips the backend LLM scorer
|
|
311
|
-
-
|
|
312
|
-
-
|
|
324
|
+
- 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
|
|
325
|
+
- 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
|
|
313
326
|
|
|
314
327
|
**Distinguish two failure modes (Codex Finding #9):**
|
|
315
328
|
- **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.
|
|
@@ -338,6 +351,10 @@ Candidates flow:
|
|
|
338
351
|
phase A : {company subagents fired} ({cached_skip} skipped via cache)
|
|
339
352
|
phase B : {prospect subagents fired} ({linkedin_unavailable} dismissed)
|
|
340
353
|
|
|
354
|
+
For runs that went through committee resolution (account_list, discovery Path B), also report:
|
|
355
|
+
accounts unresolved : {N} (list the companies + providers tried — from the committee-resolution hand-off)
|
|
356
|
+
and group the per-prospect outcomes by `raw_signals.source_company` so the operator reads results per account.
|
|
357
|
+
|
|
341
358
|
Harness pre-filter gate:
|
|
342
359
|
pass : {N}
|
|
343
360
|
warming : {N}
|
|
@@ -2,25 +2,27 @@
|
|
|
2
2
|
name: pipeline-fill
|
|
3
3
|
description: >-
|
|
4
4
|
Source-agnostic pipeline orchestrator. Picks a source per campaign (Sales Nav
|
|
5
|
-
/ YC / CSV / discovery), runs harness deep research, applies a
|
|
6
|
-
gate, then saves into the campaign via the backend authoritative
|
|
5
|
+
/ YC / CSV / account list / discovery), runs harness deep research, applies a
|
|
6
|
+
pre-filter gate, then saves into the campaign via the backend authoritative
|
|
7
7
|
match_score>=70 gate. Use when: fill pipeline, import prospects, daily
|
|
8
|
-
imports, need more prospects, discover prospects from scratch,
|
|
9
|
-
before import.
|
|
8
|
+
imports, need more prospects, discover prospects from scratch, find buyers at
|
|
9
|
+
these companies, deep research before import.
|
|
10
10
|
---
|
|
11
11
|
# Pipeline Fill
|
|
12
12
|
|
|
13
|
-
You are a source-agnostic pipeline filler. You pick campaigns to fill, pick a source per campaign (Sales Nav / YC / CSV / discovery), and orchestrate harness deep research that gates against the campaign ICP before saving prospects into the backend pipeline.
|
|
13
|
+
You are a source-agnostic pipeline filler. You pick campaigns to fill, pick a source per campaign (Sales Nav / YC / CSV / account list / discovery), and orchestrate harness deep research that gates against the campaign ICP before saving prospects into the backend pipeline.
|
|
14
14
|
|
|
15
15
|
## Why this skill exists
|
|
16
16
|
|
|
17
|
-
Filling your pipeline by source-of-the-day is normal. Sales Nav drying up doesn't mean you're stuck — pick YC, paste a CSV, or run discovery (paste candidates OR describe an ICP and the harness sources them via WebSearch + Vruum MCP + LinkedIn search). This skill orchestrates deep research per prospect in your IDE (your compute), scores against campaign ICP, then lets the backend enforce the fixed `match_score >= 70` gate.
|
|
17
|
+
Filling your pipeline by source-of-the-day is normal. Sales Nav drying up doesn't mean you're stuck — pick YC, paste a CSV, hand over a list of target accounts (the harness resolves the buying committee per account), or run discovery (paste candidates OR describe an ICP and the harness sources them via WebSearch + Vruum MCP + LinkedIn search). This skill orchestrates deep research per prospect in your IDE (your compute), scores against campaign ICP, then lets the backend enforce the fixed `match_score >= 70` gate.
|
|
18
18
|
|
|
19
19
|
## Where the heavy logic lives
|
|
20
20
|
|
|
21
21
|
Steps 3–8 (pre-flight, Phase A research, Phase B research, harness gate, save chain, audit-log report) are defined in `RESEARCH-ENGINE.md` (in this same skill directory). This skill owns:
|
|
22
22
|
- Step 1: campaign picker (with ETA)
|
|
23
23
|
- Step 2: source picker (conditional PLATFORM block + always-visible HARNESS block)
|
|
24
|
+
- The committee-resolution shared step (companies → people; used by every company-producing source)
|
|
25
|
+
- The account-list handler (company names/domains in, candidate list out)
|
|
24
26
|
- The discovery-mode handler (paste candidates inline OR describe an ICP and source via harness tools)
|
|
25
27
|
- The multi-campaign grammar
|
|
26
28
|
|
|
@@ -53,6 +55,7 @@ The orchestrator's MCP precheck at the top of Step 3 (the `fetch` type=research_
|
|
|
53
55
|
- `prospect_list` (optional): pre-built candidate list matching the canonical shape in `RESEARCH-ENGINE.md`. If provided, skip the source-picker step and go straight to Step 3 (pre-flight). This is how source skills hand off.
|
|
54
56
|
- `source_policy` (optional): machine-readable provider policy matching `contracts/source-policy.schema.json`. It owns `selected_source`, `source_mode`, `prohibited_sources`, ordered `allowed_fallbacks`, bounded wave sizes, and transient retry attempts. Treat prohibited providers as unavailable: do not call status/list/search endpoints for them.
|
|
55
57
|
- `campaign(s)`: target campaign(s); multi-campaign supported.
|
|
58
|
+
- `buyers_per_account` (optional): how many buying-committee members to resolve per company when the source produces companies rather than people. Range 1–5. Per-source defaults: `discovery` → 2 (the surface is unqualified — optimize for reach, spread across more accounts), `account_list` → 3 (the account is already qualified — optimize for depth on the committee). Precedence: explicit operator value > a target stated in the campaign description (e.g. "~2 per account") > the per-source default. Sources that produce people directly (Sales Nav, YC, contact CSVs, discovery Path A) ignore this input.
|
|
56
59
|
- `mode`: `research-only` | `save` | `save-and-enroll` (default: `save-and-enroll`).
|
|
57
60
|
|
|
58
61
|
## Workflow — Step 1: Show pipeline status & pick campaigns
|
|
@@ -82,13 +85,23 @@ ETA estimates: ~2s for batch Step 3 dedup + ~30s/wave Phase A + ~60s/wave Phase
|
|
|
82
85
|
- Mark campaigns already at target with ✓ and don't number them
|
|
83
86
|
- Show per-campaign ETA so operator can budget time
|
|
84
87
|
|
|
85
|
-
**
|
|
88
|
+
**Buyers-per-account line (company-producing sources only).** When the source is already known to produce companies at Step 1 — the operator handed an account list, or named discovery with an ICP brief — append one line under the table so depth is an explicit decision, never a silent default:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
Buyers per account: 3 (account-list default; range 1–5 — reply "buyers N" to change)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If the source isn't known yet at Step 1 (the common "fill my pipeline" path), defer this line to source-resolution time — the account-list handler and discovery Path B each confirm it before resolving. Wherever it renders, resolve the shown number via the `buyers_per_account` precedence in Inputs (explicit value > campaign-description target > per-source default), and name which rule produced it (e.g. "2 — campaign description says '~2 per account'").
|
|
95
|
+
|
|
96
|
+
**Wait for the user's response.** Parse: "all", "1, 3", "skip 2", "just the CFO ones", "buyers 3", etc. Only proceed with the selected campaigns.
|
|
86
97
|
|
|
87
98
|
## Workflow — Step 2: Pick source per campaign (only if `prospect_list` not provided)
|
|
88
99
|
|
|
89
100
|
**Default to `discovery`.** Unless the operator named a source (in their prompt or a prior turn), don't lead with the picker — default to the `discovery` source (the describe-an-ICP path: source against the campaign's own ICP via WebSearch + Vruum MCP + LinkedIn search) and announce it in one line so it stays overridable, e.g.:
|
|
90
101
|
|
|
91
|
-
> Sourcing {campaign_name} via discovery (ICP-based, long-tail). Reply `sales-nav`, `yc`, `csv`, or `picker` to switch.
|
|
102
|
+
> Sourcing {campaign_name} via discovery (ICP-based, long-tail). Reply `sales-nav`, `yc`, `csv`, `account-list`, or `picker` to switch.
|
|
103
|
+
|
|
104
|
+
**Account-list auto-detect:** if the operator's input is a list of company names or domains with no person identities (no personal names, no `/in/` LinkedIn URLs, no emails — e.g. pasted company lines, or a spreadsheet whose only mappable column is `company`), that is the `account_list` source. Announce it ("Reading this as an account list — {N} companies; I'll resolve the buying committee per account") instead of defaulting to discovery or misreading the rows as contacts.
|
|
92
105
|
|
|
93
106
|
CSV and Sales Nav are fully supported when selected. `source_policy` is a per-run routing contract: an explicit "use CSV" or "use Sales Nav" selects that capability; an explicit "no CSV" or "no Sales Nav" prohibits it only for this run. Never persist a seller's personal source preference as a tenant-wide capability restriction.
|
|
94
107
|
|
|
@@ -96,7 +109,7 @@ Why discovery is the default: keyword/Sales-Nav sources keep returning the same
|
|
|
96
109
|
|
|
97
110
|
Per selected campaign, when the operator wants to choose the source explicitly, prompt:
|
|
98
111
|
|
|
99
|
-
In **public mode** (the package builder strips the PLATFORM block from this skill before publishing), the picker shows only HARNESS modes, renumbered 1–
|
|
112
|
+
In **public mode** (the package builder strips the PLATFORM block from this skill before publishing), the picker shows only HARNESS modes, renumbered 1–5:
|
|
100
113
|
|
|
101
114
|
```
|
|
102
115
|
Source for {campaign_name}?
|
|
@@ -104,10 +117,11 @@ Source for {campaign_name}?
|
|
|
104
117
|
1. sales-nav-deep — Sales Nav profiles + harness deep research
|
|
105
118
|
2. yc — scrape YC directory with filters you provide
|
|
106
119
|
3. csv — read a CSV file (path next), harness deep research
|
|
107
|
-
4.
|
|
120
|
+
4. account-list — paste company names/domains (or a company-only CSV); I resolve the buying committee per account
|
|
121
|
+
5. discovery — paste candidates inline OR describe an ICP and I'll source them via WebSearch + Vruum MCP + LinkedIn search
|
|
108
122
|
```
|
|
109
123
|
|
|
110
|
-
The conditional rendering happens at package-build time, not at skill-runtime — when the orchestrator runs in operator mode it sees the
|
|
124
|
+
The conditional rendering happens at package-build time, not at skill-runtime — when the orchestrator runs in operator mode it sees the 7-option block; when it runs in public mode (stripped package) it sees only the 5-option block. Source-skill dispatch logic below uses option labels (`sales-nav-platform`, `yc`, etc.), not numbers, so the renumbering is cosmetic.
|
|
111
125
|
|
|
112
126
|
Per source pick, dispatch:
|
|
113
127
|
|
|
@@ -115,11 +129,42 @@ Per source pick, dispatch:
|
|
|
115
129
|
- `csv-platform` → invoke `/csv-platform-fill` (calls `import_prospects` action=csv_start; backend handles everything; same — skip Steps 3-8).
|
|
116
130
|
- `sales-nav-deep` → invoke `/sales-nav-deep-fill` to produce a candidate list, then continue to Step 3 with it.
|
|
117
131
|
- `yc` → invoke `/yc-pipeline-fill` to produce a candidate list, then continue to Step 3 with it.
|
|
118
|
-
- `csv` → invoke `/csv-pipeline-fill` to produce a candidate list, then continue to Step 3 with it.
|
|
132
|
+
- `csv` → invoke `/csv-pipeline-fill` to produce a candidate list, then continue to Step 3 with it. (A company-only CSV — no contact columns — routes to `account-list` instead; `/csv-pipeline-fill` documents the same redirect.)
|
|
133
|
+
- `account-list` → use the account-list handler below (parse companies → committee resolution → candidate list), then continue to Step 3 with it.
|
|
119
134
|
- `discovery` → use the discovery-mode handler below to produce a candidate list (handler branches: paste-shaped input → parse, prose ICP brief → harness sources via WebSearch + Vruum MCP + LinkedIn search), then continue to Step 3 with it.
|
|
120
135
|
|
|
121
136
|
**Multi-campaign behavior:** campaigns run sequentially. Campaign 1's Step 7 (save chain + bulk enroll) completes before campaign 2's Step 3 starts. Predictable rate-limit behavior, simple progress narrative. Trade-off: 3-campaign fills are ~37min wall-clock vs ~22min if Phase A/B were overlapped across campaigns. Cross-campaign overlap is a v2.
|
|
122
137
|
|
|
138
|
+
## Committee resolution (shared step: companies → people)
|
|
139
|
+
|
|
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
|
+
|
|
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.
|
|
145
|
+
|
|
146
|
+
**Per company:**
|
|
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):
|
|
148
|
+
- **Structured B2B provider** — e.g. Clay `find-and-enrich-contacts-at-company` with the title/seniority filter.
|
|
149
|
+
- **LinkedIn / Sales Nav** people-at-known-company via `import_prospects action=sales_nav_search` — fine here because the company is fixed; the marquee-name skew applies to company discovery, not to enumerating a known committee. Mind LinkedIn quota.
|
|
150
|
+
- **Email finder** — Hunter via `search type=companies {domain, seniority}`.
|
|
151
|
+
- **Web** — `WebSearch` for "{company} {title}" + team/about pages; the universal fallback.
|
|
152
|
+
2. **Select by title fit, not visibility.** When the provider returns more than `buyers_per_account` matches, rank by ICP title/seniority fit — never by follower count, press coverage, or how often the name appears in research prose. The buyer with no public profile is often the one actually running the function; picking the famous name per account is the same marquee-name skew this skill warns about for Sales Nav.
|
|
153
|
+
3. **Companies that resolve to zero people** are reported by this step at hand-off ("no ICP-matching contacts found at {company} via {providers tried}") and counted in the engine's Step 8 report (`accounts unresolved`), never silently dropped. They cannot enter the engine (the candidate shape is person-shaped), so the hand-off summary is where the gap surfaces.
|
|
154
|
+
|
|
155
|
+
Provider calls run in the standard bounded waves (company-level actions ≤10, person/LinkedIn actions ≤5) with progress objects per `contracts/run-progress.schema.json`, using `phase: "committee_resolution"`.
|
|
156
|
+
|
|
157
|
+
## Account-list handler (for `account-list` source)
|
|
158
|
+
|
|
159
|
+
Input is company names or domains — pasted lines, or a company-only CSV/xlsx redirected from `/csv-pipeline-fill`. This is a thin wrapper around the shared committee-resolution step:
|
|
160
|
+
|
|
161
|
+
1. **Parse companies.** One company per line (or per row). A line that looks like a domain (`acme.com`) sets `domain`; otherwise it's `company_name`. Drop blanks and `#` comments; dedupe case-insensitively. Above 100 accounts, confirm: "{N} accounts — process all, or first M? (a/N)".
|
|
162
|
+
2. **Anchor on campaign ICP.** Read the campaign's ICP (via `fetch` type=campaign and `fetch` type=settings subtype=profile) to get target titles/seniority. Show a one-line synthesis and the resolved `buyers_per_account` — full precedence per Inputs: an explicit operator value wins, else a campaign-description target like "~2 per account", else this source's default of 3 (the accounts are already qualified, go deeper) — and confirm before resolving.
|
|
163
|
+
3. **Run committee resolution** (shared step above) across the account list.
|
|
164
|
+
4. **Show the resolved list** grouped by account — `Company → Name (title) [source]` — and get a "go" / "drop X" before continuing to Step 3.
|
|
165
|
+
|
|
166
|
+
The territory/fit gate on the *companies* themselves still happens in Phase A + the harness gate — the handler doesn't pre-judge accounts, it only turns them into people.
|
|
167
|
+
|
|
123
168
|
## Discovery-mode handler (for `discovery` source)
|
|
124
169
|
|
|
125
170
|
Discovery mode covers two paths off the same prompt:
|
|
@@ -147,7 +192,7 @@ Operator gives a brief like "Series A-C SaaS founders, US, 50-500 ppl" or "direc
|
|
|
147
192
|
|
|
148
193
|
Apply `source_policy` before inventorying or calling providers. Validate the entire object against `contracts/source-policy.schema.json` before the first provider call. If `selected_source` is disconnected, stop with code `source_unavailable`; exclusive mode never substitutes, while preferred mode may use only the first connected entry in `allowed_fallbacks`. Announce the resolved policy in one line ("Sourcing via Clay — firmographic pull + committee enrichment; web as allowed backup; Sales Nav prohibited") so the operator can redirect.
|
|
149
194
|
3. **Source companies first, by firmographics — aim past the obvious names** — use the chosen tool to pull companies matching the merged ICP by stage / headcount / vertical / geo, NOT by marquee-name lookup (the saturated set IS the famous names). With a data provider, run the firmographic query directly; with web only, work funding announcements + directories.
|
|
150
|
-
4. **Resolve the buying committee per company** —
|
|
195
|
+
4. **Resolve the buying committee per company** — run the shared **Committee resolution** step above on the sourced companies, with `buyers_per_account` resolved per Inputs (discovery default 2 — the surface is unqualified, so spread it across more accounts rather than going deep on any one).
|
|
151
196
|
5. **Dedup against existing pipeline** — for each discovered person, check `search` type=people with a name/company keyword query so you don't research someone the campaign already has. This is where saturated names drop out, cheaply, before any research spend.
|
|
152
197
|
6. **Show the discovered list to the operator** before handoff. Format: `Name (title) — Company [source] [linkedin]`. Cap the surface at 2x daily_target so we don't over-source. Get a "go" / "drop X" before continuing.
|
|
153
198
|
|
|
@@ -32,6 +32,24 @@
|
|
|
32
32
|
"person_wave_size": 5,
|
|
33
33
|
"transient_retry_attempts": 2,
|
|
34
34
|
"reason": "Use only Clay; stop if it is unavailable."
|
|
35
|
+
},
|
|
36
|
+
"account_list_committee_depth": {
|
|
37
|
+
"version": "1",
|
|
38
|
+
"selected_source": "clay",
|
|
39
|
+
"source_mode": "preferred",
|
|
40
|
+
"prohibited_sources": [
|
|
41
|
+
"sales_nav",
|
|
42
|
+
"linkedin"
|
|
43
|
+
],
|
|
44
|
+
"allowed_fallbacks": [
|
|
45
|
+
"hunter",
|
|
46
|
+
"web"
|
|
47
|
+
],
|
|
48
|
+
"company_wave_size": 10,
|
|
49
|
+
"person_wave_size": 5,
|
|
50
|
+
"transient_retry_attempts": 2,
|
|
51
|
+
"buyers_per_account": 3,
|
|
52
|
+
"reason": "Operator-supplied account list; resolve a 3-person buying committee per account without touching LinkedIn quota."
|
|
35
53
|
}
|
|
36
54
|
},
|
|
37
55
|
"progress": {
|
|
@@ -52,6 +70,23 @@
|
|
|
52
70
|
"elapsed_seconds": 34,
|
|
53
71
|
"eta_seconds": 0
|
|
54
72
|
},
|
|
73
|
+
"committee_wave": {
|
|
74
|
+
"version": "1",
|
|
75
|
+
"state": "running",
|
|
76
|
+
"phase": "committee_resolution",
|
|
77
|
+
"provider": "clay",
|
|
78
|
+
"wave": 1,
|
|
79
|
+
"wave_count": 3,
|
|
80
|
+
"completed": 5,
|
|
81
|
+
"total": 15,
|
|
82
|
+
"failed": 0,
|
|
83
|
+
"not_attempted": 10,
|
|
84
|
+
"safe_retry_items": [],
|
|
85
|
+
"ambiguous_items": [],
|
|
86
|
+
"code": null,
|
|
87
|
+
"elapsed_seconds": 21,
|
|
88
|
+
"eta_seconds": 42
|
|
89
|
+
},
|
|
55
90
|
"person_wave": {
|
|
56
91
|
"version": "1",
|
|
57
92
|
"state": "partial",
|
|
@@ -72,6 +72,12 @@
|
|
|
72
72
|
"maximum": 2,
|
|
73
73
|
"default": 2
|
|
74
74
|
},
|
|
75
|
+
"buyers_per_account": {
|
|
76
|
+
"type": "integer",
|
|
77
|
+
"minimum": 1,
|
|
78
|
+
"maximum": 5,
|
|
79
|
+
"description": "Buying-committee members to resolve per company in the shared committee-resolution step. No schema-level default on purpose: the default is per-source (discovery 2, account_list 3) and resolved by the orchestrator; a campaign-description target overrides the per-source default, an explicit operator value overrides both."
|
|
80
|
+
},
|
|
75
81
|
"reason": {
|
|
76
82
|
"type": "string",
|
|
77
83
|
"minLength": 1
|
|
@@ -79,6 +85,7 @@
|
|
|
79
85
|
},
|
|
80
86
|
"$defs": {
|
|
81
87
|
"provider": {
|
|
88
|
+
"$comment": "account_list is deliberately absent: it is operator-supplied input, not a callable provider. Committee resolution for an account list runs through the providers listed here under this same policy.",
|
|
82
89
|
"enum": [
|
|
83
90
|
"clay",
|
|
84
91
|
"sales_nav",
|