@sellable/mcp 0.1.104 → 0.1.106

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.
@@ -7,10 +7,10 @@ Required first step:
7
7
  - Load the canonical provider prompt before searching. If the parent supplies a
8
8
  draft `campaignOfferId`, call `get_provider_prompt({ provider:
9
9
  "signal-discovery", campaignOfferId, confirmed: true })` and include that same
10
- `campaignOfferId` in `search_signals` so the user can watch source work in the
11
- campaign UI. Treat that as a campaign-attached persisted search; do not run a
12
- post-mint search without the campaign ID. If no campaign ID is supplied, run
13
- campaignless preview mode.
10
+ `campaignOfferId` plus `currentStep: "signal-discovery"` in `search_signals`
11
+ so the user can watch source work in the campaign UI. Treat that as a
12
+ campaign-attached persisted search; do not run a post-mint search without the
13
+ campaign ID. If no campaign ID is supplied, run campaignless preview mode.
14
14
 
15
15
  Use the inherited Sellable MCP tools when available:
16
16
 
@@ -6,9 +6,10 @@ Required first step:
6
6
 
7
7
  - Load the canonical provider prompt before searching. If the parent supplies a
8
8
  draft `campaignOfferId`, call `get_provider_prompt({ provider: "prospeo",
9
- campaignOfferId, confirmed: true })` and include that same `campaignOfferId` in
10
- `search_prospeo` so the user can watch source work in the campaign UI. If no
11
- campaign ID is supplied, run campaignless preview mode. Treat post-mint
9
+ campaignOfferId, confirmed: true })` and include that same `campaignOfferId`
10
+ plus `currentStep: "prospeo"` in `search_prospeo` so the user can watch source
11
+ work in the campaign UI. If no campaign ID is supplied, run campaignless
12
+ preview mode. Treat post-mint
12
13
  searches with `campaignOfferId` as campaign-attached persisted search tabs;
13
14
  do not run a live campaign search without the campaign ID.
14
15
 
@@ -7,8 +7,9 @@ Required first step:
7
7
  - Load the canonical provider prompt before searching. If the parent supplies a
8
8
  draft `campaignOfferId`, call `get_provider_prompt({ provider: "sales-nav",
9
9
  campaignOfferId, confirmed: true })` and include that same `campaignOfferId` in
10
- `search_sales_nav` so the user can watch source work in the campaign UI. If no
11
- campaign ID is supplied, run campaignless preview mode. Treat post-mint
10
+ `search_sales_nav` with `currentStep: "sales-nav"` so the user can watch
11
+ source work in the campaign UI. If no campaign ID is supplied, run
12
+ campaignless preview mode. Treat post-mint
12
13
  searches with `campaignOfferId` as campaign-attached persisted search tabs;
13
14
  do not run a live campaign search without the campaign ID.
14
15
 
@@ -594,7 +594,7 @@ export const leadToolDefinitions = [
594
594
  },
595
595
  {
596
596
  name: "search_sales_nav",
597
- description: 'Search LinkedIn Sales Navigator. Requires get_provider_prompt({ provider: "sales-nav" }) first. Returns normalized results with pagination. `campaignOfferId` is optional for directional preview runs; include it only when the search should be associated with a campaign.',
597
+ description: 'Search LinkedIn Sales Navigator. Requires get_provider_prompt({ provider: "sales-nav" }) first. Returns normalized results with pagination. `campaignOfferId` is optional for directional preview runs; include it only when the search should be associated with a campaign. Post-mint create-campaign-v2 watch runs MUST pass campaignOfferId and currentStep: "sales-nav" so the search appears in the watched campaign UI. Omitting campaignOfferId post-mint orphans the search from the UI.',
598
598
  inputSchema: {
599
599
  type: "object",
600
600
  properties: {
@@ -739,7 +739,7 @@ export const leadToolDefinitions = [
739
739
  },
740
740
  {
741
741
  name: "search_prospeo",
742
- description: 'Search Prospeo for people using filters and optional domainFilterId. Requires get_provider_prompt({ provider: "prospeo" }) first. When targeting known accounts, call load_csv_domains for CSV-on-disk workflows or save_domain_filters for pasted/raw domain lists, then pass domainFilterId. Raw domain inputs and company-name targeting are NOT supported in this MCP tool. Strategy: start with 2-3 high-signal filters (title/seniority + industry or domainFilterId + headcount), then tighten one filter at a time. For security, AppSec, SOC, RevOps, Demand Gen, and similar function-specific lanes, do not widen with bare seniority labels like "Head" or "Director" alone; pair them with explicit function-title keywords and inspect the sample for off-function `Head of X` leakage. Prefer person location over company HQ unless HQ is explicitly needed. `campaignOfferId` routing rule: OMIT campaignOfferId ONLY in pre-mint Phase 84 `find leads` discovery mode (validating ICP before the commit gate). In every other context — post-mint lead additions, operator-driven searches on a live campaign, any search where the intent is to persist results to a specific campaign — you MUST pass campaignOfferId so the search shows up in that campaign\'s Contact Search panel. Omitting campaignOfferId post-mint orphans the search from the UI. Returns normalized results with pagination.',
742
+ description: 'Search Prospeo for people using filters and optional domainFilterId. Requires get_provider_prompt({ provider: "prospeo" }) first. When targeting known accounts, call load_csv_domains for CSV-on-disk workflows or save_domain_filters for pasted/raw domain lists, then pass domainFilterId. Raw domain inputs and company-name targeting are NOT supported in this MCP tool. Strategy: start with 2-3 high-signal filters (title/seniority + industry or domainFilterId + headcount), then tighten one filter at a time. For security, AppSec, SOC, RevOps, Demand Gen, and similar function-specific lanes, do not widen with bare seniority labels like "Head" or "Director" alone; pair them with explicit function-title keywords and inspect the sample for off-function `Head of X` leakage. Prefer person location over company HQ unless HQ is explicitly needed. `campaignOfferId` routing rule: OMIT campaignOfferId ONLY in pre-mint Phase 84 `find leads` discovery mode (validating ICP before the commit gate). In every other context — post-mint lead additions, operator-driven searches on a live campaign, any search where the intent is to persist results to a specific campaign — you MUST pass campaignOfferId so the search shows up in that campaign\'s Contact Search panel. Post-mint create-campaign-v2 watch runs MUST pass currentStep: "prospeo". Omitting campaignOfferId post-mint orphans the search from the UI. Returns normalized results with pagination.',
743
743
  inputSchema: {
744
744
  type: "object",
745
745
  properties: {
@@ -848,7 +848,7 @@ export const leadToolDefinitions = [
848
848
  },
849
849
  {
850
850
  name: "search_signals",
851
- description: 'Search LinkedIn posts for signal discovery. Requires get_provider_prompt({ provider: "signal-discovery" }) first. Supports keyword search, profile posts, company posts, or a single post URL. Use campaignless preview mode for directional discovery; include `campaignOfferId` only when the search should persist into campaign state. Returns a compact summary with recommended posts to avoid context bloat.',
851
+ description: 'Search LinkedIn posts for signal discovery. Requires get_provider_prompt({ provider: "signal-discovery" }) first. Supports keyword search, profile posts, company posts, or a single post URL. Use campaignless preview mode for directional discovery; include `campaignOfferId` when the search should persist into campaign state. Post-mint create-campaign-v2 watch runs MUST pass campaignOfferId and currentStep: "signal-discovery" so the search appears in the watched campaign UI. Omitting campaignOfferId post-mint orphans the search from the UI. Returns a compact summary with recommended posts to avoid context bloat.',
852
852
  inputSchema: {
853
853
  type: "object",
854
854
  properties: {
@@ -2366,6 +2366,15 @@ export async function selectPromisingPosts(input) {
2366
2366
  headlineICPCriteria,
2367
2367
  rubricGuidelines: headlineICPCriteria,
2368
2368
  });
2369
+ if (selectionResult.selectedCount <= 0) {
2370
+ return {
2371
+ success: false,
2372
+ selectedCount: selectionResult.selectedCount,
2373
+ unselectedCount: selectionResult.unselectedCount,
2374
+ criteriaCount: selectionResult.criteriaCount,
2375
+ message: "No Signal Discovery posts were selected for this campaign. Do not import yet. Re-run a campaign-scoped search_signals call, use recommendedPostIds from the current campaign search state, or ask the user to promote posts in the UI before retrying select_promising_posts.",
2376
+ };
2377
+ }
2369
2378
  // Update currentStep if provided (via v2 endpoint)
2370
2379
  if (currentStep) {
2371
2380
  await api.put(`/api/v2/campaign-offers/${campaignOfferId}`, {
@@ -32,6 +32,7 @@ type WaitForRubricResultsInput = {
32
32
  campaignOfferId?: string;
33
33
  tableId?: string;
34
34
  targetCount?: number;
35
+ minPassedCount?: number;
35
36
  timeoutMs?: number;
36
37
  intervalMs?: number;
37
38
  includeRows?: boolean;
@@ -109,6 +110,7 @@ export declare const rubricToolDefinitions: ({
109
110
  maxProspects?: undefined;
110
111
  tableId?: undefined;
111
112
  targetCount?: undefined;
113
+ minPassedCount?: undefined;
112
114
  timeoutMs?: undefined;
113
115
  intervalMs?: undefined;
114
116
  includeRows?: undefined;
@@ -141,6 +143,7 @@ export declare const rubricToolDefinitions: ({
141
143
  maxProspects?: undefined;
142
144
  tableId?: undefined;
143
145
  targetCount?: undefined;
146
+ minPassedCount?: undefined;
144
147
  timeoutMs?: undefined;
145
148
  intervalMs?: undefined;
146
149
  includeRows?: undefined;
@@ -198,6 +201,7 @@ export declare const rubricToolDefinitions: ({
198
201
  maxProspects?: undefined;
199
202
  tableId?: undefined;
200
203
  targetCount?: undefined;
204
+ minPassedCount?: undefined;
201
205
  timeoutMs?: undefined;
202
206
  intervalMs?: undefined;
203
207
  includeRows?: undefined;
@@ -255,6 +259,7 @@ export declare const rubricToolDefinitions: ({
255
259
  maxProspects?: undefined;
256
260
  tableId?: undefined;
257
261
  targetCount?: undefined;
262
+ minPassedCount?: undefined;
258
263
  timeoutMs?: undefined;
259
264
  intervalMs?: undefined;
260
265
  includeRows?: undefined;
@@ -314,6 +319,7 @@ export declare const rubricToolDefinitions: ({
314
319
  maxProspects?: undefined;
315
320
  tableId?: undefined;
316
321
  targetCount?: undefined;
322
+ minPassedCount?: undefined;
317
323
  timeoutMs?: undefined;
318
324
  intervalMs?: undefined;
319
325
  includeRows?: undefined;
@@ -343,6 +349,7 @@ export declare const rubricToolDefinitions: ({
343
349
  maxProspects?: undefined;
344
350
  tableId?: undefined;
345
351
  targetCount?: undefined;
352
+ minPassedCount?: undefined;
346
353
  timeoutMs?: undefined;
347
354
  intervalMs?: undefined;
348
355
  includeRows?: undefined;
@@ -372,6 +379,7 @@ export declare const rubricToolDefinitions: ({
372
379
  checkName?: undefined;
373
380
  tableId?: undefined;
374
381
  targetCount?: undefined;
382
+ minPassedCount?: undefined;
375
383
  timeoutMs?: undefined;
376
384
  intervalMs?: undefined;
377
385
  includeRows?: undefined;
@@ -397,6 +405,10 @@ export declare const rubricToolDefinitions: ({
397
405
  type: string;
398
406
  description: string;
399
407
  };
408
+ minPassedCount: {
409
+ type: string;
410
+ description: string;
411
+ };
400
412
  timeoutMs: {
401
413
  type: string;
402
414
  description: string;
@@ -496,6 +508,7 @@ export declare function waitForRubricResults(input: WaitForRubricResultsInput):
496
508
  elapsedMs: number;
497
509
  tableId: string;
498
510
  passRate: {
511
+ minPassedCount?: number | undefined;
499
512
  completed: number;
500
513
  passed: number;
501
514
  percent: number;
@@ -507,7 +520,10 @@ export declare function waitForRubricResults(input: WaitForRubricResultsInput):
507
520
  diagnostic?: undefined;
508
521
  guidance?: undefined;
509
522
  } | {
523
+ stats: WorkflowTableStats;
524
+ rows?: import("./rows.js").LightweightRow[] | undefined;
510
525
  ready: boolean;
526
+ partial: boolean;
511
527
  reason: string;
512
528
  attempts: number;
513
529
  elapsedMs: number;
@@ -518,6 +534,7 @@ export declare function waitForRubricResults(input: WaitForRubricResultsInput):
518
534
  pending: number;
519
535
  percent: number;
520
536
  targetCount: number;
537
+ minPassedCount: number;
521
538
  };
522
539
  partialResult: {
523
540
  completed: number;
@@ -525,7 +542,35 @@ export declare function waitForRubricResults(input: WaitForRubricResultsInput):
525
542
  pending: number;
526
543
  percent: number;
527
544
  targetCount: number;
545
+ minPassedCount: number;
546
+ enoughToDiagnose: boolean;
547
+ floorMet: boolean;
548
+ };
549
+ diagnostic?: undefined;
550
+ guidance?: undefined;
551
+ } | {
552
+ ready: boolean;
553
+ reason: string;
554
+ attempts: number;
555
+ elapsedMs: number;
556
+ tableId: string;
557
+ passRate: {
558
+ minPassedCount?: number | undefined;
559
+ completed: number;
560
+ passed: number;
561
+ pending: number;
562
+ percent: number;
563
+ targetCount: number;
564
+ };
565
+ partialResult: {
528
566
  enoughToDiagnose: boolean;
567
+ floorMet: boolean;
568
+ minPassedCount?: number | undefined;
569
+ completed: number;
570
+ passed: number;
571
+ pending: number;
572
+ percent: number;
573
+ targetCount: number;
529
574
  };
530
575
  diagnostic: {
531
576
  totalRows: number;
@@ -103,6 +103,11 @@ function resolveMaxProspects(value) {
103
103
  const parsed = typeof value === "number" && !Number.isNaN(value) ? value : fallback;
104
104
  return Math.min(MAX_PROSPECTS_LIMIT, Math.max(1, Math.floor(parsed)));
105
105
  }
106
+ function resolveMinPassedCount(value) {
107
+ if (typeof value !== "number" || Number.isNaN(value))
108
+ return null;
109
+ return Math.min(MAX_PROSPECTS_LIMIT, Math.max(1, Math.floor(value)));
110
+ }
106
111
  function mergeRubricsWithExisting(draftRubrics, existingRubrics) {
107
112
  const existingByCheckName = new Map();
108
113
  existingRubrics.forEach((item) => {
@@ -348,6 +353,10 @@ export const rubricToolDefinitions = [
348
353
  type: "number",
349
354
  description: `Number of completed rubric results to wait for (default ${DEFAULT_MAX_PROSPECTS}).`,
350
355
  },
356
+ minPassedCount: {
357
+ type: "number",
358
+ description: "Optional pass floor for bounded create-campaign samples. When this floor is met and remaining target rows are resolved as failed/not processing, the tool returns ready:true with partial:true instead of forcing a timeout.",
359
+ },
351
360
  timeoutMs: {
352
361
  type: "number",
353
362
  description: `Max time to wait in ms (default ${DEFAULT_TIMEOUT_MS}).`,
@@ -625,6 +634,7 @@ export async function waitForRubricResults(input) {
625
634
  const timeoutMs = Math.max(5000, input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
626
635
  const intervalMs = Math.max(500, input.intervalMs ?? DEFAULT_INTERVAL_MS);
627
636
  const targetCount = resolveMaxProspects(input.targetCount);
637
+ const minPassedCount = resolveMinPassedCount(input.minPassedCount);
628
638
  const includeRows = input.includeRows !== false;
629
639
  let tableId = input.tableId;
630
640
  if (!tableId && input.campaignOfferId) {
@@ -642,10 +652,17 @@ export async function waitForRubricResults(input) {
642
652
  const stats = await api.get(`/api/v3/workflow-tables/${tableId}/stats`);
643
653
  lastStats = stats;
644
654
  const completed = stats.passRate?.completed ?? 0;
655
+ const passed = stats.passRate?.passed ?? 0;
645
656
  const totalRows = stats.totalRows ?? 0;
646
657
  const effectiveTarget = totalRows > 0 ? Math.min(targetCount, totalRows) : targetCount;
658
+ const pending = Math.max(effectiveTarget - completed, 0);
659
+ const failedCount = stats.failedCount ?? 0;
660
+ const processingCount = stats.processingCount ?? 0;
661
+ const needsEnrichCount = stats.needsEnrichCount ?? 0;
662
+ const minPassFloorMet = minPassedCount !== null && passed >= minPassedCount;
663
+ const unresolvedRowsResolvedAsFailures = pending > 0 && completed + failedCount >= effectiveTarget;
664
+ const noActiveProcessing = processingCount === 0 && needsEnrichCount === 0;
647
665
  if (completed >= effectiveTarget) {
648
- const passed = stats.passRate?.passed ?? 0;
649
666
  const percent = completed > 0 ? Math.round((passed / completed) * 100) : 0;
650
667
  const rowSnapshot = includeRows
651
668
  ? await getTableRowsMinimal(tableId, {
@@ -662,6 +679,45 @@ export async function waitForRubricResults(input) {
662
679
  passed,
663
680
  percent,
664
681
  targetCount: effectiveTarget,
682
+ ...(minPassedCount !== null ? { minPassedCount } : {}),
683
+ },
684
+ ...(rowSnapshot ? { rows: rowSnapshot.rows } : {}),
685
+ stats,
686
+ };
687
+ }
688
+ if (minPassFloorMet &&
689
+ unresolvedRowsResolvedAsFailures &&
690
+ noActiveProcessing) {
691
+ const percent = completed > 0 ? Math.round((passed / completed) * 100) : 0;
692
+ const rowSnapshot = includeRows
693
+ ? await getTableRowsMinimal(tableId, {
694
+ limit: effectiveTarget,
695
+ })
696
+ : null;
697
+ return {
698
+ ready: true,
699
+ partial: true,
700
+ reason: "min_passed_count_met_with_resolved_failures",
701
+ attempts,
702
+ elapsedMs: Date.now() - start,
703
+ tableId,
704
+ passRate: {
705
+ completed,
706
+ passed,
707
+ pending,
708
+ percent,
709
+ targetCount: effectiveTarget,
710
+ minPassedCount,
711
+ },
712
+ partialResult: {
713
+ completed,
714
+ passed,
715
+ pending,
716
+ percent,
717
+ targetCount: effectiveTarget,
718
+ minPassedCount,
719
+ enoughToDiagnose: true,
720
+ floorMet: true,
665
721
  },
666
722
  ...(rowSnapshot ? { rows: rowSnapshot.rows } : {}),
667
723
  stats,
@@ -687,6 +743,7 @@ export async function waitForRubricResults(input) {
687
743
  pending,
688
744
  percent,
689
745
  targetCount: effectiveTarget,
746
+ ...(minPassedCount !== null ? { minPassedCount } : {}),
690
747
  },
691
748
  partialResult: {
692
749
  completed,
@@ -694,7 +751,9 @@ export async function waitForRubricResults(input) {
694
751
  pending,
695
752
  percent,
696
753
  targetCount: effectiveTarget,
754
+ ...(minPassedCount !== null ? { minPassedCount } : {}),
697
755
  enoughToDiagnose: completed > 0,
756
+ floorMet: minPassedCount !== null && passed >= minPassedCount,
698
757
  },
699
758
  diagnostic: {
700
759
  totalRows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.104",
3
+ "version": "0.1.106",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -432,6 +432,13 @@ to be worth a LinkedIn test. I'll compare source paths by expected volume,
432
432
  sampled ICP fit, activity/warmth signal, cleanup risk, and tradeoffs. This
433
433
  usually takes ~3-5 min, and I'll show you the source decision + sample before
434
434
  anything goes live.`
435
+ - In watch mode, do not leave the user sitting on only `pick-provider` while
436
+ source scouts run. Move the campaign to the likely primary source lane
437
+ (`signal-discovery`, `sales-nav`, or `prospeo`) before background source
438
+ scouts, then run the first campaign-attached provider prompt + provider search
439
+ in the parent thread with `campaignOfferId`, `confirmed: true`, and
440
+ `currentStep` when the tool accepts it. Background scouts may continue after
441
+ the visible campaign tab exists.
435
442
  - After the lead sample/source decision is ready and approved,
436
443
  show the next progress line:
437
444
  `Lead source is set. I'll import the first 15-row review batch into the
@@ -542,6 +549,11 @@ message we should test.`
542
549
  then draft the message from the same sample.` Never say `kicking off two
543
550
  workstreams`, `in parallel`, or `background` unless parallel branches were
544
551
  actually launched.
552
+ - Source scout parallelism must not hide all provider work from the watch UI.
553
+ The parent thread owns the first visible provider search: call the chosen
554
+ provider prompt/search with the minted CampaignOffer id and provider
555
+ `currentStep` before waiting on source-scout results, so relevant search tabs
556
+ appear in real time.
545
557
  - For post-lead workstreams, first call
546
558
  `get_post_find_leads_scout_registry` and launch exactly the returned
547
559
  `filter-leads` and `message-generation` scouts when real subagents are
@@ -520,6 +520,33 @@
520
520
  "when": "before_source_scouts_or_provider_search",
521
521
  "chatRenderRule": "Move the campaign watch view to Find Contacts before the main thread starts comparing source paths. Do not mention MCP or local artifacts."
522
522
  },
523
+ {
524
+ "action": "advance_watch_to_initial_source_lane",
525
+ "tool": "update_campaign",
526
+ "requiredFields": [
527
+ "campaignId",
528
+ "currentStep"
529
+ ],
530
+ "currentStepByPrimaryLane": {
531
+ "signals": "signal-discovery",
532
+ "salesNav": "sales-nav",
533
+ "prospeo": "prospeo",
534
+ "apollo": "apollo-ai",
535
+ "existingList": "saved-lists",
536
+ "uploadedDomains": "prospeo"
537
+ },
538
+ "when": "before_background_source_scouts",
539
+ "rule": "Choose the likely primary visible source lane from source intake, brief preference, or the best first lane the main thread will actually search. Do this before waiting on background scouts so watch mode never sits on only Pick Provider while source work happens elsewhere."
540
+ },
541
+ {
542
+ "action": "run_first_campaign_attached_source_search",
543
+ "requiredFields": [
544
+ "campaignOfferId",
545
+ "currentStep"
546
+ ],
547
+ "before": "waiting_on_background_source_scouts",
548
+ "rule": "Before relying on background source scouts, the parent thread must run the first provider prompt + provider search for the chosen visible lane with campaignOfferId and currentStep (signal-discovery, sales-nav, or prospeo) so the campaign UI creates the corresponding live search/tab. Background scouts may continue after that, but the user must see at least one relevant campaign-attached search begin in the watched page."
549
+ },
523
550
  {
524
551
  "action": "render_find_leads_progress",
525
552
  "requiredVisibleContent": [
@@ -555,7 +582,7 @@
555
582
  "action": "run_subskill",
556
583
  "target": "find-leads",
557
584
  "mode": "campaign-attached-required",
558
- "sourceScoutRule": "Shell-first flow requires the CampaignOffer campaignId from durable state. Pass campaignId as campaignOfferId into every provider prompt/search that can persist source state (`get_provider_prompt({ provider, campaignOfferId, confirmed: true })`, `search_signals`, `search_sales_nav`, `search_prospeo`) so the user can watch the selected source inside the campaign. The later import_leads call must use the same campaignOfferId. When several source lanes are credible, scout them independently, then write one primary source recommendation and any runner-up tradeoffs. Do not import, confirm, enrich, queue, or start leads during source discovery."
585
+ "sourceScoutRule": "Shell-first flow requires the CampaignOffer campaignId from durable state. Pass campaignId as campaignOfferId into every provider prompt/search that can persist source state (`get_provider_prompt({ provider, campaignOfferId, confirmed: true })`, `search_signals`, `search_sales_nav`, `search_prospeo`) and include currentStep for tools that accept it so the user can watch the selected source inside the campaign. In watch mode, run the first campaign-attached provider search in the parent thread before waiting on background scouts; subagents can explore runner-up lanes after the visible campaign tab exists. The later import_leads call must use the same campaignOfferId. When several source lanes are credible, scout them independently, then write one primary source recommendation and any runner-up tradeoffs. Do not import, confirm, enrich, queue, or start leads during source discovery."
559
586
  },
560
587
  {
561
588
  "action": "optional_debug_artifacts",
@@ -1916,17 +1943,23 @@
1916
1943
  {
1917
1944
  "tool": "wait_for_rubric_results",
1918
1945
  "requiredFields": [
1919
- "targetCount"
1946
+ "targetCount",
1947
+ "minPassedCount"
1920
1948
  ],
1921
1949
  "targetCountSource": "stats.totalRows_or_imported_batch_count",
1950
+ "minPassedCountSource": "sample.minProjectedPass (3)",
1922
1951
  "requiredValues": {
1923
1952
  "includeRows": false
1924
1953
  },
1925
- "note": "The shell-first flow tests 15 leads first; always pass cohortSize explicitly instead of relying on default 25 behavior.",
1954
+ "note": "The shell-first flow tests 15 leads first; always pass cohortSize explicitly instead of relying on default 25 behavior. Pass minPassedCount so a 15-row batch with resolved failures can advance when the 3-pass floor is already met.",
1926
1955
  "readVia": "stats_only_tool_result",
1927
1956
  "extractFields": [
1928
1957
  "ready",
1958
+ "partial",
1959
+ "reason",
1929
1960
  "passRate.completed",
1961
+ "passRate.passed",
1962
+ "passRate.minPassedCount",
1930
1963
  "stats"
1931
1964
  ],
1932
1965
  "doNotRetain": "rows_payload",
@@ -224,6 +224,17 @@ campaignOfferId, confirmed: true })` -> `search_signals({ campaignOfferId,
224
224
  selectionMode: "replace", selections, headlineICPCriteria })` ->
225
225
  `import_leads({ campaignOfferId, provider: "signal-discovery",
226
226
  targetLeadCount: importLimit })`.
227
+ For Signal Discovery, the promotion/select step is load-bearing. Use
228
+ post IDs from the current campaign-scoped `search_signals` response or
229
+ posts the user has visibly promoted in the campaign UI. Never use post IDs
230
+ copied only from a source-scout summary unless they have been re-resolved
231
+ through the current campaign search state. After `select_promising_posts`,
232
+ require `selectedCount > 0` before calling `import_leads`. If it returns
233
+ `selectedCount: 0`, do not switch providers and do not retry import.
234
+ Explain that the campaign has no promoted Signal Discovery posts yet,
235
+ re-run a narrow campaign-scoped `search_signals` call to recover current
236
+ post rows, or ask the user to promote the desired posts in the UI and then
237
+ retry `import_leads`.
227
238
  Source approval is the explicit confirmation for this bounded review
228
239
  batch; do not ask for a second yes/no gate between
229
240
  `select_promising_posts` and `import_leads`.
@@ -275,11 +286,14 @@ Shape:
275
286
  queue_cells({ tableId: workflowTableId, cellIds: reviewBatchEnrichCellIds })
276
287
  wait_for_campaign_table_ready({ tableId: workflowTableId })
277
288
  get_rows_minimal({ tableId: workflowTableId })
278
- wait_for_rubric_results({ tableId: workflowTableId, targetCount: cohortSize, includeRows: false })
289
+ wait_for_rubric_results({ tableId: workflowTableId, targetCount: cohortSize, minPassedCount: minProjectedPass, includeRows: false })
279
290
  passInSample = count of first sampleSize review-batch rows with passesRubric === true
280
291
  projectedPass = round(passInSample / sampleSize * importLimit)
281
292
 
282
- if wait_for_rubric_results.ready === false and reason === "timeout":
293
+ if wait_for_rubric_results.ready === true and partial === true and reason === "min_passed_count_met_with_resolved_failures":
294
+ use the partial passRate/stats as a valid sample diagnostic
295
+ continue if projectedPass >= minProjectedPass and generated messages are present for the passing rows
296
+ else if wait_for_rubric_results.ready === false and reason === "timeout":
283
297
  use the partial passRate/stats as the sample diagnostic
284
298
  if active processing is visible:
285
299
  wait one more time at most
@@ -377,6 +377,14 @@ selections, headlineICPCriteria })`, then call
377
377
  targetLeadCount })` for the approved review batch without asking for another
378
378
  yes/no gate. Do not import more than the approved bounded batch.
379
379
 
380
+ The promotion/select step is required campaign state. Use post IDs from the
381
+ current campaign-scoped search result, not stale IDs copied from a source review
382
+ or scout summary. If `select_promising_posts` returns `selectedCount: 0`, stop:
383
+ do not call `import_leads`, do not switch providers automatically, and do not
384
+ claim the source failed. Re-run a narrow campaign-scoped search to recover
385
+ current post rows, or have the user promote the posts in the UI and then retry
386
+ the bounded import.
387
+
380
388
  ```json
381
389
  import_leads({
382
390
  "campaignOfferId": "cmp_xxx",