@vruum/skills 0.6.35 → 0.6.37

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.35",
3
+ "version": "0.6.37",
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.35",
3
+ "version": "0.6.37",
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
@@ -63,6 +63,14 @@ Download and upload via **Settings → Customize → Plugins → "+"**.
63
63
  npx @vruum/skills install --target /path/to/skills/dir
64
64
  ```
65
65
 
66
+ ## Coexisting with operator skills
67
+
68
+ The public and operator skill bundles can coexist in the same harness. For
69
+ overlapping skill names, a valid operator-owned installation takes precedence.
70
+ Public installs and uninstalls preserve the operator bundle's links and update
71
+ checker. If an operator link becomes stale, the next public install restores
72
+ the public skill instead of leaving a broken link.
73
+
66
74
  ## Skills
67
75
 
68
76
  <!-- generated:skills-begin -->
package/install.js CHANGED
@@ -37,6 +37,7 @@ const VRUUM_ROOT = path.join(os.homedir(), '.vruum');
37
37
  const VRUUM_SKILLS = path.join(VRUUM_ROOT, 'skills');
38
38
  const VRUUM_AGENTS = path.join(VRUUM_ROOT, 'agents');
39
39
  const VRUUM_BIN = path.join(VRUUM_ROOT, 'bin');
40
+ const VRUUM_OPERATOR_ROOT = path.join(VRUUM_ROOT, 'skills-operator');
40
41
  const VRUUM_VERSION_FILE = path.join(VRUUM_ROOT, 'VERSION');
41
42
  const PKG_BIN = path.join(PACKAGE_ROOT, 'bin');
42
43
 
@@ -154,7 +155,7 @@ function syncVruumRoot({ dryRun }) {
154
155
  `would sync ${PKG_SKILLS} -> ${VRUUM_SKILLS} (with auto-update prelude)`,
155
156
  ];
156
157
  if (fs.existsSync(PKG_AGENTS)) rows.push(`would sync ${PKG_AGENTS} -> ${VRUUM_AGENTS}`);
157
- if (fs.existsSync(PKG_BIN)) rows.push(`would sync ${PKG_BIN} -> ${VRUUM_BIN}`);
158
+ if (fs.existsSync(PKG_BIN)) rows.push(`would merge ${PKG_BIN} -> ${VRUUM_BIN}`);
158
159
  rows.push(`would write ${VRUUM_VERSION_FILE} = ${VERSION}`);
159
160
  return rows;
160
161
  }
@@ -174,14 +175,18 @@ function syncVruumRoot({ dryRun }) {
174
175
  rows.push(`synced ${VRUUM_AGENTS}`);
175
176
  }
176
177
 
177
- // Bin scripts update-check needs to land at a stable path and be exec.
178
+ // Bin scripts share ~/.vruum/bin with the operator installer. Copy only this
179
+ // package's files; replacing the directory would delete the operator update
180
+ // checker and make that bundle permanently stale.
178
181
  if (fs.existsSync(PKG_BIN)) {
179
- fs.rmSync(VRUUM_BIN, { recursive: true, force: true });
180
- fs.cpSync(PKG_BIN, VRUUM_BIN, { recursive: true });
181
- for (const entry of fs.readdirSync(VRUUM_BIN)) {
182
- fs.chmodSync(path.join(VRUUM_BIN, entry), 0o755);
182
+ fs.mkdirSync(VRUUM_BIN, { recursive: true });
183
+ for (const entry of fs.readdirSync(PKG_BIN)) {
184
+ const src = path.join(PKG_BIN, entry);
185
+ const dst = path.join(VRUUM_BIN, entry);
186
+ fs.cpSync(src, dst, { recursive: true, force: true });
187
+ fs.chmodSync(dst, 0o755);
183
188
  }
184
- rows.push(`synced ${VRUUM_BIN}`);
189
+ rows.push(`merged ${PKG_BIN} -> ${VRUUM_BIN}`);
185
190
  }
186
191
 
187
192
  // VERSION file — read by vruum-skills-update-check as the local version.
@@ -205,6 +210,25 @@ function isOurLink(dst, linkTarget) {
205
210
  return resolved === VRUUM_SKILLS || resolved.startsWith(VRUUM_SKILLS + path.sep);
206
211
  }
207
212
 
213
+ // Operator skills are a strict superset with multi-company scoping. When both
214
+ // installers are present, operator links win for overlapping names regardless
215
+ // of install order. Public-only names still link normally.
216
+ function isOperatorLink(dst, linkTarget) {
217
+ const resolved = path.isAbsolute(linkTarget)
218
+ ? path.resolve(linkTarget)
219
+ : path.resolve(path.dirname(dst), linkTarget);
220
+ return resolved === VRUUM_OPERATOR_ROOT
221
+ || resolved.startsWith(VRUUM_OPERATOR_ROOT + path.sep);
222
+ }
223
+
224
+ function hasSkillFile(dir) {
225
+ try {
226
+ return fs.statSync(path.join(dir, 'SKILL.md')).isFile();
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
208
232
  // Prune pass — after relinking current skills, remove links for skills no
209
233
  // longer in the package (renamed or deleted), but ONLY symlinks this installer
210
234
  // owns (target under ~/.vruum/skills/). Non-symlinks (real user dirs/files) and
@@ -276,6 +300,22 @@ function linkSkill({ name, srcAbs, target, dryRun }) {
276
300
  if (existing?.kind === 'symlink' && existing.target === srcAbs) {
277
301
  return { name, target, action: 'already-linked' };
278
302
  }
303
+ const resolvedExistingTarget = existing?.kind === 'symlink'
304
+ ? (path.isAbsolute(existing.target)
305
+ ? path.resolve(existing.target)
306
+ : path.resolve(path.dirname(dst), existing.target))
307
+ : null;
308
+ const operatorSkillIsUsable = resolvedExistingTarget
309
+ && isOperatorLink(dst, existing.target)
310
+ && hasSkillFile(resolvedExistingTarget);
311
+ if (operatorSkillIsUsable) {
312
+ return {
313
+ name,
314
+ target,
315
+ action: 'operator-kept',
316
+ reason: 'operator bundle takes precedence for overlapping skills',
317
+ };
318
+ }
279
319
  if (existing && existing.kind !== 'symlink') {
280
320
  return {
281
321
  name,
@@ -441,7 +481,7 @@ function commandUninstall({ targets: extraTargets, dryRun }) {
441
481
  // Only clean up our own subdirectories — ~/.vruum/ is a shared state dir
442
482
  // (e.g. the .agents/ vruum-update-check keeps config.yaml + snooze state
443
483
  // there, and both installers share that config).
444
- for (const dir of [VRUUM_SKILLS, VRUUM_AGENTS, VRUUM_BIN]) {
484
+ for (const dir of [VRUUM_SKILLS, VRUUM_AGENTS]) {
445
485
  if (!fs.existsSync(dir)) continue;
446
486
  if (dryRun) {
447
487
  console.log(`${prefix}would remove ${dir}`);
@@ -450,6 +490,23 @@ function commandUninstall({ targets: extraTargets, dryRun }) {
450
490
  console.log(`${prefix}removed ${dir}`);
451
491
  }
452
492
  }
493
+ // Remove only binaries shipped by the public package. The operator update
494
+ // checker in the same directory belongs to the operator installer.
495
+ if (fs.existsSync(PKG_BIN) && fs.existsSync(VRUUM_BIN)) {
496
+ for (const entry of fs.readdirSync(PKG_BIN)) {
497
+ const dst = path.join(VRUUM_BIN, entry);
498
+ if (!fs.existsSync(dst)) continue;
499
+ if (dryRun) {
500
+ console.log(`${prefix}would remove ${dst}`);
501
+ } else {
502
+ fs.rmSync(dst, { recursive: true, force: true });
503
+ console.log(`${prefix}removed ${dst}`);
504
+ }
505
+ }
506
+ if (!dryRun && fs.readdirSync(VRUUM_BIN).length === 0) {
507
+ fs.rmdirSync(VRUUM_BIN);
508
+ }
509
+ }
453
510
  if (fs.existsSync(VRUUM_VERSION_FILE)) {
454
511
  if (dryRun) {
455
512
  console.log(`${prefix}would remove ${VRUUM_VERSION_FILE}`);
@@ -543,6 +600,7 @@ module.exports = {
543
600
  listAvailableSkills,
544
601
  linkSkill,
545
602
  isOurLink,
603
+ isOperatorLink,
546
604
  pruneStaleLinks,
547
605
  pruneLegacyCodexLinks,
548
606
  commandInstall,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vruum/skills",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
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": "e6f36061d77555954c948125566850ef21af1debb21e0b65371876caa29768d4"
45
+ "contentHash": "9562cd3662884c9fb44cb61950299f9569a13ce38dc64101dc196b36e26d1c43"
46
46
  }
@@ -32,6 +32,19 @@ Call `search` with `type="people"` and the criteria (include `filters={research_
32
32
 
33
33
  Iterate with the seller until the cohort is right ("too broad — only the US ones" → add the region attribute). This is the step to get right; everything after is mechanical.
34
34
 
35
+ ### Named-account sourcing handoff
36
+
37
+ If the criteria names organizations/accounts and the preview has fewer people than needed, do not create an empty campaign or pretend the accounts are contacts. Hand off the missing-account cohort to `/pipeline-fill` discovery before Step 3:
38
+
39
+ - Build `source_policy` using `pipeline-fill/contracts/source-policy.schema.json` from the installed skills bundle.
40
+ - Preserve every explicit provider instruction. Example: "use Clay, no Sales Nav or CSV" becomes `selected_source: "clay"`, `source_mode: "preferred"`, `prohibited_sources: ["sales_nav", "linkedin", "csv"]`, and an ordered `allowed_fallbacks` list.
41
+ - CSV and Sales Nav remain fully supported sources when the seller explicitly selects them. Source prohibitions are scoped to this run only; never turn one seller's preference into a global capability restriction or silently substitute a prohibited source.
42
+ - Use the schema defaults unless the seller overrides them: company waves 10, person waves 5, and two retries for transient failures. `source_mode: "exclusive"` requires `allowed_fallbacks: []`.
43
+ - Pass the named organizations and campaign criteria as the discovery ICP brief.
44
+ - Set pipeline-fill `mode: "save"` explicitly. This persists approved people so their IDs can return here, but cannot enroll them or start outreach.
45
+ - Let `/pipeline-fill` source companies first, resolve up to five matching people per company in bounded waves, preview the people, and return their IDs.
46
+ - Resume here only with the approved person IDs. This handoff is sourcing only; it never launches outreach.
47
+
35
48
  ## Step 3: Create the campaign
36
49
 
37
50
  Two paths — ask which:
@@ -41,23 +54,39 @@ Two paths — ask which:
41
54
 
42
55
  ## Step 4: Assign the cohort
43
56
 
44
- Collect the person ids from the Step 2 preview (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: "add", person_ids: [...]}.
57
+ 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: [...]}.
45
58
 
46
- For large cohorts, add in batches of a few hundred and report progress.
59
+ For large cohorts, assign in bounded batches and report requested vs updated counts. If the response contains `requires_confirmation: true`, emit `state: "paused"` with code `research_confirmation_required`, stop, and show the preview to the seller. Never set `confirm: true` without their explicit confirmation. A source-campaign 403 is a visible failed item; a response that updates fewer people than requested is `state: "partial"` with code `research_partial`.
47
60
 
48
61
  ## Step 5: Review and launch — CONFIRMATION REQUIRED
49
62
 
50
63
  Show the seller a launch summary before anything sends:
51
64
  - Campaign name, source of messaging (cloned from X / fresh)
52
65
  - Cohort size and criteria
53
- - Channels and cadence (from the campaign config)
66
+ - Channels, cadence, and maximum touches (from the campaign config). Enrollment schedules the first action immediately; spacing between later touches must already be represented in the campaign cadence.
54
67
 
55
- Then ask explicitly: "Launch outreach to these N people?" Only after a clear yes, call `manage_outreach` action=start id=[person uuids] (native bulk; payload optional {max_touches, allowed_channels, start_immediately}).
68
+ Then ask explicitly: "Launch outreach to these N people?" Only after a clear yes, call `manage_outreach` action=start id=[person uuids] (native bulk; payload optional `{max_touches, allowed_channels}`). Do not pass `start_immediately`; the MCP intentionally ignores it.
56
69
 
57
70
  If the seller wants a dry run, stop after Step 4 — the campaign exists with members and nothing sends until plans start.
58
71
 
72
+ ### Existing Gmail scheduling
73
+
74
+ If Gmail already contains scheduled or sent campaign emails, reconcile them before approving, drafting, or starting replacement email touches:
75
+
76
+ 1. Call `fetch` with `type=settings subtype=channel_status`. Select the intended sender mailbox from `channels.email.accounts[]` and use its public `id` as `account_id`; never invent or ask the seller for an internal provider id.
77
+ 2. Call `manage_messages` action=`reconcile_external_email`, id=<campaign uuid>, payload=`{account_id, action: "preview", after?, before?}`.
78
+ 3. Show exact matched, ambiguous, and unmatched counts. A preview is read-only.
79
+ 4. When the seller asked to synchronize—or explicitly approves the preview—apply that exact snapshot with payload=`{account_id, action: "apply", preview_id}`. Applying creates/finalizes Vruum reservations; it never sends or resends email.
80
+ 5. Pull payload=`{account_id, action: "exceptions"}` for the exception-first rescue queue. Never guess a recipient or silently release a reservation.
81
+
82
+ Provider-scheduled rows are protected from duplicate dispatch and excluded from actionable review. Surface `externally_scheduled_count` when verifying the campaign.
83
+
84
+ ### Repairing already-created plans
85
+
86
+ If the seller changes maximum touches or allowed channels after plans exist, update the cohort through `manage_outreach` action=`update`, id=[plan uuids], payload=`{max_touches?, allowed_channels?}`. This preserves each plan's active/paused state. Report per-plan success, error, and not-attempted counts; never patch outreach-plan rows directly in the database.
87
+
59
88
  ## Notes
60
89
 
61
90
  - Junk-safe personalization: contacts with garbage first names (single letters, org names) automatically get the no-name greeting variant — you don't need to filter them out of the cohort for that reason.
62
- - A person can be in many lists but holds ONE campaign assignment; adding to a campaign moves them. Say so if the cohort overlaps an active campaign — surface counts before Step 4.
91
+ - A person can be in many lists but holds ONE campaign assignment; assigning to a campaign moves them. Say so if the cohort overlaps an active campaign — surface counts before Step 4.
63
92
  - Never call `manage_outreach` action=start without the Step 5 confirmation, and never auto-approve drafts; the outreach queue review (`/outreach-triage`) stays the quality gate.
@@ -28,9 +28,16 @@ For small queues (5 or fewer) or when subagents can't access MCP, review directl
28
28
 
29
29
  ### Step 1: Get the lay of the land
30
30
 
31
- Call `fetch` with type=stats and subtype=outreach to see the pending queue shape. The response carries `needs_draft_count` (unauthored touches awaiting authoring) alongside `draft_count` (authored, awaiting approval) surface both so the authoring backlog is visible up front. Present a quick summary:
31
+ Call `fetch` with type=stats and subtype=outreach to see the pending queue shape, then call `get_outreach_review` with `content_length=preview, limit=1` for authoritative review-state counts. Surface:
32
32
 
33
- "You have N to author (needs_draft, W of them WARM accepted-connection follow-ups), X reply responses, Y pending T1s, Z T2+ follow-ups. [Any critical alerts.] Want me to run full triage or focus on a specific category?"
33
+ - `needs_draft_count`: unauthored touches awaiting authoring
34
+ - `draft_count`: authored touches awaiting approval
35
+ - `approved_pending_send_count`: approved Vruum sends waiting on cadence/cooldown
36
+ - `externally_scheduled_count`: Gmail/provider-scheduled rows protected by an external-send reservation
37
+
38
+ Externally scheduled rows are intentionally excluded from actionable items. Never re-draft, approve, or manually resend them. Present a quick summary:
39
+
40
+ "You have N to author (W WARM accepted-connection follow-ups), D drafts to review, A approved sends waiting on cadence, and E externally scheduled in Gmail. [Any critical alerts.] Want me to run full triage or focus on a specific category?"
34
41
 
35
42
  **Warm follow-ups outrank everything except replies.** The stats payload carries `needs_draft_warm_count`: unauthored `linkedin_message` touches to people who ACCEPTED the connection request. These are the highest-EV rows in the queue — a person who said yes to the invite is waiting on a first real message. When non-zero, lead the summary with it and default the triage order to: inbound replies → warm follow-ups → everything else.
36
43
 
@@ -38,6 +45,19 @@ Keep it short. The user knows their queue — they just need the numbers to deci
38
45
 
39
46
  **needs_draft items EXPIRE.** A nightly backend sweep (03:20 UTC) rejects any `needs_draft` row older than 14 days from creation — intended garbage collection, not an operator action. An expired touch is not lost forever (the plan reschedules and mints a fresh row about a week later), but the authoring work is deferred a cycle and the queue silently shrinks. The tools tell you: the stats payload carries `needs_draft_expiring_soon_count` (rows within 3 days of the sweep — surface it in the summary when non-zero), and each needs_draft item carries `expires_at` (its sweep deadline). Consequence for triage: author oldest-first, and if the queue is too big to clear in one session, clear the items closest to `expires_at` rather than sampling the freshest.
40
47
 
48
+ ### Gmail/Vruum reconciliation lane
49
+
50
+ When the seller asks to audit or synchronize Gmail scheduling, do this before normal triage:
51
+
52
+ 1. Call `fetch` with `type=settings subtype=channel_status`. Select the intended sender mailbox from `channels.email.accounts[]` and use its public `id` as `account_id`; never invent or ask the seller for an internal provider id.
53
+ 2. Call `manage_messages` action=`reconcile_external_email`, id=<campaign uuid>, payload=`{account_id, action: "preview", after?, before?}`.
54
+ 3. Present matched, ambiguous, and unmatched counts. Preview is provider-read-only and mutation-free.
55
+ 4. If synchronization was requested or the seller approves the preview, call the same action with payload=`{account_id, action: "apply", preview_id}`. Apply only the exact preview; it creates/finalizes reservations without sending or resending anything.
56
+ 5. Call payload=`{account_id, action: "exceptions"}` and lead with blocked, ambiguous, unmatched, failed, or unverified contacts.
57
+ 6. Re-read `get_outreach_review` and verify `externally_scheduled_count` plus the actionable queue. A Gmail-scheduled row must never remain actionable.
58
+
59
+ Manual Gmail edits require a fresh preview. Use guarded `hold`, `release`, or `mark_cancelled` only with the reservation id, a reason, and affirmative cancellation evidence or explicit human instruction. Reauthorization must preserve the provider identity and existing reservation; never create a replacement touch merely because the connector was refreshed.
60
+
41
61
  ### Step 2: Build the dispatch list and categorize
42
62
 
43
63
  Once the user says go (or picks a focus area), pull the lightweight message queue via `search` with type=messages, `fields=compact` and limit=100 — make THREE cheap calls: `warm_only=true, status=needs_draft, sort_by=expiring` (the WARM authoring lane — LinkedIn follow-ups to accepted connections, nearest deadline first), `status=needs_draft` (the full authoring lane; warm rows appear here too — dedupe by message_id, warm lane wins), and `status=draft` (the review lane). `fields=compact` returns message_id, person_name, category, sequence_number, channel, status, match_score, touches_completed, campaign_id, first_content_touch, and channel_rewrite_reason WITHOUT message content — very cheap on tokens. Tag each item with its status so dispatch routes it to the right mode: `needs_draft` → authoring, `draft` → review. (Omitting the status filter returns the default actionable set — needs_draft + draft + approved — but pull the two lanes explicitly so already-approved messages awaiting send don't enter triage.)
@@ -202,4 +222,5 @@ After outreach messages are processed, ask if the user wants to review the engag
202
222
  - **User wants to review a specific person:** pull that person's conversation with `fetch` (type=conversation) and review directly. No batch workflow.
203
223
  - **Subagent can't reach MCP tools:** fall back to inline review.
204
224
  - **Homogeneous T1 pattern:** if the first T1 batch all had the identical issue, fix the remaining in bulk with a single `manage_messages` call passing an id array (same action applied to every id, max 50 per call). Confirm first.
225
+ - **Campaign plan settings drifted:** repair `max_touches` or `allowed_channels` through `manage_outreach` action=`update` with plan-id arrays. This preserves execution state; never update plan rows directly.
205
226
 
@@ -32,6 +32,43 @@ All harness source skills produce candidate lists matching this shape exactly. T
32
32
  - At minimum, each candidate needs **either** `linkedin_url` **or** (`name`-fields + `company`). Candidates with neither are skipped at Step 3.
33
33
  - `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
34
  - Field additions are additive only. Removing a field is a breaking change for source skills.
35
+ - `source_policy`, candidate examples, and progress events have executable schemas under `contracts/`. Validate handoffs against them before provider calls.
36
+
37
+ ## Source-policy and recovery contract
38
+
39
+ The canonical source policy is `contracts/source-policy.schema.json`. Validate it before
40
+ inventorying providers. A selected source may not also be prohibited; exclusive mode
41
+ has no fallbacks; ordered fallbacks may not contain prohibited sources. An explicitly
42
+ selected disconnected source stops before the first external call.
43
+
44
+ Every wave emits an object matching `contracts/run-progress.schema.json`, including
45
+ `state`, `phase`, `wave`, `wave_count`, `completed`, `total`, `failed`,
46
+ `not_attempted`, `safe_retry_items`, `ambiguous_items`, and `code`. A server 5xx puts
47
+ the failing mutation in `ambiguous_items` because its commit status is unknown; only
48
+ later not-attempted items go in `safe_retry_items`. Completed items are never replayed.
49
+
50
+ Stable operator-visible codes:
51
+
52
+ | Code | Meaning | Recovery |
53
+ |---|---|---|
54
+ | `source_policy_invalid` | contradictory or out-of-range policy | correct the named fields before any provider call |
55
+ | `source_unavailable` | selected source is not connected | connect it or explicitly choose another source |
56
+ | `source_prohibited` | attempted source violates policy | remove the call; never override implicitly |
57
+ | `linkedin_identity_unresolvable` | profile is invalid/private/not found | use the next allowed structured fallback with the same `person_id` |
58
+ | `linkedin_temporarily_unavailable` | timeout/429 after bounded retries | retry only the returned item later; do not switch silently |
59
+ | `linkedin_auth_required` | LinkedIn account is disconnected/expired | reconnect LinkedIn |
60
+ | `company_identity_conflict` | exact evidence points to different companies | correct the evidence; never auto-merge |
61
+ | `company_resolution_failed` | company resolver returned no canonical row | retry once, then inspect resolver logs and evidence |
62
+ | `company_research_save_failed` | company research persistence failed with unknown commit status | inspect stored rows before any replay |
63
+ | `person_not_visible` | supplied person is outside the caller's tenant | use a tenant-visible person or omit `person_id` |
64
+ | `person_not_found` | tenant membership points to a missing person | refresh the candidate list |
65
+ | `person_identity_conflict` | fallback identifier belongs to another person | remove the conflicting identifier and review the provider result |
66
+ | `person_research_save_failed` | person research persistence failed with unknown commit status | inspect stored rows before any replay |
67
+ | `source_campaign_forbidden` | caller cannot remove people from their current campaign | ask the source-campaign owner to move them |
68
+ | `research_confirmation_required` | assignment requires explicit approval | pause and show the preview; never self-confirm |
69
+ | `research_partial` | some items failed or were not attempted | resume only `safe_retry_items` |
70
+
71
+ Backend response details link to `backend/app/domains/people/README.md#named-account-source-errors`.
35
72
 
36
73
  ---
37
74
 
@@ -85,11 +122,11 @@ acv_floor: {dollars or default $10K}
85
122
  Run your workflow (a–i) and return the structured output block.
86
123
  ```
87
124
 
88
- Each subagent returns: `company_id`, `funding_data`, `growth_metrics`, `current_priorities`, `outbound_motion_score` (0/1/2), `acv_class` (smb/mid/ent), `sales_cycle_inference` (short/medium/long), `triggers[]`, `STATUS: ok | failed`, `CACHE_HIT`.
125
+ Each subagent returns: `company_name`, `domain`, `funding_data`, `growth_metrics`, `current_priorities`, `outbound_motion_score` (0/1/2), `acv_class` (smb/mid/ent), `sales_cycle_inference` (short/medium/long), `triggers[]`, `STATUS: ok | failed`, `CACHE_HIT`. Subagents never persist; the orchestrator resolves `company_id` in Step 7 when the requested mode permits writes.
89
126
 
90
127
  **Wait for the wave to complete before Phase B.** Phase B inputs depend on Phase A's signals (or null if failed).
91
128
 
92
- **Subagent timeout cascade (load-bearing):** when STATUS=failed for a company, the orchestrator does NOT skip the prospects from that company. Phase B still runs for them with `null` company signals. The harness pre-filter gate then tags them `harness_gate_status: gate_inconclusive` (a fourth status alongside pass/warming/low_priority/dismiss). `manage_person` action=save_discovered is still called the backend's `MatchAnalysisAgent` may have cached company research from earlier runs and gates them appropriately. Surface gate-inconclusive prospects in the final report so the operator can re-run the failed companies later.
129
+ **Subagent timeout cascade (load-bearing):** when STATUS=failed for a company, the orchestrator does NOT skip the prospects from that company. Phase B still runs for them with `null` company signals. The harness tags them `harness_gate_status: gate_inconclusive`; the Step 7 rubric gives zero company/ACV and outbound points, so their authoritative score cannot exceed 50 and the backend-enforced gate cannot enroll them. Save them for operator review and surface them in the final report so the operator can re-run the failed companies later.
93
130
 
94
131
  **Inter-wave progress line.** After each wave (5–10 subagents):
95
132
  ```
@@ -103,6 +140,8 @@ Helps operators distinguish "still working" from "stuck."
103
140
 
104
141
  **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.)
105
142
 
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")`. 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
+
106
145
  Dispatch one `vruum-prospect-deep-researcher` per surviving candidate. Subagent file at `.claude/agents/vruum-prospect-deep-researcher.md`.
107
146
 
108
147
  Dispatch prompt template:
@@ -128,7 +167,7 @@ acv_floor: {dollars}
128
167
  Run your workflow (a–k) and return the structured output block. Note: do NOT call manage_person action=save_discovered or manage_outreach action=start — those are orchestrator-only and not in your tools list.
129
168
  ```
130
169
 
131
- Each subagent returns: `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`. Note: `person_id` is NOT returned here — identity resolution happens in Step 7.
170
+ 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.
132
171
 
133
172
  **Inter-wave progress line:**
134
173
  ```
@@ -139,7 +178,7 @@ Each subagent returns: `topics_of_interest`, `recent_posts`, `opening_hooks[]` (
139
178
 
140
179
  ## Step 6 — Harness pre-filter gate (orchestrator-side, pre-save)
141
180
 
142
- This is a **coarse pre-filter** its job is to avoid wasted backend save calls (`manage_person` action=save_discovered) on obvious dismisses. The **authoritative** gate is server-side `MatchAnalysisAgent.match_score >= 70` and runs inside that save call. The harness gate cannot override the backend gate; it can only dismiss before reaching it.
181
+ This is the categorical first half of the harness-authoritative gate. It avoids wasted backend saves for obvious dismisses and feeds the deterministic numeric assessment in Step 7c. The backend does not re-score a supplied assessment; it records the harness score and mechanically enforces `match_score >= 70`. `MatchAnalysisAgent` is fallback-only for newly added people when callers omit assessment; duplicates retain their stored score unless a campaign move enqueues an asynchronous re-score.
143
182
 
144
183
  Per surviving prospect, evaluate four criteria using the campaign's playbook ICP and the Phase A + Phase B signals:
145
184
 
@@ -149,7 +188,7 @@ Per surviving prospect, evaluate four criteria using the campaign's playbook ICP
149
188
 
150
189
  ### 2. Outbound motion or hiring signal?
151
190
  - `outbound_motion_score > 0` OR explicit hiring trigger present → pass
152
- - If no → flag `warming_candidate` (still call `manage_person` action=save_discovered — operator may want to warm-track them; backend match analysis tells us if the campaign fit is real)
191
+ - If no → flag `warming_candidate` (still call `manage_person` action=save_discovered — operator may want to warm-track them; the Step 7 rubric records the weaker fit honestly)
153
192
 
154
193
  ### 3. Decision-maker level senior?
155
194
  - `decision_maker_level == senior` → pass
@@ -175,10 +214,16 @@ For non-dismiss outcomes, also set `dismiss_reason` to null and `flag` to the re
175
214
 
176
215
  ## Step 7 — Save chain (everyone except harness-gate dismisses)
177
216
 
217
+ Apply the requested mode before any persistence:
218
+
219
+ - `research-only`: stop before Step 7a. Return the researched preview and do not call `save_company`, `save_person`, `save_discovered`, or `manage_outreach`.
220
+ - `save`: run Steps 7a–7c, but call `save_discovered` **without** `campaign_id`. This persists the tenant-visible prospect and gate result without assigning a campaign or starting outreach.
221
+ - `save-and-enroll`: run the full chain. Pass `campaign_id` to `save_discovered`, then include passing prospects in Step 7d.
222
+
178
223
  Per surviving prospect:
179
224
 
180
225
  ### a. Save company research (once per company)
181
- If the prospect's company isn't already cached and Phase A produced fresh research, call `research(action="save_company", payload={company_name, domain, funding_data, growth_metrics, current_priorities})`. Skip if `CACHE_HIT: true` for that company.
226
+ 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.
182
227
 
183
228
  ### b. Identity resolution + person research (load-bearing — corrects Codex Finding #6)
184
229
 
@@ -215,12 +260,56 @@ If the prospect's company isn't already cached and Phase A produced fresh resear
215
260
  - If the prospect already had `person_id` set on the candidate (e.g. operator pasted a Vruum person UUID), pass it explicitly in the payload: `research(action="save_person", payload={person_id: ..., ...})` — backend updates rather than creating a new record.
216
261
  - The response includes the `person_id`. Capture it for step c.
217
262
 
218
- ### c. Save discovered person (the backend authoritative gate runs here)
263
+ ### c. Save discovered person (authoritative harness score, backend-enforced gate)
264
+
265
+ Build the authoritative `assessment` from the campaign playbook plus Phase A/B evidence. Score mechanically so reruns agree:
266
+
267
+ - Company/ACV fit: 30 points when the known ACV class meets the campaign floor; a known miss is a harness dismiss and never reaches Step 7.
268
+ - Buying authority: 25 senior, 15 mid; a junior with no senior replacement is dismissed.
269
+ - Outbound/hiring motion: 20 when present, otherwise 0 and tag `warming`.
270
+ - Recent timing trigger: 15 when present, otherwise 0 and tag `low_priority`.
271
+ - Evidence strength: 10 for a verified profile plus at least two cited sources, 5 for partial cited evidence, 0 for unverified evidence.
272
+ - `gate_inconclusive` gets 0 for unknown company/ACV and outbound criteria, so it cannot exceed 50 without fresh company evidence.
273
+
274
+ The score is the sum (0–100); 70+ passes. Send this exact shape:
275
+
276
+ ```json
277
+ {
278
+ "match_score": 85,
279
+ "match_summary": "Two or three evidence-backed sentences against this campaign's ICP.",
280
+ "alignment_points": [
281
+ {
282
+ "point": "Specific alignment",
283
+ "evidence": "Cited fact and URL",
284
+ "confidence": 0.8,
285
+ "source_type": "harness_research"
286
+ }
287
+ ],
288
+ "concerns": [
289
+ {
290
+ "concern": "Specific gap",
291
+ "evidence": "Cited or explicitly missing evidence",
292
+ "severity": "blocker|warning|minor"
293
+ }
294
+ ],
295
+ "why_now": "Timing rationale with source",
296
+ "recommended_approach": "Campaign-relevant approach",
297
+ "overall_confidence": 0.8,
298
+ "scored_by": "harness:pipeline-fill"
299
+ }
300
+ ```
301
+
302
+ `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
+
304
+ Then call `manage_person(action="save_discovered", payload={person_id: <from b>, assessment: <object above>, ...})`:
305
+
306
+ - `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
+ - `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.
219
308
 
220
- Call `manage_person(action="save_discovered", payload={person_id: <from b>, campaign_id: ...})`. This:
221
- - Runs server-side `analyze_person_match` + signal eval
309
+ This:
310
+ - Records the harness assessment as authoritative and skips the backend LLM scorer
222
311
  - Returns `match_score` (0–100) and `quality_gate_pass` (bool, true iff `match_score >= 70`)
223
- - Writes the `company_people` row that puts the prospect into the campaign
312
+ - Writes the tenant's `company_people` row; campaign assignment happens only when the payload includes `campaign_id`
224
313
 
225
314
  **Distinguish two failure modes (Codex Finding #9):**
226
315
  - **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.
@@ -256,7 +345,7 @@ Harness pre-filter gate:
256
345
  gate_inconclusive : {N}
257
346
  dismiss : {N} (top reasons: acv_too_low={N}, decision_maker_junior={N})
258
347
 
259
- Backend authoritative gate (match_score >= 70):
348
+ Backend-enforced gate using the authoritative harness score (match_score >= 70):
260
349
  passed : {N}
261
350
  failed : {N} (saved with research; operator can review via /enrich-prospect)
262
351
  request_failed : {N} (retry candidates — surface in next run)
@@ -281,9 +370,9 @@ For multi-campaign runs, group the report by campaign and include a totals summa
281
370
  ## Edge cases + failure handling reference
282
371
 
283
372
  - **Source returns empty after dedup** — orchestrator says "All {N} candidates already in pipeline, nothing to research" and exits cleanly.
284
- - **Mid-flight cancellation** (operator Ctrl+C between Phase A and Phase B) Phase A research is saved server-side. Re-running `/pipeline-fill` for the same campaign + source picks up via batch dedup; no re-research of cached companies. Note this in the cancellation message.
285
- - **Subagent timeout cascade** — Phase A failed for a company → Phase B runs degraded → harness gate marks `gate_inconclusive` → backend decides via cached company research. See Step 4.
286
- - **Two-gate disagreement** — harness pass + backend fail (or vice versa) see Step 7c. Stricter outcome wins for enrollment; both states surfaced in the report.
373
+ - **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.
374
+ - **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.
375
+ - **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.
287
376
  - **Cached company research >90 days old** — Phase A re-runs the company subagent. Don't trust stale signals for an active fill.
288
377
  - **Manual-list cap** — if >100 lines pasted, orchestrator asks "{N} prospects pasted — process all, or first M? (a/N)".
289
378
  - **CSV >200 rows** — same prompt at Step 5 of csv-pipeline-fill.
@@ -14,7 +14,7 @@ You are a source-agnostic pipeline filler. You pick campaigns to fill, pick a so
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), pre-filters against campaign ICP, then saves the qualified ones into the campaign via the backend's canonical 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, 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
 
@@ -51,13 +51,15 @@ The orchestrator's MCP precheck at the top of Step 3 (the `fetch` type=research_
51
51
  ## Inputs
52
52
 
53
53
  - `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
+ - `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.
54
55
  - `campaign(s)`: target campaign(s); multi-campaign supported.
55
56
  - `mode`: `research-only` | `save` | `save-and-enroll` (default: `save-and-enroll`).
56
- - `gate_threshold`: minimum backend `match_score` to enroll (default: campaign's existing quality_gate).
57
57
 
58
58
  ## Workflow — Step 1: Show pipeline status & pick campaigns
59
59
 
60
- Call `import_prospects(action="sales_nav_searches", payload={action: "list"})` + `fetch(type="stats", subtype="outreach")` for queue depth + `search(type="campaigns")` for non-Sales-Nav campaigns. Present a numbered table with **per-campaign ETA**:
60
+ Always call `fetch(type="stats", subtype="outreach")` for queue depth and `search(type="campaigns")` for campaign status. Call `import_prospects(action="sales_nav_searches", payload={action: "list"})` **only** when the operator explicitly selected Sales Nav and `source_policy.prohibited_sources` does not contain `sales_nav` or `linkedin`. A generic status check must never touch Sales Nav.
61
+
62
+ Present a numbered table with **per-campaign ETA**:
61
63
 
62
64
  ```
63
65
  Pipeline status:
@@ -88,6 +90,8 @@ ETA estimates: ~2s for batch Step 3 dedup + ~30s/wave Phase A + ~60s/wave Phase
88
90
 
89
91
  > Sourcing {campaign_name} via discovery (ICP-based, long-tail). Reply `sales-nav`, `yc`, `csv`, or `picker` to switch.
90
92
 
93
+ 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
+
91
95
  Why discovery is the default: keyword/Sales-Nav sources keep returning the same marquee names, which collide with already-enrolled prospects as a campaign matures — the Step 3 dedup then throws most of the batch away. Discovery anchors on the campaign's own ICP and reaches the long tail, deduping *before* research instead of after. Only render the full picker below when the operator asks to choose (`picker`), names a non-discovery source, or the discovery handler can't proceed.
92
96
 
93
97
  Per selected campaign, when the operator wants to choose the source explicitly, prompt:
@@ -141,12 +145,16 @@ Operator gives a brief like "Series A-C SaaS founders, US, 50-500 ppl" or "direc
141
145
  - **Email finder** — Hunter via `search type=companies {domain, seniority}`, or the provider's own email step — to fill the contact emails Phase B needs.
142
146
  - **Web** (`WebSearch` / `WebFetch`) — always available; the universal fallback and a strong long-tail *company* finder (funding announcements, Crunchbase/PitchBook, vertical directories) even when a data provider is connected.
143
147
 
144
- Announce the pick in one line ("Sourcing via Clay — firmographic pull + committee enrichment; web as backup") so the operator can redirect. If no enrichment provider is connected, say so and fall back to web + Hunter.
148
+ 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.
145
149
  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.
146
150
  4. **Resolve the buying committee per company** — for each candidate company, pull ICP-matching titles via the same provider's contact enrichment (e.g. Clay `find-and-enrich-contacts-at-company`) or `search type=companies {domain, seniority}` (Hunter). Cap ~5 people/company to spread the surface.
147
151
  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.
148
152
  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.
149
153
 
154
+ Emit progress objects matching `contracts/run-progress.schema.json` after every bounded wave. Company-provider actions run in waves of at most 10; person/LinkedIn/provider contact actions run in waves of at most 5. Never submit a mixed unbounded batch and wait without a progress update.
155
+
156
+ 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.
157
+
150
158
  Discovery-path candidates produced in either path use the canonical shape in `RESEARCH-ENGINE.md` and feed into Step 3 the same way.
151
159
 
152
160
  **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?"
@@ -161,5 +169,5 @@ Do not duplicate the engine logic in this skill — link operators back to the e
161
169
 
162
170
  - **Composability** with source skills: source skills produce candidate lists; this orchestrator runs the research engine. Both directions allowed (operator can run a source skill standalone or run /pipeline-fill as the front door).
163
171
  - **Real money costs** are in Phase B (LinkedIn API + Hunter calls + OpenAI tokens for the prospect subagent). Phase A is mostly WebFetch/WebSearch which is operator-network. The batch primitives in Step 3 keep dedup latency low (~2s vs 12s pre-batch).
164
- - **Harness offload framing**: deep research runs in your IDE (your tokens). The backend `MatchAnalysisAgent` runs the canonical gate (~$0.02/prospect on Vruum's bill). This split is intentional see memory `project_harness_offload_strategy.md`.
172
+ - **Harness offload framing**: deep research and the authoritative campaign score run in your IDE (your tokens). The backend validates the payload, records provenance, and mechanically enforces `match_score >= 70`; `MatchAnalysisAgent` is fallback-only for newly added people when a caller omits assessment. Duplicates retain their stored score unless a campaign move enqueues an asynchronous re-score.
165
173
  - **Audit trail**: every run writes to `.context/runs/pipeline-fill-{ISO-timestamp}.md`. Useful weeks later for "what did the YC fill on Apr 12 import?"
@@ -0,0 +1,95 @@
1
+ {
2
+ "source_policies": {
3
+ "clay_without_sales_nav": {
4
+ "version": "1",
5
+ "selected_source": "clay",
6
+ "source_mode": "preferred",
7
+ "prohibited_sources": [
8
+ "sales_nav",
9
+ "linkedin",
10
+ "csv"
11
+ ],
12
+ "allowed_fallbacks": [
13
+ "web",
14
+ "hunter"
15
+ ],
16
+ "company_wave_size": 10,
17
+ "person_wave_size": 5,
18
+ "transient_retry_attempts": 2,
19
+ "reason": "Named-account sourcing requested through Clay."
20
+ },
21
+ "clay_exclusive": {
22
+ "version": "1",
23
+ "selected_source": "clay",
24
+ "source_mode": "exclusive",
25
+ "prohibited_sources": [
26
+ "sales_nav",
27
+ "linkedin",
28
+ "csv"
29
+ ],
30
+ "allowed_fallbacks": [],
31
+ "company_wave_size": 10,
32
+ "person_wave_size": 5,
33
+ "transient_retry_attempts": 2,
34
+ "reason": "Use only Clay; stop if it is unavailable."
35
+ }
36
+ },
37
+ "progress": {
38
+ "company_wave": {
39
+ "version": "1",
40
+ "state": "running",
41
+ "phase": "company_research",
42
+ "provider": "clay",
43
+ "wave": 1,
44
+ "wave_count": 1,
45
+ "completed": 7,
46
+ "total": 7,
47
+ "failed": 0,
48
+ "not_attempted": 0,
49
+ "safe_retry_items": [],
50
+ "ambiguous_items": [],
51
+ "code": null,
52
+ "elapsed_seconds": 34,
53
+ "eta_seconds": 0
54
+ },
55
+ "person_wave": {
56
+ "version": "1",
57
+ "state": "partial",
58
+ "phase": "person_research",
59
+ "provider": "clay",
60
+ "wave": 2,
61
+ "wave_count": 4,
62
+ "completed": 9,
63
+ "total": 20,
64
+ "failed": 1,
65
+ "not_attempted": 10,
66
+ "safe_retry_items": [
67
+ "person-11",
68
+ "person-12",
69
+ "person-13",
70
+ "person-14",
71
+ "person-15",
72
+ "person-16",
73
+ "person-17",
74
+ "person-18",
75
+ "person-19",
76
+ "person-20"
77
+ ],
78
+ "ambiguous_items": [
79
+ "person-10"
80
+ ],
81
+ "code": "research_partial",
82
+ "elapsed_seconds": 58,
83
+ "eta_seconds": null
84
+ }
85
+ },
86
+ "recent_posts": [
87
+ {
88
+ "text": "We are opening a new clinic.",
89
+ "posted_at": "2026-07-20T14:00:00Z",
90
+ "share_url": "https://www.linkedin.com/feed/update/urn:li:activity:1",
91
+ "reaction_count": 12,
92
+ "comment_count": 3
93
+ }
94
+ ]
95
+ }
@@ -0,0 +1,126 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://vruum.ai/contracts/pipeline-fill/run-progress.schema.json",
4
+ "title": "Pipeline Fill Run Progress",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "version",
9
+ "state",
10
+ "phase",
11
+ "wave",
12
+ "wave_count",
13
+ "completed",
14
+ "total",
15
+ "failed",
16
+ "not_attempted",
17
+ "safe_retry_items",
18
+ "ambiguous_items",
19
+ "code"
20
+ ],
21
+ "properties": {
22
+ "version": {
23
+ "const": "1"
24
+ },
25
+ "state": {
26
+ "enum": [
27
+ "running",
28
+ "completed",
29
+ "partial",
30
+ "paused",
31
+ "failed"
32
+ ]
33
+ },
34
+ "phase": {
35
+ "enum": [
36
+ "company_research",
37
+ "person_research",
38
+ "assignment"
39
+ ]
40
+ },
41
+ "provider": {
42
+ "type": [
43
+ "string",
44
+ "null"
45
+ ]
46
+ },
47
+ "wave": {
48
+ "type": "integer",
49
+ "minimum": 1
50
+ },
51
+ "wave_count": {
52
+ "type": "integer",
53
+ "minimum": 1
54
+ },
55
+ "completed": {
56
+ "type": "integer",
57
+ "minimum": 0
58
+ },
59
+ "total": {
60
+ "type": "integer",
61
+ "minimum": 0
62
+ },
63
+ "failed": {
64
+ "type": "integer",
65
+ "minimum": 0
66
+ },
67
+ "not_attempted": {
68
+ "type": "integer",
69
+ "minimum": 0
70
+ },
71
+ "safe_retry_items": {
72
+ "type": "array",
73
+ "items": {
74
+ "oneOf": [
75
+ {
76
+ "type": "string",
77
+ "minLength": 1
78
+ },
79
+ {
80
+ "type": "integer",
81
+ "minimum": 0
82
+ },
83
+ {
84
+ "type": "object"
85
+ }
86
+ ]
87
+ }
88
+ },
89
+ "ambiguous_items": {
90
+ "type": "array",
91
+ "description": "Failed mutations whose commit status is unknown; inspect before replaying.",
92
+ "items": {
93
+ "oneOf": [
94
+ {
95
+ "type": "string",
96
+ "minLength": 1
97
+ },
98
+ {
99
+ "type": "integer",
100
+ "minimum": 0
101
+ },
102
+ {
103
+ "type": "object"
104
+ }
105
+ ]
106
+ }
107
+ },
108
+ "code": {
109
+ "type": [
110
+ "string",
111
+ "null"
112
+ ]
113
+ },
114
+ "elapsed_seconds": {
115
+ "type": "number",
116
+ "minimum": 0
117
+ },
118
+ "eta_seconds": {
119
+ "type": [
120
+ "number",
121
+ "null"
122
+ ],
123
+ "minimum": 0
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,399 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://vruum.ai/contracts/pipeline-fill/source-policy.schema.json",
4
+ "title": "Pipeline Fill Source Policy",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "version",
9
+ "selected_source",
10
+ "source_mode",
11
+ "prohibited_sources",
12
+ "allowed_fallbacks",
13
+ "company_wave_size",
14
+ "person_wave_size",
15
+ "transient_retry_attempts"
16
+ ],
17
+ "properties": {
18
+ "version": {
19
+ "const": "1"
20
+ },
21
+ "selected_source": {
22
+ "oneOf": [
23
+ {
24
+ "$ref": "#/$defs/provider"
25
+ },
26
+ {
27
+ "type": "null"
28
+ }
29
+ ],
30
+ "default": null
31
+ },
32
+ "source_mode": {
33
+ "enum": [
34
+ "preferred",
35
+ "exclusive"
36
+ ],
37
+ "default": "preferred"
38
+ },
39
+ "prohibited_sources": {
40
+ "type": "array",
41
+ "items": {
42
+ "$ref": "#/$defs/provider"
43
+ },
44
+ "uniqueItems": true,
45
+ "default": []
46
+ },
47
+ "allowed_fallbacks": {
48
+ "type": "array",
49
+ "items": {
50
+ "$ref": "#/$defs/provider"
51
+ },
52
+ "uniqueItems": true,
53
+ "default": [
54
+ "web"
55
+ ]
56
+ },
57
+ "company_wave_size": {
58
+ "type": "integer",
59
+ "minimum": 1,
60
+ "maximum": 10,
61
+ "default": 10
62
+ },
63
+ "person_wave_size": {
64
+ "type": "integer",
65
+ "minimum": 1,
66
+ "maximum": 5,
67
+ "default": 5
68
+ },
69
+ "transient_retry_attempts": {
70
+ "type": "integer",
71
+ "minimum": 0,
72
+ "maximum": 2,
73
+ "default": 2
74
+ },
75
+ "reason": {
76
+ "type": "string",
77
+ "minLength": 1
78
+ }
79
+ },
80
+ "$defs": {
81
+ "provider": {
82
+ "enum": [
83
+ "clay",
84
+ "sales_nav",
85
+ "linkedin",
86
+ "csv",
87
+ "yc",
88
+ "web",
89
+ "hunter",
90
+ "manual"
91
+ ]
92
+ }
93
+ },
94
+ "allOf": [
95
+ {
96
+ "if": {
97
+ "properties": {
98
+ "source_mode": {
99
+ "const": "exclusive"
100
+ }
101
+ },
102
+ "required": [
103
+ "source_mode"
104
+ ]
105
+ },
106
+ "then": {
107
+ "properties": {
108
+ "allowed_fallbacks": {
109
+ "maxItems": 0
110
+ }
111
+ }
112
+ }
113
+ },
114
+ {
115
+ "not": {
116
+ "anyOf": [
117
+ {
118
+ "properties": {
119
+ "selected_source": {
120
+ "const": "clay"
121
+ },
122
+ "prohibited_sources": {
123
+ "contains": {
124
+ "const": "clay"
125
+ }
126
+ }
127
+ },
128
+ "required": [
129
+ "selected_source",
130
+ "prohibited_sources"
131
+ ]
132
+ },
133
+ {
134
+ "properties": {
135
+ "selected_source": {
136
+ "const": "sales_nav"
137
+ },
138
+ "prohibited_sources": {
139
+ "contains": {
140
+ "const": "sales_nav"
141
+ }
142
+ }
143
+ },
144
+ "required": [
145
+ "selected_source",
146
+ "prohibited_sources"
147
+ ]
148
+ },
149
+ {
150
+ "properties": {
151
+ "selected_source": {
152
+ "const": "linkedin"
153
+ },
154
+ "prohibited_sources": {
155
+ "contains": {
156
+ "const": "linkedin"
157
+ }
158
+ }
159
+ },
160
+ "required": [
161
+ "selected_source",
162
+ "prohibited_sources"
163
+ ]
164
+ },
165
+ {
166
+ "properties": {
167
+ "selected_source": {
168
+ "const": "csv"
169
+ },
170
+ "prohibited_sources": {
171
+ "contains": {
172
+ "const": "csv"
173
+ }
174
+ }
175
+ },
176
+ "required": [
177
+ "selected_source",
178
+ "prohibited_sources"
179
+ ]
180
+ },
181
+ {
182
+ "properties": {
183
+ "selected_source": {
184
+ "const": "yc"
185
+ },
186
+ "prohibited_sources": {
187
+ "contains": {
188
+ "const": "yc"
189
+ }
190
+ }
191
+ },
192
+ "required": [
193
+ "selected_source",
194
+ "prohibited_sources"
195
+ ]
196
+ },
197
+ {
198
+ "properties": {
199
+ "selected_source": {
200
+ "const": "web"
201
+ },
202
+ "prohibited_sources": {
203
+ "contains": {
204
+ "const": "web"
205
+ }
206
+ }
207
+ },
208
+ "required": [
209
+ "selected_source",
210
+ "prohibited_sources"
211
+ ]
212
+ },
213
+ {
214
+ "properties": {
215
+ "selected_source": {
216
+ "const": "hunter"
217
+ },
218
+ "prohibited_sources": {
219
+ "contains": {
220
+ "const": "hunter"
221
+ }
222
+ }
223
+ },
224
+ "required": [
225
+ "selected_source",
226
+ "prohibited_sources"
227
+ ]
228
+ },
229
+ {
230
+ "properties": {
231
+ "selected_source": {
232
+ "const": "manual"
233
+ },
234
+ "prohibited_sources": {
235
+ "contains": {
236
+ "const": "manual"
237
+ }
238
+ }
239
+ },
240
+ "required": [
241
+ "selected_source",
242
+ "prohibited_sources"
243
+ ]
244
+ }
245
+ ]
246
+ }
247
+ },
248
+ {
249
+ "not": {
250
+ "anyOf": [
251
+ {
252
+ "properties": {
253
+ "allowed_fallbacks": {
254
+ "contains": {
255
+ "const": "clay"
256
+ }
257
+ },
258
+ "prohibited_sources": {
259
+ "contains": {
260
+ "const": "clay"
261
+ }
262
+ }
263
+ },
264
+ "required": [
265
+ "allowed_fallbacks",
266
+ "prohibited_sources"
267
+ ]
268
+ },
269
+ {
270
+ "properties": {
271
+ "allowed_fallbacks": {
272
+ "contains": {
273
+ "const": "sales_nav"
274
+ }
275
+ },
276
+ "prohibited_sources": {
277
+ "contains": {
278
+ "const": "sales_nav"
279
+ }
280
+ }
281
+ },
282
+ "required": [
283
+ "allowed_fallbacks",
284
+ "prohibited_sources"
285
+ ]
286
+ },
287
+ {
288
+ "properties": {
289
+ "allowed_fallbacks": {
290
+ "contains": {
291
+ "const": "linkedin"
292
+ }
293
+ },
294
+ "prohibited_sources": {
295
+ "contains": {
296
+ "const": "linkedin"
297
+ }
298
+ }
299
+ },
300
+ "required": [
301
+ "allowed_fallbacks",
302
+ "prohibited_sources"
303
+ ]
304
+ },
305
+ {
306
+ "properties": {
307
+ "allowed_fallbacks": {
308
+ "contains": {
309
+ "const": "csv"
310
+ }
311
+ },
312
+ "prohibited_sources": {
313
+ "contains": {
314
+ "const": "csv"
315
+ }
316
+ }
317
+ },
318
+ "required": [
319
+ "allowed_fallbacks",
320
+ "prohibited_sources"
321
+ ]
322
+ },
323
+ {
324
+ "properties": {
325
+ "allowed_fallbacks": {
326
+ "contains": {
327
+ "const": "yc"
328
+ }
329
+ },
330
+ "prohibited_sources": {
331
+ "contains": {
332
+ "const": "yc"
333
+ }
334
+ }
335
+ },
336
+ "required": [
337
+ "allowed_fallbacks",
338
+ "prohibited_sources"
339
+ ]
340
+ },
341
+ {
342
+ "properties": {
343
+ "allowed_fallbacks": {
344
+ "contains": {
345
+ "const": "web"
346
+ }
347
+ },
348
+ "prohibited_sources": {
349
+ "contains": {
350
+ "const": "web"
351
+ }
352
+ }
353
+ },
354
+ "required": [
355
+ "allowed_fallbacks",
356
+ "prohibited_sources"
357
+ ]
358
+ },
359
+ {
360
+ "properties": {
361
+ "allowed_fallbacks": {
362
+ "contains": {
363
+ "const": "hunter"
364
+ }
365
+ },
366
+ "prohibited_sources": {
367
+ "contains": {
368
+ "const": "hunter"
369
+ }
370
+ }
371
+ },
372
+ "required": [
373
+ "allowed_fallbacks",
374
+ "prohibited_sources"
375
+ ]
376
+ },
377
+ {
378
+ "properties": {
379
+ "allowed_fallbacks": {
380
+ "contains": {
381
+ "const": "manual"
382
+ }
383
+ },
384
+ "prohibited_sources": {
385
+ "contains": {
386
+ "const": "manual"
387
+ }
388
+ }
389
+ },
390
+ "required": [
391
+ "allowed_fallbacks",
392
+ "prohibited_sources"
393
+ ]
394
+ }
395
+ ]
396
+ }
397
+ }
398
+ ]
399
+ }
@@ -33,6 +33,7 @@ Build "your revenue engine today" from live reads — never from memory or assum
33
33
  - `search` type=people limit=1 filters={research_status: "all"} → total contacts (read the total, not the rows)
34
34
  - `search` type=deals limit=5 → deal pipeline existence
35
35
  - `fetch` type=stats subtype=outreach → sends, replies, meetings
36
+ - `get_outreach_review` content_length=preview limit=1 → actionable, approved-pending-send, and externally scheduled counts
36
37
  - `search` type=content → whether an organic content motion is active
37
38
 
38
39
  Present a compact snapshot (5-8 lines, their numbers), positioned on the revenue-motion map (Step 2). If the company record or knowledge base shows a referral source ("referred by X"), acknowledge it and skip intake questions that referral context already answers.
@@ -64,6 +65,7 @@ Important boundaries: outreach/reply/content/comment prose is authored in the ha
64
65
  | Signal | Recommendation |
65
66
  |---|---|
66
67
  | Contacts sitting unenrolled | enroll into a campaign (`/campaign-builder` or `manage_campaign` action=members) |
68
+ | Gmail/provider-scheduled rows or reconciliation exceptions | `/outreach-triage` Gmail/Vruum reconciliation lane; never recreate the email |
67
69
  | Outreach queue has pending drafts | `/outreach-triage` |
68
70
  | Engagement queue non-empty | `/engagement-triage` |
69
71
  | Replies without follow-up | `/diagnose-reply` on the interesting ones, then respond |