@sellable/mcp 0.1.907 → 0.1.908

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.
@@ -269,6 +269,48 @@ export declare function validateAgentSuppliedBroadenKeywords(rawKeywords: unknow
269
269
  ok: false;
270
270
  reason: string;
271
271
  };
272
+ type RefillV3SignalPostCandidate = {
273
+ postId: string;
274
+ postUrl: string;
275
+ totalEngagers: number;
276
+ recencyDays: number;
277
+ };
278
+ type RefillV3SignalPostEstimate = {
279
+ postId?: unknown;
280
+ sampleSize?: unknown;
281
+ passCount?: unknown;
282
+ fitPercentage?: unknown;
283
+ estimatedFits?: unknown;
284
+ };
285
+ export type RefillV3SignalPostSample = {
286
+ postId: string;
287
+ sampleSize: number;
288
+ passCount: number;
289
+ fitRate: number;
290
+ totalEngagers: number;
291
+ projectedFits: number;
292
+ qualifies: boolean;
293
+ };
294
+ export declare function selectRefillV3SignalPostsFromSamples(params: {
295
+ candidates: readonly RefillV3SignalPostCandidate[];
296
+ estimates: readonly RefillV3SignalPostEstimate[];
297
+ maxPosts: number;
298
+ }): {
299
+ samples: RefillV3SignalPostSample[];
300
+ selectedPostIds: string[];
301
+ yieldEvidence: {
302
+ family: "signal_discovery";
303
+ evaluated: number;
304
+ passes: number;
305
+ };
306
+ qualityGateEvidence: {
307
+ family: "signal_discovery";
308
+ evaluated: number;
309
+ passes: number;
310
+ };
311
+ engagerSum: number;
312
+ projectedYield: number;
313
+ };
272
314
  export declare function recentBroadenablePostIdsFromSignalSearch(summary: Awaited<ReturnType<typeof searchSignals>>, options: {
273
315
  maxAgeDays: number;
274
316
  excludePostIds?: string[];
@@ -6,6 +6,7 @@ import { startPrepareCampaignMessages } from "./campaign-message-preparation.js"
6
6
  import { normalizeMessageTemplateRevision, normalizeRefillRunHandle, normalizeRefillTargetConfig, reviseMessageTemplateAndRerun, } from "./campaign-processing.js";
7
7
  import { startCampaign } from "./campaigns.js";
8
8
  import { confirmLeadList, importLeads, searchProspeo, searchSalesNav, searchSignals, selectPromisingPosts, } from "./leads.js";
9
+ import { REFILL_V3_SIGNAL_POST_MIN_ICP_RATE, REFILL_V3_SIGNAL_POST_MIN_SAMPLE_SIZE, REFILL_V3_SIGNAL_POST_SAMPLE_CANDIDATE_CAP, } from "./refill-v3-attention-packet-contract.js";
9
10
  import { refreshSenderEngagementCommand } from "./refresh-sender-engagement.js";
10
11
  import { runSchedulerSweep, } from "./scheduler-run.js";
11
12
  import { refreshPaidInmailCredits } from "./senders.js";
@@ -132,10 +133,26 @@ export function normalizeRefillV3SourceOwnerReceipt(params) {
132
133
  const signalKeywords = stringArray(rawRecord.keywordsTried);
133
134
  const signalSelectedPostIds = stringArray(rawRecord.selectedPostIds);
134
135
  const signalExcludedPostIds = stringArray(rawRecord.excludedPostIds);
136
+ const signalImportResult = recordValue(rawRecord.importResult);
137
+ const signalImportJobId = stringValue(signalImportResult?.jobId);
138
+ if (isSignalOwner &&
139
+ rawRecord.rowsGainedPending === true &&
140
+ signalImportJobId) {
141
+ // Signal import is asynchronous. Starting its scrape is not a zero-row
142
+ // terminal: return a retained effect identity so the coordinator reobserves
143
+ // the live source job instead of recording a dry round and spending the
144
+ // keyword avenue before any rows can arrive.
145
+ return {
146
+ kind: "pending",
147
+ effectIdentity: signalImportJobId,
148
+ detail: `Signal import ${signalImportJobId} started and rows are pending; reobserve the source job before classifying yield.`,
149
+ };
150
+ }
135
151
  const signalRowsGained = Math.max(0, numberValue(rawRecord.rowsGained) ?? 0);
136
152
  const signalProjection = recordValue(rawRecord.yieldProjection);
137
- const signalEvaluated = Math.max(0, Math.floor(numberValue(signalProjection?.engagerSum) ?? 0));
138
- const signalPasses = Math.min(signalEvaluated, Math.max(0, Math.floor(numberValue(signalProjection?.projectedYield) ?? 0)));
153
+ const signalYieldRecord = recordValue(rawRecord.yieldEvidence);
154
+ const signalYieldEvaluated = Math.max(0, Math.floor(numberValue(signalYieldRecord?.evaluated) ?? 0));
155
+ const signalYieldPasses = Math.min(signalYieldEvaluated, Math.max(0, Math.floor(numberValue(signalYieldRecord?.passes) ?? 0)));
139
156
  const synthesizedBelowFloorRecord = signalBelowFloor && signalCampaignId && priorSourceFingerprint
140
157
  ? {
141
158
  ...rawRecord,
@@ -156,6 +173,10 @@ export function normalizeRefillV3SourceOwnerReceipt(params) {
156
173
  importedCount: 0,
157
174
  effects: [
158
175
  "signal_yield_below_floor",
176
+ ...(stringValue(rawRecord.failureCode) ===
177
+ "signal_post_icp_below_floor"
178
+ ? ["signal_post_icp_below_floor"]
179
+ : []),
159
180
  `keywords_searched:${signalKeywords.length}`,
160
181
  `candidate_posts:${Math.max(0, numberValue(signalProjection?.candidatePostCount) ?? 0)}`,
161
182
  `projected_yield:${Math.max(0, numberValue(signalProjection?.projectedYield) ?? 0)}`,
@@ -234,11 +255,14 @@ export function normalizeRefillV3SourceOwnerReceipt(params) {
234
255
  }
235
256
  : { available: false, reason: "not_applicable" },
236
257
  postRationales: null,
237
- yieldEvidence: isSignalOwner && signalEvaluated > 0
258
+ // `yieldProjection` is volume planning (`engagers * assumed pass rate`),
259
+ // not observed candidate qualification. The dispatcher attaches actual
260
+ // candidate verdict evidence when it exists.
261
+ yieldEvidence: isSignalOwner && signalYieldEvaluated > 0
238
262
  ? {
239
263
  family: "signal_discovery",
240
- evaluated: signalEvaluated,
241
- passes: signalPasses,
264
+ evaluated: signalYieldEvaluated,
265
+ passes: signalYieldPasses,
242
266
  }
243
267
  : null,
244
268
  };
@@ -950,6 +974,90 @@ export function validateAgentSuppliedBroadenKeywords(rawKeywords, negativeList)
950
974
  }
951
975
  return { ok: true, keywords: freshKeywords };
952
976
  }
977
+ function refillV3SignalPostCandidates(summary, options) {
978
+ const excluded = new Set((options.excludePostIds ?? []).map((postId) => postId.toLowerCase()));
979
+ const topPosts = Array.isArray(summary?.topPosts)
980
+ ? summary.topPosts
981
+ : [];
982
+ const seen = new Set();
983
+ const candidates = [];
984
+ for (const post of topPosts) {
985
+ const postId = stringValue(post.id);
986
+ const postUrl = stringValue(post.url);
987
+ const recencyDays = numberValue(post.recencyDays);
988
+ if (!postId ||
989
+ !postUrl ||
990
+ recencyDays === null ||
991
+ recencyDays > options.maxAgeDays ||
992
+ excluded.has(postId.toLowerCase()) ||
993
+ seen.has(postId.toLowerCase())) {
994
+ continue;
995
+ }
996
+ const totalEngagers = scrapablePostEngagers(post);
997
+ if (totalEngagers < 1)
998
+ continue;
999
+ seen.add(postId.toLowerCase());
1000
+ candidates.push({ postId, postUrl, totalEngagers, recencyDays });
1001
+ if (candidates.length >= REFILL_V3_SIGNAL_POST_SAMPLE_CANDIDATE_CAP)
1002
+ break;
1003
+ }
1004
+ return candidates;
1005
+ }
1006
+ export function selectRefillV3SignalPostsFromSamples(params) {
1007
+ const estimatesByPost = new Map(params.estimates
1008
+ .map((estimate) => [stringValue(estimate.postId), estimate])
1009
+ .filter((entry) => Boolean(entry[0])));
1010
+ const samples = params.candidates.flatMap((candidate) => {
1011
+ const estimate = estimatesByPost.get(candidate.postId);
1012
+ const sampleSize = Math.max(0, Math.floor(numberValue(estimate?.sampleSize) ?? 0));
1013
+ const passCount = Math.min(sampleSize, Math.max(0, Math.floor(numberValue(estimate?.passCount) ?? 0)));
1014
+ if (sampleSize === 0)
1015
+ return [];
1016
+ const fitRate = passCount / sampleSize;
1017
+ return [
1018
+ {
1019
+ postId: candidate.postId,
1020
+ sampleSize,
1021
+ passCount,
1022
+ fitRate,
1023
+ totalEngagers: candidate.totalEngagers,
1024
+ projectedFits: Math.floor(candidate.totalEngagers * fitRate),
1025
+ qualifies: sampleSize >= REFILL_V3_SIGNAL_POST_MIN_SAMPLE_SIZE &&
1026
+ fitRate >= REFILL_V3_SIGNAL_POST_MIN_ICP_RATE,
1027
+ },
1028
+ ];
1029
+ });
1030
+ const candidateById = new Map(params.candidates.map((candidate) => [candidate.postId, candidate]));
1031
+ const selected = samples
1032
+ .filter((sample) => sample.qualifies)
1033
+ .sort((left, right) => {
1034
+ if (right.fitRate !== left.fitRate)
1035
+ return right.fitRate - left.fitRate;
1036
+ if (right.totalEngagers !== left.totalEngagers) {
1037
+ return right.totalEngagers - left.totalEngagers;
1038
+ }
1039
+ return ((candidateById.get(left.postId)?.recencyDays ?? Infinity) -
1040
+ (candidateById.get(right.postId)?.recencyDays ?? Infinity));
1041
+ })
1042
+ .slice(0, Math.max(1, params.maxPosts));
1043
+ const sampleSizeEligible = samples.filter((sample) => sample.sampleSize >= REFILL_V3_SIGNAL_POST_MIN_SAMPLE_SIZE);
1044
+ return {
1045
+ samples,
1046
+ selectedPostIds: selected.map((sample) => sample.postId),
1047
+ yieldEvidence: {
1048
+ family: "signal_discovery",
1049
+ evaluated: selected.reduce((sum, sample) => sum + sample.sampleSize, 0),
1050
+ passes: selected.reduce((sum, sample) => sum + sample.passCount, 0),
1051
+ },
1052
+ qualityGateEvidence: {
1053
+ family: "signal_discovery",
1054
+ evaluated: sampleSizeEligible.reduce((sum, sample) => sum + sample.sampleSize, 0),
1055
+ passes: sampleSizeEligible.reduce((sum, sample) => sum + sample.passCount, 0),
1056
+ },
1057
+ engagerSum: selected.reduce((sum, sample) => sum + sample.totalEngagers, 0),
1058
+ projectedYield: selected.reduce((sum, sample) => sum + sample.projectedFits, 0),
1059
+ };
1060
+ }
953
1061
  // feat(110-23c): the broadenable NEW-post frontier. A post counts only when its
954
1062
  // engagement recency is inside the 45-day window (unknown recency is treated as
955
1063
  // out-of-window), it carries meaningful engagement (>=1 commenter or reactor), and
@@ -1741,6 +1849,8 @@ export async function broadenSignalSearch(action, workspaceId) {
1741
1849
  let newPostIds;
1742
1850
  let carriedRemainder;
1743
1851
  let yieldProjectionReceipt = null;
1852
+ let postYieldSamples = null;
1853
+ let observedYieldEvidence = null;
1744
1854
  if (carriedRound) {
1745
1855
  // Consume the carried frontier first: these posts were already found inside
1746
1856
  // the 45-day window by a prior round whose projection cleared the floor.
@@ -1801,21 +1911,67 @@ export async function broadenSignalSearch(action, workspaceId) {
1801
1911
  },
1802
1912
  };
1803
1913
  }
1804
- // feat(110-23d, OWNER REFINEMENT #11): project the round's new-lead yield instead
1805
- // of a fuzzy per-post meaningful-engagement test. Sum the scrapable engager counts
1806
- // across the NEW candidate posts (inside the 45-day window, not already scraped) and
1807
- // multiply by the campaign's observed headline-ICP pass rate. When the projection
1808
- // falls below the floor min(remainingPrepGap, 100) — zero new posts being the
1809
- // projection=0 special case — terminalize typed (signal_yield_below_floor) with the
1810
- // projection receipt so the lane chain advances; otherwise scrape proceeds.
1811
- const yieldProjection = projectSignalBroadenYield(searchSummary, {
1812
- maxAgeDays,
1813
- excludePostIds: excludedPostIds,
1814
- observedPassRate,
1815
- remainingPrepGap,
1816
- maxPosts: maxPostsToScrape,
1817
- });
1818
- if (yieldProjection.projectedYield < yieldProjection.floor) {
1914
+ const yieldProjection = isRefillV3SinglePassAction(action)
1915
+ ? await (async () => {
1916
+ const candidates = refillV3SignalPostCandidates(searchSummary, {
1917
+ maxAgeDays,
1918
+ excludePostIds: excludedPostIds,
1919
+ });
1920
+ if (candidates.length === 0) {
1921
+ return {
1922
+ candidatePostCount: 0,
1923
+ engagerSum: 0,
1924
+ observedPassRate: 0,
1925
+ projectedYield: 0,
1926
+ floor: Math.min(SIGNAL_YIELD_FLOOR_CAP, Math.max(1, remainingPrepGap)),
1927
+ scrapePostIds: [],
1928
+ carriedPostIds: [],
1929
+ };
1930
+ }
1931
+ const estimateResponse = await api.post("/api/v1/signal-discovery/estimate-icp-fits", {
1932
+ posts: candidates.map((candidate) => ({
1933
+ id: candidate.postId,
1934
+ url: candidate.postUrl,
1935
+ totalEngagers: candidate.totalEngagers,
1936
+ })),
1937
+ campaignOffer: {
1938
+ id: campaignOfferId,
1939
+ name: campaign.name ?? "",
1940
+ },
1941
+ }, requestOptions);
1942
+ const sampled = selectRefillV3SignalPostsFromSamples({
1943
+ candidates,
1944
+ estimates: estimateResponse.estimates ?? [],
1945
+ maxPosts: scrapeCap,
1946
+ });
1947
+ postYieldSamples = sampled.samples;
1948
+ observedYieldEvidence =
1949
+ sampled.selectedPostIds.length > 0
1950
+ ? sampled.yieldEvidence
1951
+ : sampled.qualityGateEvidence;
1952
+ const evaluated = observedYieldEvidence.evaluated;
1953
+ return {
1954
+ candidatePostCount: candidates.length,
1955
+ engagerSum: sampled.engagerSum,
1956
+ observedPassRate: evaluated > 0 ? observedYieldEvidence.passes / evaluated : 0,
1957
+ projectedYield: sampled.projectedYield,
1958
+ floor: Math.min(SIGNAL_YIELD_FLOOR_CAP, Math.max(1, remainingPrepGap)),
1959
+ scrapePostIds: sampled.selectedPostIds,
1960
+ carriedPostIds: [],
1961
+ };
1962
+ })()
1963
+ : projectSignalBroadenYield(searchSummary, {
1964
+ maxAgeDays,
1965
+ excludePostIds: excludedPostIds,
1966
+ observedPassRate,
1967
+ remainingPrepGap,
1968
+ maxPosts: maxPostsToScrape,
1969
+ });
1970
+ const noQualifyingV3Post = isRefillV3SinglePassAction(action) &&
1971
+ yieldProjection.scrapePostIds.length === 0;
1972
+ if (noQualifyingV3Post ||
1973
+ (!isRefillV3SinglePassAction(action) &&
1974
+ yieldProjection.projectedYield < yieldProjection.floor)) {
1819
1975
  const runScopedCleanup = await cleanupRunScopedDerivedTabs();
1820
1976
  // fix(110-23e): the yield floor was evaluated on the ACTUAL candidates this
1821
1977
  // search round returned (zero candidates being the projection=0 special case).
@@ -1826,7 +1982,9 @@ export async function broadenSignalSearch(action, workspaceId) {
1826
1982
  // terminalizing the lane with the agent tier untried.
1827
1983
  return {
1828
1984
  status: "refused",
1829
- refusalReason: `signal_yield_below_floor: projected new-lead yield ${yieldProjection.projectedYield} (${yieldProjection.engagerSum} scrapable engagers x ${yieldProjection.observedPassRate} observed headline-ICP pass rate across ${yieldProjection.candidatePostCount} NEW candidate posts) is below the floor ${yieldProjection.floor} (min(remaining prep gap, 100))${effectiveKeywordSource === "agent_supplied" ? "; the 45-day relevant Signal universe cannot fill the remaining gap" : "; escalate to the next keyword-source tier"}`,
1985
+ refusalReason: noQualifyingV3Post
1986
+ ? `signal_yield_below_floor: signal_post_icp_below_floor: none of ${yieldProjection.candidatePostCount} sampled recent posts had at least ${REFILL_V3_SIGNAL_POST_MIN_SAMPLE_SIZE} evaluated engagers and a ${REFILL_V3_SIGNAL_POST_MIN_ICP_RATE * 100}% observed headline-ICP pass rate`
1987
+ : `signal_yield_below_floor: projected new-lead yield ${yieldProjection.projectedYield} (${yieldProjection.engagerSum} scrapable engagers x ${yieldProjection.observedPassRate} observed headline-ICP pass rate across ${yieldProjection.candidatePostCount} NEW candidate posts) is below the floor ${yieldProjection.floor} (min(remaining prep gap, 100))${effectiveKeywordSource === "agent_supplied" ? "; the 45-day relevant Signal universe cannot fill the remaining gap" : "; escalate to the next keyword-source tier"}`,
1830
1988
  result: {
1831
1989
  campaignOfferId,
1832
1990
  keywordSource: effectiveKeywordSource,
@@ -1849,6 +2007,13 @@ export async function broadenSignalSearch(action, workspaceId) {
1849
2007
  projectedYield: yieldProjection.projectedYield,
1850
2008
  floor: yieldProjection.floor,
1851
2009
  },
2010
+ ...(postYieldSamples ? { postYieldSamples } : {}),
2011
+ ...(observedYieldEvidence
2012
+ ? { yieldEvidence: observedYieldEvidence }
2013
+ : {}),
2014
+ ...(noQualifyingV3Post
2015
+ ? { failureCode: "signal_post_icp_below_floor" }
2016
+ : {}),
1852
2017
  newPostCount: 0,
1853
2018
  maxAgeDays,
1854
2019
  universeExhausted: effectiveKeywordSource === "agent_supplied",
@@ -1873,7 +2038,10 @@ export async function broadenSignalSearch(action, workspaceId) {
1873
2038
  campaignOfferId,
1874
2039
  selections: newPostIds.map((postId) => ({
1875
2040
  postId,
1876
- reason: "refill_sends signal broadening: NEW recent post (<=45 days) with meaningful engagement, derived from fresh keywords within the campaign's existing search intent",
2041
+ reason: postYieldSamples
2042
+ ? `refill_sends Signal post sample: ${Math.round((postYieldSamples.find((sample) => sample.postId === postId)
2043
+ ?.fitRate ?? 0) * 1000) / 10}% observed headline-ICP fit (minimum ${REFILL_V3_SIGNAL_POST_MIN_ICP_RATE * 100}%)`
2044
+ : "refill_sends signal broadening: NEW recent post (<=45 days) with meaningful engagement, derived from fresh keywords within the campaign's existing search intent",
1877
2045
  })),
1878
2046
  headlineICPCriteria,
1879
2047
  currentStep: null,
@@ -1972,6 +2140,10 @@ export async function broadenSignalSearch(action, workspaceId) {
1972
2140
  ...(yieldProjectionReceipt
1973
2141
  ? { yieldProjection: yieldProjectionReceipt }
1974
2142
  : {}),
2143
+ ...(postYieldSamples ? { postYieldSamples } : {}),
2144
+ ...(observedYieldEvidence
2145
+ ? { yieldEvidence: observedYieldEvidence }
2146
+ : {}),
1975
2147
  // The run-scoped progress gate reads newPostCount: a productive round keeps
1976
2148
  // the rung re-emittable; a zero round would have terminalized above.
1977
2149
  newPostCount: newPostIds.length,
@@ -171,6 +171,14 @@ export declare const REFILL_V3_DIAGNOSTIC_SAMPLE_CAP = 10;
171
171
  export declare const REFILL_V3_MIN_SOURCE_YIELD = 0.08;
172
172
  /** The acceptance sample is a fixed number of EVALUATED candidates. */
173
173
  export declare const REFILL_V3_MIN_SOURCE_YIELD_SAMPLE = 20;
174
+ /**
175
+ * Signal Discovery has a stricter post-level gate than the shared source
176
+ * receipt floor: each individual post must clear 10% on a real sample before
177
+ * that post may be selected for scraping.
178
+ */
179
+ export declare const REFILL_V3_SIGNAL_POST_MIN_ICP_RATE = 0.1;
180
+ export declare const REFILL_V3_SIGNAL_POST_MIN_SAMPLE_SIZE = 20;
181
+ export declare const REFILL_V3_SIGNAL_POST_SAMPLE_CANDIDATE_CAP = 5;
174
182
  /** `post_engager` is excluded: it keeps posts-refresh preconditions, not a rate. */
175
183
  export declare const REFILL_V3_YIELD_FLOOR_FAMILIES: readonly ["signal_discovery", "cold"];
176
184
  export type RefillV3YieldFloorFamily = (typeof REFILL_V3_YIELD_FLOOR_FAMILIES)[number];
@@ -196,6 +196,14 @@ export const REFILL_V3_DIAGNOSTIC_SAMPLE_CAP = 10;
196
196
  export const REFILL_V3_MIN_SOURCE_YIELD = 0.08;
197
197
  /** The acceptance sample is a fixed number of EVALUATED candidates. */
198
198
  export const REFILL_V3_MIN_SOURCE_YIELD_SAMPLE = 20;
199
+ /**
200
+ * Signal Discovery has a stricter post-level gate than the shared source
201
+ * receipt floor: each individual post must clear 10% on a real sample before
202
+ * that post may be selected for scraping.
203
+ */
204
+ export const REFILL_V3_SIGNAL_POST_MIN_ICP_RATE = 0.1;
205
+ export const REFILL_V3_SIGNAL_POST_MIN_SAMPLE_SIZE = 20;
206
+ export const REFILL_V3_SIGNAL_POST_SAMPLE_CANDIDATE_CAP = 5;
199
207
  /** `post_engager` is excluded: it keeps posts-refresh preconditions, not a rate. */
200
208
  export const REFILL_V3_YIELD_FLOOR_FAMILIES = [
201
209
  "signal_discovery",
@@ -143,6 +143,11 @@ export type RefillV3SourceOwnerOutcome = {
143
143
  } | {
144
144
  kind: "no_effect";
145
145
  receipt: RefillV3SourceOwnerReceipt;
146
+ } | {
147
+ /** The source mutation started an asynchronous import that has not settled. */
148
+ kind: "pending";
149
+ effectIdentity: string;
150
+ detail: string;
146
151
  } | {
147
152
  /** A closed provider-source business terminal, never infrastructure. */
148
153
  kind: "source_terminal";
@@ -21,7 +21,7 @@
21
21
  * fingerprint-identified, and carries an explicit typed RELEASE CONDITION, so a
22
22
  * bounded attempt can never harden into a standing claim.
23
23
  */
24
- import { evaluateRefillV3SourceYield, REFILL_V3_EXHAUSTION_PREDICATE, REFILL_V3_LEAD_SOURCE_FAMILIES, REFILL_V3_MIN_SOURCE_YIELD, REFILL_V3_MIN_SOURCE_YIELD_SAMPLE, } from "./refill-v3-attention-packet-contract.js";
24
+ import { evaluateRefillV3SourceYield, REFILL_V3_EXHAUSTION_PREDICATE, REFILL_V3_LEAD_SOURCE_FAMILIES, REFILL_V3_MIN_SOURCE_YIELD, REFILL_V3_MIN_SOURCE_YIELD_SAMPLE, REFILL_V3_SIGNAL_POST_MIN_ICP_RATE, } from "./refill-v3-attention-packet-contract.js";
25
25
  import { REFILL_V3_POSTS_KEYWORD_BATCH_SIZE, REFILL_V3_POSTS_MAX_SELECTED, REFILL_V3_POSTS_ROUND_BUDGET, } from "./refill-v3-campaign-work.js";
26
26
  // ───────────────────────────────────────────────────────────────────────────
27
27
  // The constant owner table
@@ -450,26 +450,62 @@ export async function dispatchRefillV3SourceFamily(input) {
450
450
  reobserveRequired: true,
451
451
  };
452
452
  }
453
- const receipt = outcome.receipt;
454
- if (!isNonEmpty(receipt.sourceBeforeFingerprint) ||
455
- !isNonEmpty(receipt.sourceAfterFingerprint) ||
456
- !isNonEmpty(receipt.requestFingerprint) ||
457
- !isNonEmpty(receipt.idempotencyKey)) {
453
+ if (outcome.kind === "pending") {
454
+ return {
455
+ kind: "scoped_failure",
456
+ family: command.family,
457
+ failureClass: "uncertain",
458
+ retryable: true,
459
+ effectIdentity: outcome.effectIdentity,
460
+ detail: `${ownerKey}: ${outcome.detail}`,
461
+ exhausted: false,
462
+ reobserveRequired: true,
463
+ };
464
+ }
465
+ const ownerReceipt = outcome.receipt;
466
+ if (!isNonEmpty(ownerReceipt.sourceBeforeFingerprint) ||
467
+ !isNonEmpty(ownerReceipt.sourceAfterFingerprint) ||
468
+ !isNonEmpty(ownerReceipt.requestFingerprint) ||
469
+ !isNonEmpty(ownerReceipt.idempotencyKey)) {
458
470
  return refused("receipt_incomplete", `${ownerKey} returned a receipt without complete source before/after and request evidence; uncertain evidence is never success.`);
459
471
  }
460
472
  // 14. The AI's recorded per-candidate verdicts feed the SHARED floor. The
461
473
  // arithmetic, the comparison, and the routing are deterministic; there is
462
474
  // no deterministic candidate judge.
463
475
  const verdicts = input.candidateVerdicts ?? null;
464
- const yieldVerdict = verdicts && verdicts.length > 0
465
- ? evaluateRefillV3DispatchYield({
466
- family: command.family,
467
- evaluated: verdicts.length,
468
- passes: verdicts.filter((entry) => entry.verdict === "pass").length,
469
- })
470
- : receipt.yieldEvidence
471
- ? evaluateRefillV3DispatchYield(receipt.yieldEvidence)
472
- : null;
476
+ const auditedYieldEvidence = command.family === "signal_discovery" && ownerReceipt.yieldEvidence
477
+ ? ownerReceipt.yieldEvidence
478
+ : verdicts && verdicts.length > 0
479
+ ? {
480
+ family: command.family,
481
+ evaluated: verdicts.length,
482
+ passes: verdicts.filter((entry) => entry.verdict === "pass").length,
483
+ }
484
+ : ownerReceipt.yieldEvidence;
485
+ // Signal's owner-level `yieldProjection` is volume planning based on an
486
+ // assumed pass rate. It is never a sampled qualification result. The owner
487
+ // receipt now exposes only real post-level sample evidence, which is
488
+ // authoritative over an agent's pre-search candidate judgement.
489
+ const receipt = {
490
+ ...ownerReceipt,
491
+ yieldEvidence: auditedYieldEvidence,
492
+ };
493
+ const signalPostFloorRejected = ownerReceipt.effects.includes("signal_post_icp_below_floor");
494
+ const yieldVerdict = auditedYieldEvidence
495
+ ? signalPostFloorRejected
496
+ ? auditedYieldEvidence.evaluated < REFILL_V3_MIN_SOURCE_YIELD_SAMPLE
497
+ ? {
498
+ decision: "sample_incomplete",
499
+ evaluated: auditedYieldEvidence.evaluated,
500
+ required: REFILL_V3_MIN_SOURCE_YIELD_SAMPLE,
501
+ }
502
+ : {
503
+ decision: "below_floor",
504
+ ratio: auditedYieldEvidence.passes / auditedYieldEvidence.evaluated,
505
+ floor: REFILL_V3_SIGNAL_POST_MIN_ICP_RATE,
506
+ }
507
+ : evaluateRefillV3DispatchYield(auditedYieldEvidence)
508
+ : null;
473
509
  // A round that MEETS the floor is a qualifying source: import and stop
474
510
  // broadening, so its fingerprint is not a refusal for later rounds. Every
475
511
  // other completed round registers so it cannot repeat unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.907",
3
+ "version": "0.1.908",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code, Codex, and Hermes campaign workflows",
6
6
  "main": "dist/index.js",
@@ -136,8 +136,8 @@ You must estimate:
136
136
  floor is a viability reject threshold, not the denominator when the sample
137
137
  rate is higher.
138
138
  - `planningFloor`
139
- - minimum acceptable sampled/projected headline-fit rate; default 10%.
140
- Below this, do not scale Signal Discovery.
139
+ - minimum acceptable observed headline-fit rate for each sampled post;
140
+ default 10%. Below this, do not scrape that post.
141
141
  - `projectedRange`
142
142
  - the conservative range implied by the observed sample
143
143
 
@@ -164,9 +164,11 @@ Use conservative logic:
164
164
  - preferred: "`sampledCount`: 40, `passCount`: 9, `passRate`: ~22%, `goodFitPer100Engagers`: ~22 headline-fit prospects, `requiredEngagersToScrape`: ~1,365 raw engagers for a 300 headline-fit target before a practical coverage buffer, `avgReachableEngagersPerPost`: 240, `goodFitProspectsPerPost`: ~53, `postsNeededForTarget`: ~6, `recentStrongPostCount`: 15-25, `freshEnoughPostCount`: 8-12, `projectedRange`: 300-380 from selected posts"
165
165
  - fallback: "I could not fetch engagers, so this is inferred from post and author quality only"
166
166
  5. If the evidence is too weak, say so and return a low-confidence estimate instead of pretending precision.
167
- 6. If sampled/projected headline-fit rate is below the 10% planning floor,
168
- reject the Signal Discovery scrape path and recommend Sales Nav recent
169
- activity as the next source.
167
+ 6. Decide per post. Keep only posts whose own real engager sample clears the
168
+ 10% planning floor; never pool all candidate posts or substitute a default
169
+ planning rate for an observed sample. If no sampled post clears 10%, reject
170
+ this Signal Discovery round and recommend Sales Nav recent activity as the
171
+ next source.
170
172
  7. Do not manually enumerate dozens of engagers in the final answer. Summarize the sample by `sampledCount`, `passCount`, `passRate`, `projectedRange`, and 3-6 representative examples.
171
173
 
172
174
  </yield_estimation>
@@ -448,7 +450,8 @@ recommendation, estimate source capacity from real sample math:
448
450
  - average reachable engagers per right-content post
449
451
  - expected headline-fit leads per selected post
450
452
  - posts needed to reach the target
451
- - whether sampled/projected headline-fit rate clears the 10% planning floor
453
+ - each selected post's sampled headline-fit rate and whether it clears the 10%
454
+ planning floor
452
455
 
453
456
  Then select the smallest right-content post set that plausibly hits the source
454
457
  target. Do not scrape every promoted sample post by default; promoted sampling
@@ -457,8 +460,8 @@ state and final scrape plan are separate. If one 1,200+ engager post clears a
457
460
  sample set. If the math says the warm post lane only supports a smaller first
458
461
  batch, say that and name the Sales Nav or Prospeo scale fallback rather than
459
462
  padding the selection with noisy posts. If the
460
- sampled/projected headline-fit rate is below 10%, do not call
461
- `import_leads`; move to Sales Nav recent activity.
463
+ sampled headline-fit rate for a post is below 10%, exclude that post from
464
+ `import_leads`. If no sampled post clears 10%, move to Sales Nav recent activity.
462
465
 
463
466
  ```json
464
467
  select_promising_posts({
@@ -285,10 +285,10 @@ For a signal-discovery posts search:
285
285
  receipt's `remainingCorrectionAttempts: 1` is consumed by that one revised
286
286
  decision; a second collision is terminal and must never loop.
287
287
  - Apply both quality gates from the Signal provider contract: recent relevant
288
- post quality first, then a 10% minimum sampled/projected headline-ICP pass
289
- rate. The deterministic capacity check uses that pass rate against the
290
- exact remaining refill gap; it does not require an arbitrary 100 qualified
291
- leads when only a smaller gap remains.
288
+ post quality first, then a 10% minimum observed headline-ICP pass rate on a
289
+ real engager sample for each specific post. Never pool candidate-post
290
+ engagement, apply a default rate, or relabel projected volume as observed
291
+ qualification. Import only the individual posts that clear 10%.
292
292
  - select AT MOST 5 candidate posts from that round's results;
293
293
  - pick the BEST posts in this order: (1) expected ICP fit of the post's engagers,
294
294
  (2) engagement volume high enough that scraping is efficient — qualified