@sellable/mcp 0.1.600 → 0.1.602-phase110.20260717134543

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.
@@ -291,7 +291,20 @@ function sanitizeSchedulerRunReceipt(raw) {
291
291
  prefilterReasons,
292
292
  prefilterReasonsByType,
293
293
  skippedReasons,
294
+ // CAP110-21: surface the raw skip/defer gate strings so the operator sees
295
+ // the actual reason a considered cell was rejected instead of only the
296
+ // normalized "other" bucket.
297
+ ...(rawReceipt.skippedDetailedReasons
298
+ ? {
299
+ skippedDetailedReasons: schedulerRunCountMap(rawReceipt.skippedDetailedReasons),
300
+ }
301
+ : {}),
294
302
  deferredReasons,
303
+ ...(rawReceipt.deferredDetailedReasons
304
+ ? {
305
+ deferredDetailedReasons: schedulerRunCountMap(rawReceipt.deferredDetailedReasons),
306
+ }
307
+ : {}),
295
308
  skipReasons,
296
309
  campaignScopeSummary: schedulerRunCampaignScopeSummary(rawReceipt.campaignScopeSummary),
297
310
  ...(stringValue(rawReceipt.summary)
@@ -489,6 +502,104 @@ function runScopedSignalDiscoveryAttemptUpdate(action, runState, now) {
489
502
  ],
490
503
  };
491
504
  }
505
+ // Phase 110-05: Sales Nav / Prospeo same-source continuation gets exactly one
506
+ // bounded add per (campaign, sender) per run, mirroring the Signal Discovery
507
+ // run-scoped attempt bound so the loop never repeats an unchanged failed
508
+ // same-source mutation.
509
+ const PROVIDER_SOURCE_CONTINUATION_TYPES = new Set([
510
+ "continue_sales_nav_source",
511
+ "continue_prospeo_source",
512
+ ]);
513
+ function hasRunScopedProviderSourceAttempt(action, runState) {
514
+ const campaignId = actionCampaignId(action);
515
+ const senderId = actionSenderId(action);
516
+ if (!campaignId)
517
+ return false;
518
+ return arrayValue(runState.providerSourceAttempts)
519
+ .filter(isRecord)
520
+ .some((attempt) => stringValue(attempt.campaignId) === campaignId &&
521
+ stringValue(attempt.senderId) === senderId);
522
+ }
523
+ function runScopedProviderSourceAttemptUpdate(action, runState, now) {
524
+ const campaignId = actionCampaignId(action);
525
+ const senderId = actionSenderId(action);
526
+ if (!campaignId)
527
+ return {};
528
+ const attempts = arrayValue(runState.providerSourceAttempts).filter(isRecord);
529
+ return {
530
+ providerSourceAttempts: [
531
+ ...attempts.filter((attempt) => stringValue(attempt.campaignId) !== campaignId ||
532
+ stringValue(attempt.senderId) !== senderId),
533
+ {
534
+ campaignId,
535
+ senderId,
536
+ provider: stringValue(actionToolInput(action).provider),
537
+ sourceFingerprint: actionSourceFingerprint(action),
538
+ attemptedAt: now.toISOString(),
539
+ scope: "refill_run",
540
+ },
541
+ ],
542
+ };
543
+ }
544
+ // CAP110-18: A prepare_messages job that terminalizes with stopReason
545
+ // no_eligible_rows for a (campaignId, actionType) proved the source frontier dry
546
+ // for that lane. Record the dry-source attempt run-scoped and refuse an
547
+ // identical re-prep for that key, mirroring the provider-source one-attempt
548
+ // bound above, so the loop never re-runs the same dry prep three times (run
549
+ // cmrow9c4q/cmrowh0tc/cmrowr7rz: three identical preps at cursorPosition 1411).
550
+ // A same-source add for the campaign clears the bound so a genuine re-prep
551
+ // against replenished rows can still run.
552
+ function actionRefillLaneType(action) {
553
+ const toolInput = actionToolInput(action);
554
+ const ids = actionIds(action);
555
+ return (stringValue(action.actionType) ??
556
+ stringValue(toolInput.actionType) ??
557
+ stringValue(toolInput.refillLaneKey) ??
558
+ stringValue(ids.actionType));
559
+ }
560
+ function hasRunScopedDrySourcePrepAttempt(action, runState) {
561
+ const campaignId = actionCampaignId(action);
562
+ if (!campaignId)
563
+ return false;
564
+ const actionType = actionRefillLaneType(action);
565
+ return arrayValue(runState.drySourcePrepAttempts)
566
+ .filter(isRecord)
567
+ .some((attempt) => stringValue(attempt.campaignId) === campaignId &&
568
+ stringValue(attempt.actionType) === actionType);
569
+ }
570
+ function runScopedDrySourcePrepAttemptUpdate(action, runState, stopReason, now) {
571
+ const campaignId = actionCampaignId(action);
572
+ if (!campaignId)
573
+ return {};
574
+ const actionType = actionRefillLaneType(action);
575
+ const attempts = arrayValue(runState.drySourcePrepAttempts).filter(isRecord);
576
+ return {
577
+ drySourcePrepAttempts: [
578
+ ...attempts.filter((attempt) => stringValue(attempt.campaignId) !== campaignId ||
579
+ stringValue(attempt.actionType) !== actionType),
580
+ {
581
+ campaignId,
582
+ actionType,
583
+ stopReason,
584
+ sourceFingerprint: actionSourceFingerprint(action),
585
+ attemptedAt: now.toISOString(),
586
+ scope: "refill_run",
587
+ },
588
+ ],
589
+ };
590
+ }
591
+ // A bounded same-source add (Signal Discovery, Sales Nav, Prospeo) for a
592
+ // campaign may replenish its frontier, so clear that campaign's dry-source prep
593
+ // bounds and let the planner offer a genuine re-prep against the new rows.
594
+ function clearRunScopedDrySourcePrepAttempts(campaignId, runState) {
595
+ if (!campaignId)
596
+ return {};
597
+ const attempts = arrayValue(runState.drySourcePrepAttempts).filter(isRecord);
598
+ const remaining = attempts.filter((attempt) => stringValue(attempt.campaignId) !== campaignId);
599
+ if (remaining.length === attempts.length)
600
+ return {};
601
+ return { drySourcePrepAttempts: remaining };
602
+ }
492
603
  function laneExhaustionRunState(plan, action, currentRunState, reason, now) {
493
604
  const senderId = actionSenderId(action);
494
605
  const evergreen = recordValue(plan.evergreen) ?? {};
@@ -1387,6 +1498,43 @@ async function gateExecute(input, deps, ctx) {
1387
1498
  };
1388
1499
  outcome = did;
1389
1500
  }
1501
+ else if (typeof action.type === "string" &&
1502
+ PROVIDER_SOURCE_CONTINUATION_TYPES.has(action.type) &&
1503
+ hasRunScopedProviderSourceAttempt(action, ctx.runState)) {
1504
+ // Phase 110-05: Sales Nav / Prospeo same-source add already ran once this
1505
+ // run for this (campaign, sender); do not repeat the unchanged mutation.
1506
+ did = {
1507
+ status: "refused",
1508
+ refusalReason: "same-provider-source continuation already used its bounded run-scoped add without closing projected coverage",
1509
+ result: {
1510
+ scope: "refill_run",
1511
+ campaignId: actionCampaignId(action),
1512
+ senderId: actionSenderId(action),
1513
+ sourceFingerprint: actionSourceFingerprint(action),
1514
+ },
1515
+ };
1516
+ outcome = did;
1517
+ }
1518
+ else if (action.type === "prepare_messages" &&
1519
+ hasRunScopedDrySourcePrepAttempt(action, ctx.runState)) {
1520
+ // CAP110-18: this (campaign, lane) prep already terminalized
1521
+ // no_eligible_rows this run and no same-source add has replenished it since.
1522
+ // Refuse the identical re-prep so the loop escalates to source continuation
1523
+ // or terminalizes typed instead of re-running the dry frontier.
1524
+ did = {
1525
+ status: "refused",
1526
+ refusalReason: "prepare_messages already proved no_eligible_rows for this campaign/lane this run; refusing an identical dry-source re-prep",
1527
+ result: {
1528
+ scope: "refill_run",
1529
+ blocker: "dry_source_prep_refused",
1530
+ campaignId: actionCampaignId(action),
1531
+ actionType: actionRefillLaneType(action),
1532
+ senderId: actionSenderId(action),
1533
+ sourceFingerprint: actionSourceFingerprint(action),
1534
+ },
1535
+ };
1536
+ outcome = did;
1537
+ }
1390
1538
  else if (action.type === "start_paused_campaign") {
1391
1539
  did = await deps.executors.executeStartCampaignPrimitive({
1392
1540
  action,
@@ -1588,9 +1736,16 @@ async function verifyPrepareAction(input, deps, ctx, action, budgets) {
1588
1736
  },
1589
1737
  ],
1590
1738
  });
1739
+ const prepStopReason = stringValue(statusRecord.stopReason);
1740
+ // CAP110-18: a dry (no_eligible_rows, zero prepared) terminal records the
1741
+ // run-scoped dry-source bound so the identical prep is refused next cycle.
1742
+ const drySourcePrepUpdate = prepStopReason === "no_eligible_rows" && preparedCount === 0
1743
+ ? runScopedDrySourcePrepAttemptUpdate(action, ctx.runState, prepStopReason, deps.now?.() ?? new Date())
1744
+ : {};
1591
1745
  return advanceBackToPlan(input, deps, ctx, {
1746
+ ...drySourcePrepUpdate,
1592
1747
  progress: mergeProgress(ctx, {
1593
- lastPrepStopReason: stringValue(statusRecord.stopReason),
1748
+ lastPrepStopReason: prepStopReason,
1594
1749
  lastPreparedCount: preparedCount,
1595
1750
  lastCheckedRows: checkedRows,
1596
1751
  }),
@@ -1760,6 +1915,37 @@ async function runWindowSweepWalk(input, deps, ctx) {
1760
1915
  null;
1761
1916
  return { sweepReceipts, schedulerRunReceipt };
1762
1917
  }
1918
+ // CAP110-21: Find an exact target-bound run_scheduler_sweep the current plan
1919
+ // already carries (a per-sender one-slot packet with expectedTargets and a
1920
+ // bounded maxPlacements), distinct from the broad workspace JIT walk. Used to
1921
+ // fall through to that exact sweep when the broad workspace sweep placed zero
1922
+ // but a genuinely open slot with eligible supply remains (the dotwork Kyle Byrd
1923
+ // last Monday slot).
1924
+ function exactTargetBoundSchedulerSweep(plan) {
1925
+ const queue = arrayValue(recordValue(plan)?.globalActionQueue);
1926
+ for (const entry of queue) {
1927
+ const candidate = recordValue(entry);
1928
+ if (!candidate)
1929
+ continue;
1930
+ if (stringValue(candidate.type) !== "run_scheduler_sweep")
1931
+ continue;
1932
+ const toolInput = actionToolInput(candidate);
1933
+ const expectedTargets = arrayValue(toolInput.expectedTargets);
1934
+ const maxPlacements = numberValue(toolInput.maxPlacements) ?? 0;
1935
+ if (expectedTargets.length > 0 && maxPlacements > 0) {
1936
+ return candidate;
1937
+ }
1938
+ }
1939
+ return null;
1940
+ }
1941
+ function schedulerPrimitivePlacedCount(did) {
1942
+ const payload = resultPayload(did);
1943
+ const receipt = recordValue(payload.receipt);
1944
+ return (numberValue(receipt?.cellsScheduled) ??
1945
+ numberValue(recordValue(payload.changedCounts)?.total) ??
1946
+ numberValue(payload.cellsScheduled) ??
1947
+ 0);
1948
+ }
1763
1949
  async function verifySchedulerWait(input, deps, ctx, action, budgets) {
1764
1950
  const progress = runStateProgress(ctx);
1765
1951
  const enteredAt = stringValue(progress.schedulerWaitEnteredAt) ??
@@ -1922,6 +2108,51 @@ async function verifySchedulerWait(input, deps, ctx, action, budgets) {
1922
2108
  return waitResult;
1923
2109
  }
1924
2110
  if (zeroScheduledFresh) {
2111
+ // CAP110-21: the broad workspace sweep placed zero, but the plan may still
2112
+ // carry an exact per-sender one-slot run_scheduler_sweep packet (the dotwork
2113
+ // Kyle Byrd last Monday slot: one open slot, 27+ ready rows, a healthy
2114
+ // sender, 49 fresh credits). Fall through to that exact target-bound sweep
2115
+ // once per world state before terminalizing so a genuinely open slot with
2116
+ // eligible supply is filled instead of stranded one short.
2117
+ const exactSweep = exactTargetBoundSchedulerSweep(ctx.plan);
2118
+ const exactSweepFiredForState = currentStateRevision !== null &&
2119
+ stringValue(runStateProgress(ctx).exactTargetSweepStateRevision) ===
2120
+ currentStateRevision;
2121
+ if (exactSweep &&
2122
+ !exactSweepFiredForState &&
2123
+ deps.executors.executeOneYoloPrimitive) {
2124
+ const did = await deps.executors.executeOneYoloPrimitive(exactSweep, input.workspaceId);
2125
+ const placed = schedulerPrimitivePlacedCount(did);
2126
+ await deps.runClient.recordRunOutcome({
2127
+ workspaceId: input.workspaceId,
2128
+ runId: ctx.runId,
2129
+ fence: ctx.fence,
2130
+ eventId: lastEventId(ctx),
2131
+ did,
2132
+ outcome: {
2133
+ status: placed > 0
2134
+ ? "exact_target_sweep_placed"
2135
+ : "exact_target_sweep_zero",
2136
+ placed,
2137
+ actionKey: stringValue(exactSweep.actionKey),
2138
+ },
2139
+ });
2140
+ ctx.runState = mergeRunState(ctx.runState, {
2141
+ progress: mergeProgress(ctx, {
2142
+ exactTargetSweepFired: true,
2143
+ exactTargetSweepPlaced: placed,
2144
+ exactTargetSweepAt: (deps.now?.() ?? new Date()).toISOString(),
2145
+ ...(currentStateRevision
2146
+ ? { exactTargetSweepStateRevision: currentStateRevision }
2147
+ : {}),
2148
+ }),
2149
+ });
2150
+ if (placed > 0) {
2151
+ // The exact sweep filled the open slot; replan so coverage reflects the
2152
+ // placement instead of terminalizing one short.
2153
+ return advanceBackToPlan(input, deps, ctx);
2154
+ }
2155
+ }
1925
2156
  // The just-verified sweep placed zero cells, so every remaining candidate
1926
2157
  // is deferred by scheduler gates (per-prospect cooldowns, closed windows,
1927
2158
  // credit thresholds). Spinning on the wait cannot change that; end with a
@@ -2002,7 +2233,25 @@ async function gateVerify(input, deps, ctx, budgets) {
2002
2233
  if (actionType === "continue_signal_discovery_source" &&
2003
2234
  stringValue(recordValue(ctx.runState.lastOutcome)?.status) ===
2004
2235
  "executed_and_reread") {
2005
- return advanceBackToPlan(input, deps, ctx, runScopedSignalDiscoveryAttemptUpdate(action, ctx.runState, deps.now?.() ?? new Date()));
2236
+ return advanceBackToPlan(input, deps, ctx, {
2237
+ ...runScopedSignalDiscoveryAttemptUpdate(action, ctx.runState, deps.now?.() ?? new Date()),
2238
+ // CAP110-18: replenishing this campaign's source clears its dry-source
2239
+ // prep bound so a genuine re-prep against the new rows can run.
2240
+ ...clearRunScopedDrySourcePrepAttempts(actionCampaignId(action), ctx.runState),
2241
+ });
2242
+ }
2243
+ if (typeof actionType === "string" &&
2244
+ PROVIDER_SOURCE_CONTINUATION_TYPES.has(actionType) &&
2245
+ stringValue(recordValue(ctx.runState.lastOutcome)?.status) ===
2246
+ "executed_and_reread") {
2247
+ // Phase 110-05: record the bounded same-source add so the run does not
2248
+ // repeat it for this (campaign, sender) before replanning.
2249
+ return advanceBackToPlan(input, deps, ctx, {
2250
+ ...runScopedProviderSourceAttemptUpdate(action, ctx.runState, deps.now?.() ?? new Date()),
2251
+ // CAP110-18: a same-source add may replenish the frontier; clear the
2252
+ // campaign's dry-source prep bound so the re-prep is allowed.
2253
+ ...clearRunScopedDrySourcePrepAttempts(actionCampaignId(action), ctx.runState),
2254
+ });
2006
2255
  }
2007
2256
  if (actionType === "wait_for_scheduler") {
2008
2257
  return verifySchedulerWait(input, deps, ctx, action, budgets);
@@ -233,6 +233,19 @@ export declare function approveGeneratedMessagesBatch(action: Record<string, unk
233
233
  }>;
234
234
  export declare function approveSequenceRowsBatch(action: Record<string, unknown>, workspaceId?: string): Promise<YoloPrimitiveAttempt>;
235
235
  export declare function continueSignalDiscoverySource(action: Record<string, unknown>, workspaceId?: string): Promise<YoloPrimitiveAttempt>;
236
+ /**
237
+ * Phase 110-05: bounded SAME-provider-source continuation for cold-outbound
238
+ * Sales Nav / Prospeo campaigns. Mirrors continueSignalDiscoverySource's
239
+ * guardrails (campaign + provider + selected-source match) and reuses the
240
+ * EXISTING import_leads tool to add leads from the campaign's own saved
241
+ * provider search (mode "add") once eligible/prepared rows are receipt-proven
242
+ * exhausted. It never switches source families and never creates a campaign.
243
+ * The add is bounded by the packet's existing sourceRowLimit cap; when the
244
+ * packet carries no reusable provider search reference the add cannot start
245
+ * autonomously and the executor refuses with a bounded reason so the run
246
+ * records the attempt instead of looping on an unchanged mutation.
247
+ */
248
+ export declare function continueProviderSource(action: Record<string, unknown>, workspaceId?: string): Promise<YoloPrimitiveAttempt>;
236
249
  export declare function refreshPaidInmailCreditsWithRetry(senderId: string, workspaceId: string, options?: PaidInmailCreditRefreshOptions): Promise<{
237
250
  receipt: import("./senders.js").RefreshPaidInmailCreditsResponse;
238
251
  attempts: number;
@@ -12,6 +12,10 @@ const PAID_INMAIL_REFRESH_RETRY_DELAY_MS = Number(process.env.SELLABLE_MCP_PAID_
12
12
  const SIGNAL_DISCOVERY_MIN_REFILL_POSTS = 3;
13
13
  const SIGNAL_DISCOVERY_MAX_REFILL_POSTS = 10;
14
14
  const SIGNAL_DISCOVERY_TARGET_ROWS_PER_POST = 150;
15
+ // Phase 110-05: bound each same-source Sales Nav / Prospeo yolo continuation to
16
+ // a single provider add batch, mirroring the Signal Discovery continuation cap.
17
+ const PROVIDER_SOURCE_CONTINUATION_MAX_ROWS = 1500;
18
+ const PROVIDER_SOURCE_CONTINUATION_DEFAULT_ROWS = 100;
15
19
  const USABLE_PAID_INMAIL_REFRESH_RECEIPT_STATUSES = new Set([
16
20
  "fresh",
17
21
  "refreshed",
@@ -1005,6 +1009,122 @@ export async function continueSignalDiscoverySource(action, workspaceId) {
1005
1009
  },
1006
1010
  };
1007
1011
  }
1012
+ /**
1013
+ * Phase 110-05: bounded SAME-provider-source continuation for cold-outbound
1014
+ * Sales Nav / Prospeo campaigns. Mirrors continueSignalDiscoverySource's
1015
+ * guardrails (campaign + provider + selected-source match) and reuses the
1016
+ * EXISTING import_leads tool to add leads from the campaign's own saved
1017
+ * provider search (mode "add") once eligible/prepared rows are receipt-proven
1018
+ * exhausted. It never switches source families and never creates a campaign.
1019
+ * The add is bounded by the packet's existing sourceRowLimit cap; when the
1020
+ * packet carries no reusable provider search reference the add cannot start
1021
+ * autonomously and the executor refuses with a bounded reason so the run
1022
+ * records the attempt instead of looping on an unchanged mutation.
1023
+ */
1024
+ export async function continueProviderSource(action, workspaceId) {
1025
+ const type = stringValue(action.type);
1026
+ const campaignOfferId = actionCampaignId(action);
1027
+ const sourceLeadListId = actionSourceLeadListId(action);
1028
+ const sourceFingerprint = actionSourceFingerprint(action);
1029
+ const toolInput = actionToolInput(action);
1030
+ const packetProvider = stringValue(toolInput.provider);
1031
+ // The owner directive authorizes cold same-source continuation for Sales Nav
1032
+ // OR Prospeo only. continue_prospeo_source also spans apollo-sourced
1033
+ // campaigns, which are NOT an authorized cold yolo source — refuse those.
1034
+ const provider = type === "continue_sales_nav_source"
1035
+ ? "sales-nav"
1036
+ : type === "continue_prospeo_source" &&
1037
+ packetProvider !== "apollo" &&
1038
+ packetProvider !== "apollo-ai"
1039
+ ? "prospeo"
1040
+ : null;
1041
+ if (!provider) {
1042
+ return {
1043
+ status: "refused",
1044
+ refusalReason: "continue_provider_source only runs bounded same-source adds for Sales Nav or Prospeo cold campaigns",
1045
+ };
1046
+ }
1047
+ if (!campaignOfferId || !sourceLeadListId || !sourceFingerprint) {
1048
+ return {
1049
+ status: "refused",
1050
+ refusalReason: `${type} action is missing campaignOfferId, sourceLeadListId, or sourceFingerprint`,
1051
+ };
1052
+ }
1053
+ const api = getApi();
1054
+ const requestOptions = workspaceRequestOptions(workspaceId);
1055
+ const campaign = await api.get(`/api/v2/campaign-offers/${campaignOfferId}`, requestOptions);
1056
+ const campaignProvider = campaign.leadSourceProvider ?? null;
1057
+ const providerMatches = provider === "sales-nav"
1058
+ ? campaignProvider === "sales-nav"
1059
+ : campaignProvider === "prospeo";
1060
+ if (!providerMatches) {
1061
+ return {
1062
+ status: "refused",
1063
+ refusalReason: `campaign leadSourceProvider (${String(campaignProvider)}) is not ${provider}; refusing same-source continuation`,
1064
+ };
1065
+ }
1066
+ if (campaign.selectedLeadListId &&
1067
+ campaign.selectedLeadListId !== sourceLeadListId) {
1068
+ return {
1069
+ status: "refused",
1070
+ refusalReason: "campaign selectedLeadListId changed since the plan packet; rerun get_refill_target_plan before source continuation",
1071
+ };
1072
+ }
1073
+ // Bound the same-source add by the packet's existing sourceRowLimit cap.
1074
+ const sourceRowLimit = Math.min(PROVIDER_SOURCE_CONTINUATION_MAX_ROWS, Math.max(1, Math.floor(numberValue(toolInput.sourceRowLimit) ??
1075
+ numberValue(toolInput.targetLeadCount) ??
1076
+ numberValue(toolInput.targetRows) ??
1077
+ numberValue(action.targetRows) ??
1078
+ PROVIDER_SOURCE_CONTINUATION_DEFAULT_ROWS)));
1079
+ // Reuse the campaign's SAME saved provider search reference; never widen to a
1080
+ // different source family. Without a reusable reference the same-source add
1081
+ // cannot start autonomously.
1082
+ const searchId = stringValue(toolInput.searchId);
1083
+ if (!searchId) {
1084
+ return {
1085
+ status: "refused",
1086
+ refusalReason: `${provider} same-source continuation requires a reusable provider search reference on the packet; none was surfaced`,
1087
+ result: {
1088
+ provider,
1089
+ campaignOfferId,
1090
+ sourceLeadListId,
1091
+ sourceFingerprint,
1092
+ },
1093
+ };
1094
+ }
1095
+ markProviderPromptLoaded({ provider, campaignOfferId });
1096
+ const importResult = await importLeads({
1097
+ campaignOfferId,
1098
+ provider,
1099
+ sourceLeadListId,
1100
+ searchId,
1101
+ mode: "add",
1102
+ targetLeadCount: sourceRowLimit,
1103
+ confirmed: true,
1104
+ ...(workspaceId ? { workspaceId } : {}),
1105
+ });
1106
+ const importRecord = recordValue(importResult);
1107
+ if (importRecord?.error || importRecord?.needsModeSelection === true) {
1108
+ return {
1109
+ status: "refused",
1110
+ refusalReason: stringValue(importRecord?.message) ??
1111
+ `${provider} same-source import did not start a bounded add job`,
1112
+ result: { provider, campaignOfferId, sourceLeadListId, importResult },
1113
+ };
1114
+ }
1115
+ return {
1116
+ status: "executed_and_reread",
1117
+ result: {
1118
+ provider,
1119
+ campaignOfferId,
1120
+ previousSourceLeadListId: sourceLeadListId,
1121
+ sourceFingerprint,
1122
+ searchId,
1123
+ sourceRowLimit,
1124
+ importResult,
1125
+ },
1126
+ };
1127
+ }
1008
1128
  export async function refreshPaidInmailCreditsWithRetry(senderId, workspaceId, options = {}) {
1009
1129
  const errors = [];
1010
1130
  for (let attempt = 1; attempt <= PAID_INMAIL_REFRESH_MAX_ATTEMPTS; attempt += 1) {
@@ -1433,6 +1553,9 @@ export async function executeOneYoloPrimitive(action, workspaceId) {
1433
1553
  }
1434
1554
  case "continue_signal_discovery_source":
1435
1555
  return continueSignalDiscoverySource(action, workspaceId);
1556
+ case "continue_sales_nav_source":
1557
+ case "continue_prospeo_source":
1558
+ return continueProviderSource(action, workspaceId);
1436
1559
  case "approve_messages":
1437
1560
  return approveSequenceRowsBatch(action, workspaceId);
1438
1561
  default:
@@ -223,7 +223,113 @@ export declare function refillSendsCommand(input?: RefillSendsCommandInput): {
223
223
  workspaceResolution: string;
224
224
  intent: RefillSendsIntent;
225
225
  };
226
+ type RefillSenderSelectorMatch = {
227
+ selector: string;
228
+ senderId: string;
229
+ displayName: string;
230
+ matchType: "id" | "exact_name" | "unambiguous_prefix";
231
+ };
232
+ type RefillSenderSelectorMiss = {
233
+ selector: string;
234
+ reason: "no_match" | "ambiguous";
235
+ candidates: Array<{
236
+ senderId: string;
237
+ displayName: string;
238
+ }>;
239
+ };
226
240
  export declare function executeRefillSendsCommand(input?: RefillSendsCommandInput): Promise<import("./workspace-context.js").WorkspaceRequiredResult | {
241
+ ok: boolean;
242
+ code: string;
243
+ senderSelectorResolution: {
244
+ ok: false;
245
+ code: "sender_selector_unresolved";
246
+ workspaceId: string;
247
+ unresolvedSelectors: RefillSenderSelectorMiss[];
248
+ resolvedSelectors: RefillSenderSelectorMatch[];
249
+ availableSenders: Array<{
250
+ senderId: string;
251
+ displayName: string;
252
+ }>;
253
+ };
254
+ note: string;
255
+ targetDate: string | null;
256
+ untilDate: string | null;
257
+ horizonSendDays: number | null;
258
+ fillWindow: {
259
+ mode: string;
260
+ targetDate: string;
261
+ description: string;
262
+ untilDate?: undefined;
263
+ horizonSendDays?: undefined;
264
+ horizonHours?: undefined;
265
+ } | {
266
+ mode: string;
267
+ untilDate: string;
268
+ description: string;
269
+ targetDate?: undefined;
270
+ horizonSendDays?: undefined;
271
+ horizonHours?: undefined;
272
+ } | {
273
+ mode: string;
274
+ horizonSendDays: number | null;
275
+ horizonHours: number | null;
276
+ description: string;
277
+ targetDate?: undefined;
278
+ untilDate?: undefined;
279
+ };
280
+ approvalMode: RefillSendsApprovalMode;
281
+ campaignId: string | null;
282
+ tableId: string | null;
283
+ senderScope: string;
284
+ senderSelectors: {
285
+ senders: string[];
286
+ senderIds: string[];
287
+ senderNames: string[];
288
+ };
289
+ firstOperationalSteps: string[];
290
+ approvalContract: string;
291
+ forbiddenActions: string[];
292
+ hostExamples: {
293
+ claude: string[];
294
+ codex: string[];
295
+ mcpTool: {
296
+ name: string;
297
+ arguments: {
298
+ fence?: number | undefined;
299
+ runId?: string | undefined;
300
+ expectedActionKey?: string | undefined;
301
+ expectedTargetShapeRevision?: string | undefined;
302
+ targetDate: string | null;
303
+ untilDate: string | null;
304
+ horizonSendDays: number | null;
305
+ campaignId: string | undefined;
306
+ tableId: string | undefined;
307
+ intent: RefillSendsIntent;
308
+ approvalMode: RefillSendsApprovalMode;
309
+ workspaceId: string | null;
310
+ actionTypes?: ("send_invite" | "send_inmail_closed")[] | undefined;
311
+ yolo: boolean;
312
+ senders: string[];
313
+ senderIds: string[];
314
+ senderNames: string[];
315
+ };
316
+ };
317
+ };
318
+ actionTypes?: ("send_invite" | "send_inmail_closed")[] | undefined;
319
+ command: string;
320
+ tool: string;
321
+ promptName: string;
322
+ workflowPromptName: string;
323
+ workflowAsset: {
324
+ subskillName: string;
325
+ assetPath: string;
326
+ };
327
+ yolo: boolean;
328
+ executionMode: RefillSendsExecutionMode;
329
+ workspaceId: string | null;
330
+ workspaceResolution: string;
331
+ intent: RefillSendsIntent;
332
+ } | {
227
333
  orchestration: {
228
334
  mode: string;
229
335
  checkpointedFields: string[];
@@ -2,6 +2,7 @@ import { decodeRefillReadinessAuthority, refillReadinessFingerprint, } from "../
2
2
  import { actionCampaignId, actionIds, actionSenderId, classifyPaidInmailRefreshReceipt, collectPaidInmailRefreshActions, executeOneYoloPrimitive, executeStartCampaignPrimitive, firstGlobalAction, normalizeStrings, numberValue, recordValue, refreshPaidInmailCreditsWithRetry, stringValue, } from "./refill-executors.js";
3
3
  import { refillSendsV2Command } from "./refill-sends-v2.js";
4
4
  import { getRefillTargetPlan } from "./refill-target-plan.js";
5
+ import { listSenders } from "./senders.js";
5
6
  import { createWorkspaceContext, normalizeExplicitWorkspaceId, } from "./workspace-context.js";
6
7
  const REFILL_SEND_ACTION_TYPES = ["send_invite", "send_inmail_closed"];
7
8
  const REFILL_SEND_ACTION_TYPE_SET = new Set(REFILL_SEND_ACTION_TYPES);
@@ -101,7 +102,7 @@ function normalizeRefillSendsInput(input) {
101
102
  export const refillSendsToolDefinitions = [
102
103
  {
103
104
  name: "refill_sends",
104
- description: "Typed command entrypoint for Sellable refill sends. In --yolo it executes one fenced, checkpointed refill-run slice that returns before the MCP host deadline. An in_progress receipt includes the exact runId/fence continuation; the packaged workflow must immediately auto-resume it with the unchanged workspace/sender/action scope until scheduler-projected coverage fills or a concrete blocker appears, without asking the user to continue. Every primitive records actionKey/requestId/effectId before mutation and rereads the fresh target plan afterward. It may refresh stale paid InMail facts once, prepare/approve bounded existing rows, reuse/import receipt-proven same-source rows, start only the exact PAUSED fallback freshly selected by the packet, run a request-scoped product scheduler sweep, and wait/reread. Unselected PAUSED campaigns remain paused. Scheduler placement stays product-native; the command never sends directly or raw-writes scheduler fields.",
105
+ description: "Typed command entrypoint for Sellable refill sends. In --yolo it executes one fenced, checkpointed refill-run slice that returns before the MCP host deadline. An in_progress receipt includes the exact runId/fence continuation; the packaged workflow must immediately auto-resume it with the unchanged workspace/sender/action scope until scheduler-projected coverage fills or a concrete blocker appears, without asking the user to continue. Every primitive records actionKey/requestId/effectId before mutation and rereads the fresh target plan afterward. It may refresh stale paid InMail facts once, prepare/approve bounded existing rows, reuse/import receipt-proven same-source rows, add more leads to an existing campaign from its SAME provider source once that campaign's eligible/prepared rows are receipt-proven exhausted (cold-outbound campaigns may source Sales Nav or Prospeo), start a route-resolver start-eligible PAUSED campaign-backed sequence campaign selected by the packet when starting is needed to fill the send window, run a request-scoped product scheduler sweep, and wait/reread. Each source add and campaign start is one bounded primitive, then a full replan. It never sends directly, lowers paid InMail thresholds/floors, creates NEW campaigns, archives/deletes, or switches an existing campaign's source family (same provider source only). Unselected PAUSED campaigns that are not start-eligible remain paused. Scheduler placement stays product-native; the command never raw-writes scheduler fields.",
105
106
  inputSchema: {
106
107
  type: "object",
107
108
  properties: {
@@ -322,20 +323,21 @@ function buildRefillSendsCommand(input) {
322
323
  "Do not complete a fill/schedule request until a final get_refill_target_plan or refill-state reread proves projected coverage (sent + scheduled) fills the scheduler-forward target window. Treat awaiting_scheduler_after_ready_buffer as loaded, awaiting scheduler when ready buffer covers the gap; do not import/prep more rows in that state, and keep polling unless Christian stops/statuses the run or a concrete non-scheduler blocker such as paid_inmail_below_threshold appears.",
323
324
  ],
324
325
  approvalContract: yolo
325
- ? "Auto-accept the fresh bounded refill target packet after every get_refill_target_plan reread. Execute one globally ranked primitive at a time and continue in the same invocation until projected coverage fills or a concrete blocker appears. Source-copy/fallback requires receipt-proven exhaustion of earlier ready/prep/approval rungs. An exact freshly selected start_paused_campaign packet authorizes starting only that PAUSED fallback; unselected PAUSED campaigns remain paused. Unbounded approval, source-family switches, threshold changes, and campaign creation require their own explicit gates. Ready-to-schedule rows are buffer, not completion. If ready buffer covers the projected gap and the exact-date run_scheduler_sweep is first, execute it once and fully reread. Non-exact scheduler waits are read-only and must keep rereading until projected coverage fills, a receipt deadline/budget is reached, or a concrete blocker appears. Treat fresh packet selection, coverage, blockers, receipts, timestamps, and current action progress as state within the original workspace/sender/lane/date/side-effect envelope. --yolo does not authorize lowering paid InMail thresholds or creating connection campaigns."
326
+ ? "Auto-accept the fresh bounded refill target packet after every get_refill_target_plan reread. Execute one globally ranked primitive at a time and continue in the same invocation until projected coverage fills or a concrete blocker appears. Source-copy, same-provider-source continuation, and fallback all require receipt-proven exhaustion of earlier ready/prep/approval rungs. A start-eligible start_paused_campaign packet authorizes starting only that route-resolver-selected PAUSED campaign-backed sequence campaign; PAUSED campaigns that are not start-eligible remain paused. --yolo MAY add more leads to an existing campaign from its SAME provider source after receipt-proven exhaustion (cold campaigns: Sales Nav or Prospeo), each as one bounded add followed by a full replan. Unbounded approval, source-FAMILY switches, threshold/floor changes, and NEW campaign creation still require their own explicit gates. Ready-to-schedule rows are buffer, not completion. If ready buffer covers the projected gap and the exact-date run_scheduler_sweep is first, execute it once and fully reread. Non-exact scheduler waits are read-only and must keep rereading until projected coverage fills, a receipt deadline/budget is reached, or a concrete blocker appears. Treat fresh packet selection, coverage, blockers, receipts, timestamps, and current action progress as state within the original workspace/sender/lane/date/side-effect envelope. --yolo does not authorize lowering paid InMail thresholds/floors, switching an existing campaign's source family, or creating campaigns."
326
327
  : hasSenderSelectors
327
328
  ? "Before mutation, post the full bounded refill packet in normal chat as Markdown, including workspace, sender scope, campaign table, exact ids, caps/dates, gross target, sent count, scheduled count, projected count, ready buffer, remaining projected gap, paid InMail threshold feasibility, targetShapeRevision/stateRevision, side effects, forbidden actions, and stop condition. Then ask the final Accept/Decline structured approval question with a compact body that refers back to the posted packet instead of duplicating it. Only ask when get_refill_target_plan reports a positive remaining projected/ready gap. If target is complete by projected coverage, no-op without approval. If ready buffer covers the projected gap and the approved exact-date run_scheduler_sweep is target.globalActionQueue[0], execute it once and fully reread. Only non-exact or no-sweep scheduler waits are read-only; keep the run open until projected coverage fills or Christian stops/statuses it. Threshold changes and campaign creation need separate explicit approval."
328
329
  : "Ask which eligible enrolled senders to refill first, then, before mutation, post the full bounded refill packet in normal chat as Markdown and ask the final Accept/Decline structured approval question with a compact body that refers back to the posted packet.",
329
330
  forbiddenActions: [
330
- "start_campaign outside the exact freshly selected PAUSED fallback packet",
331
- "lower paid InMail thresholds",
332
- "create connection campaigns",
331
+ "start_campaign outside a route-resolver start-eligible PAUSED campaign-backed sequence packet",
332
+ "lower paid InMail thresholds or floors",
333
+ "create NEW campaigns (connection or otherwise)",
334
+ "switch an existing campaign's source family (same provider source only; cold campaigns may source Prospeo or Sales Nav)",
333
335
  "manual launch/send or direct sending outside product scheduler side effects",
334
336
  "archive or delete cleanup",
335
337
  "direct scheduler writes",
336
338
  "sender reassignment",
337
339
  "campaigns outside the rendered eligible sender packet",
338
- "on-demand or unrelated active-campaign fallback unless the refill workflow shows receipt-proven exhaustion for the selected same-sender lane",
340
+ "same-provider-source continuation or source-copy/fallback unless the refill workflow shows receipt-proven exhaustion for the selected same-sender lane",
339
341
  ],
340
342
  hostExamples: {
341
343
  claude: [
@@ -955,6 +957,111 @@ function paidRefreshOptionsFromApprovalPacket(packet) {
955
957
  maxStalenessSeconds: packet.maxStalenessSeconds,
956
958
  };
957
959
  }
960
+ // CAP110-17: Resolve sender display-name / free-form selectors to EXACT sender
961
+ // ids inside the resolved workspace before any target plan, paid-credit
962
+ // refresh, or fenced yolo dispatch. A selector must match an exact id, an exact
963
+ // display name, or an unambiguous name prefix within THIS workspace's fleet. On
964
+ // no-match or ambiguity the resolver refuses loudly with the candidate list so
965
+ // the caller can never silently fall back to the active workspace's full fleet
966
+ // (the "damiano r" invocation that mutated Clover instead of Damiano R).
967
+ async function resolveRefillSenderSelectors(params) {
968
+ const explicitSenderIds = normalizeStrings(params.senderIds);
969
+ const selectors = normalizeStrings(params.selectors);
970
+ const workspaceId = params.workspaceId;
971
+ if (!workspaceId || selectors.length === 0) {
972
+ return {
973
+ ok: true,
974
+ applied: false,
975
+ workspaceId: workspaceId ?? null,
976
+ resolvedSenderIds: explicitSenderIds,
977
+ resolvedSelectors: [],
978
+ };
979
+ }
980
+ const listing = await listSenders({ workspaceId });
981
+ const availableSenders = (listing?.senders ?? [])
982
+ .map((sender) => ({
983
+ senderId: String(sender?.id ?? ""),
984
+ displayName: String(sender?.displayName ?? ""),
985
+ }))
986
+ .filter((sender) => sender.senderId.length > 0);
987
+ const resolvedIds = new Set(explicitSenderIds);
988
+ const resolvedSelectors = [];
989
+ const unresolvedSelectors = [];
990
+ for (const selector of selectors) {
991
+ const normalized = selector.toLowerCase();
992
+ const byId = availableSenders.find((sender) => sender.senderId.toLowerCase() === normalized);
993
+ if (byId) {
994
+ resolvedIds.add(byId.senderId);
995
+ resolvedSelectors.push({
996
+ selector,
997
+ senderId: byId.senderId,
998
+ displayName: byId.displayName,
999
+ matchType: "id",
1000
+ });
1001
+ continue;
1002
+ }
1003
+ const exactName = availableSenders.filter((sender) => sender.displayName.trim().toLowerCase() === normalized);
1004
+ if (exactName.length === 1) {
1005
+ resolvedIds.add(exactName[0].senderId);
1006
+ resolvedSelectors.push({
1007
+ selector,
1008
+ senderId: exactName[0].senderId,
1009
+ displayName: exactName[0].displayName,
1010
+ matchType: "exact_name",
1011
+ });
1012
+ continue;
1013
+ }
1014
+ if (exactName.length > 1) {
1015
+ unresolvedSelectors.push({
1016
+ selector,
1017
+ reason: "ambiguous",
1018
+ candidates: exactName,
1019
+ });
1020
+ continue;
1021
+ }
1022
+ const prefixMatches = availableSenders.filter((sender) => sender.displayName.trim().toLowerCase().startsWith(normalized));
1023
+ if (prefixMatches.length === 1) {
1024
+ resolvedIds.add(prefixMatches[0].senderId);
1025
+ resolvedSelectors.push({
1026
+ selector,
1027
+ senderId: prefixMatches[0].senderId,
1028
+ displayName: prefixMatches[0].displayName,
1029
+ matchType: "unambiguous_prefix",
1030
+ });
1031
+ continue;
1032
+ }
1033
+ if (prefixMatches.length > 1) {
1034
+ unresolvedSelectors.push({
1035
+ selector,
1036
+ reason: "ambiguous",
1037
+ candidates: prefixMatches,
1038
+ });
1039
+ continue;
1040
+ }
1041
+ unresolvedSelectors.push({
1042
+ selector,
1043
+ reason: "no_match",
1044
+ candidates: [],
1045
+ });
1046
+ }
1047
+ if (unresolvedSelectors.length > 0) {
1048
+ return {
1049
+ ok: false,
1050
+ code: "sender_selector_unresolved",
1051
+ workspaceId,
1052
+ unresolvedSelectors,
1053
+ resolvedSelectors,
1054
+ availableSenders,
1055
+ };
1056
+ }
1057
+ return {
1058
+ ok: true,
1059
+ applied: true,
1060
+ workspaceId,
1061
+ resolvedSenderIds: [...resolvedIds],
1062
+ resolvedSelectors,
1063
+ };
1064
+ }
958
1065
  export async function executeRefillSendsCommand(input = {}) {
959
1066
  const normalizedInput = normalizeRefillSendsInput(input);
960
1067
  const yolo = normalizedInput.yolo === true;
@@ -972,16 +1079,45 @@ export async function executeRefillSendsCommand(input = {}) {
972
1079
  if (workspaceContext && !workspaceContext.ok) {
973
1080
  return workspaceContext;
974
1081
  }
975
- const scopedInput = workspaceContext && workspaceContext.ok
1082
+ const scopedInputBase = workspaceContext && workspaceContext.ok
976
1083
  ? {
977
1084
  ...normalizedInput,
978
1085
  workspaceId: workspaceContext.context.workspaceId,
979
1086
  }
980
1087
  : normalizedInput;
981
- const command = buildRefillSendsCommand(scopedInput);
982
1088
  const workspaceId = workspaceContext && workspaceContext.ok
983
1089
  ? workspaceContext.context.workspaceId
984
- : normalizeExplicitWorkspaceId(scopedInput.workspaceId);
1090
+ : normalizeExplicitWorkspaceId(scopedInputBase.workspaceId);
1091
+ // CAP110-17: Resolve any sender name / free-form selectors EXACTLY against the
1092
+ // resolved workspace before building the command or any nested call. A name
1093
+ // that does not resolve in the CORRECT workspace fails loudly with candidates
1094
+ // instead of silently mutating the active workspace's full fleet.
1095
+ const senderSelectorResolution = await resolveRefillSenderSelectors({
1096
+ senderIds: normalizeStrings(scopedInputBase.senderIds),
1097
+ selectors: [
1098
+ ...normalizeStrings(scopedInputBase.senders),
1099
+ ...normalizeStrings(scopedInputBase.senderNames),
1100
+ ],
1101
+ workspaceId,
1102
+ });
1103
+ if (!senderSelectorResolution.ok) {
1104
+ return {
1105
+ ...buildRefillSendsCommand(scopedInputBase),
1106
+ ok: false,
1107
+ code: "sender_selector_unresolved",
1108
+ senderSelectorResolution,
1109
+ note: "One or more sender selectors did not resolve exactly in the requested workspace. Refusing to mutate the active workspace's full fleet. Re-run with an exact sender id, an unambiguous name, or the correct workspaceId.",
1110
+ };
1111
+ }
1112
+ const scopedInput = senderSelectorResolution.applied
1113
+ ? {
1114
+ ...scopedInputBase,
1115
+ senderIds: senderSelectorResolution.resolvedSenderIds,
1116
+ senders: [],
1117
+ senderNames: [],
1118
+ }
1119
+ : scopedInputBase;
1120
+ const command = buildRefillSendsCommand(scopedInput);
985
1121
  if (yolo && workspaceId) {
986
1122
  // The tool contract promises that an already-approved packet's
987
1123
  // expectedTargetShapeRevision/expectedActionKey are compared against a
@@ -29,12 +29,18 @@ const SAFE_PACKET_ACTION_TYPES = new Set([
29
29
  "next_campaign",
30
30
  "done",
31
31
  ]);
32
+ // Phase 110-05: continue_sales_nav_source and continue_prospeo_source are the
33
+ // bounded SAME-provider-source continuation rungs for cold-outbound campaigns.
34
+ // Plain --yolo may execute them once eligible/prepared rows are receipt-proven
35
+ // exhausted, so they pass through as yoloEligible (like
36
+ // continue_signal_discovery_source). The remaining manual-only types stay
37
+ // gated: threshold/floor changes, source-family switches, NEW campaign
38
+ // creation, and the capSaturated / unsupported-provider dead-end
39
+ // (manual_source_replenishment) that needs human table cleanup.
32
40
  const MANUAL_ONLY_PACKET_ACTION_TYPES = new Set([
33
41
  "lower_paid_inmail_threshold",
34
42
  "switch_to_connection_lane",
35
43
  "create_connection_campaign",
36
- "continue_sales_nav_source",
37
- "continue_prospeo_source",
38
44
  "manual_source_replenishment",
39
45
  ]);
40
46
  const TERMINAL_NO_ACTION_BLOCKER_CODES = new Set([
@@ -224,35 +230,15 @@ function sanitizePrepFailureDiagnosis(value) {
224
230
  },
225
231
  };
226
232
  }
227
- function evidenceLatestMs(evidence) {
228
- const latestAt = evidence.latestAt;
229
- if (typeof latestAt !== "string")
230
- return 0;
231
- const timestamp = new Date(latestAt).getTime();
232
- return Number.isFinite(timestamp) ? timestamp : 0;
233
- }
234
- function chooseEvidenceAction(evidence, fallback) {
235
- const byAction = new Map();
236
- for (const item of evidence) {
237
- if (!isRecord(item))
238
- continue;
239
- const actionType = allowedActionType(item.actionType);
240
- if (!actionType)
241
- continue;
242
- const current = byAction.get(actionType) ?? { count: 0, latestMs: 0 };
243
- current.count += numberValue(item.count);
244
- current.latestMs = Math.max(current.latestMs, evidenceLatestMs(item));
245
- byAction.set(actionType, current);
246
- }
247
- const [best] = [...byAction.entries()].sort((a, b) => {
248
- if (a[1].latestMs !== b[1].latestMs)
249
- return b[1].latestMs - a[1].latestMs;
250
- if (a[1].count !== b[1].count)
251
- return b[1].count - a[1].count;
252
- return (REFILL_TARGET_ACTION_TYPES.indexOf(a[0]) -
253
- REFILL_TARGET_ACTION_TYPES.indexOf(b[0]));
254
- });
255
- return best?.[0] ?? fallback[0] ?? null;
233
+ // Phase 110: the backend planner is the single authority on lane selection —
234
+ // it collapses to one lane for non-cascade senders and emits BOTH lanes for
235
+ // invite-capable Sales Nav cascades. This wrapper must not re-collapse
236
+ // (zero-shot i02-clover receipt: request.actionTypes was forced to a single
237
+ // lane with no caller override, so paid lanes with available credits were
238
+ // never targeted). Lanes are ordered by REFILL_TARGET_ACTION_TYPES.
239
+ function orderedLanes(lanes) {
240
+ const laneSet = new Set(lanes);
241
+ return REFILL_TARGET_ACTION_TYPES.filter((lane) => laneSet.has(lane));
256
242
  }
257
243
  function senderActionKey(senderId, actionType) {
258
244
  return typeof senderId === "string" && typeof actionType === "string"
@@ -530,6 +516,13 @@ function sanitizeToolInput(value, authority) {
530
516
  : undefined,
531
517
  campaignOfferId: stringValue(value.campaignOfferId),
532
518
  provider: stringValue(value.provider),
519
+ // Phase 110-05: reusable SAME-provider-source search reference for bounded
520
+ // sales-nav / prospeo yolo continuation (only consumed for same-source
521
+ // adds; never authorizes a source-family switch).
522
+ searchId: stringValue(value.searchId),
523
+ targetLeadCount: typeof value.targetLeadCount === "number"
524
+ ? value.targetLeadCount
525
+ : undefined,
533
526
  allowDifferentSourceLeadList: typeof value.allowDifferentSourceLeadList === "boolean"
534
527
  ? value.allowDifferentSourceLeadList
535
528
  : undefined,
@@ -650,8 +643,8 @@ function sanitizeEligibleSenderLedger(value, selectedBySender) {
650
643
  return selectedBySender.has(row.senderId);
651
644
  })
652
645
  .map((row) => {
653
- const selectedLane = selectedBySender.get(row.senderId);
654
- const eligibleActionTypes = actionTypesFrom(row.eligibleActionTypes).filter((actionType) => actionType === selectedLane);
646
+ const selectedLanes = selectedBySender.get(row.senderId) ?? new Set();
647
+ const eligibleActionTypes = actionTypesFrom(row.eligibleActionTypes).filter((actionType) => selectedLanes.has(actionType));
655
648
  return {
656
649
  senderId: row.senderId,
657
650
  senderName: stringValue(row.senderName) ?? "",
@@ -841,7 +834,7 @@ function sanitizeStructuredSenderPlans(value, selectedBySender) {
841
834
  if (!isRecord(plan) || typeof plan.senderId !== "string")
842
835
  return false;
843
836
  const selectedLane = allowedActionType(plan.selectedLane);
844
- return Boolean(selectedLane && selectedBySender.get(plan.senderId) === selectedLane);
837
+ return Boolean(selectedLane && selectedBySender.get(plan.senderId)?.has(selectedLane));
845
838
  })
846
839
  .map((plan) => {
847
840
  const paidInmail = sanitizePaidInmail(plan.paidInmail);
@@ -1038,7 +1031,7 @@ function sanitizeTargetSchedulerSweep(params) {
1038
1031
  }
1039
1032
  const expectedSenderIds = [...params.sweepEligibleBySender.keys()].sort();
1040
1033
  const expectedActionTypes = [
1041
- ...new Set(params.sweepEligibleBySender.values()),
1034
+ ...new Set([...params.sweepEligibleBySender.values()].flatMap((lanes) => [...lanes])),
1042
1035
  ].sort();
1043
1036
  if (expectedSenderIds.length === 0 || expectedActionTypes.length === 0) {
1044
1037
  return null;
@@ -1324,31 +1317,36 @@ function sanitizeRefillTargetPlanResult(result) {
1324
1317
  const rawSenderPlans = Array.isArray(target.senderPlans)
1325
1318
  ? target.senderPlans
1326
1319
  : [];
1320
+ // Phase 110: accept every senderId:actionType pair the backend emitted —
1321
+ // the backend planner already decided lane selection (single-lane for
1322
+ // non-cascades, both lanes for invite-capable cascades). Do not pick a
1323
+ // winner per sender here.
1327
1324
  const selectedBySender = new Map();
1328
- for (const rawSelection of rawSelections) {
1329
- if (!isRecord(rawSelection) || typeof rawSelection.senderId !== "string") {
1330
- continue;
1331
- }
1332
- const fallback = actionTypesFrom(rawSelection.actionTypes);
1333
- const evidence = Array.isArray(rawSelection.evidence)
1334
- ? rawSelection.evidence
1335
- : [];
1336
- const chosen = chooseEvidenceAction(evidence, fallback);
1337
- if (chosen)
1338
- selectedBySender.set(rawSelection.senderId, chosen);
1339
- }
1325
+ const addSelectedLane = (senderId, actionType) => {
1326
+ const lanes = selectedBySender.get(senderId) ?? new Set();
1327
+ lanes.add(actionType);
1328
+ selectedBySender.set(senderId, lanes);
1329
+ };
1340
1330
  for (const rawPlan of rawSenderPlans) {
1341
- if (!isRecord(rawPlan) ||
1342
- typeof rawPlan.senderId !== "string" ||
1343
- selectedBySender.has(rawPlan.senderId)) {
1331
+ if (!isRecord(rawPlan) || typeof rawPlan.senderId !== "string")
1344
1332
  continue;
1345
- }
1346
1333
  const actionType = allowedActionType(rawPlan.actionType);
1347
1334
  if (actionType)
1348
- selectedBySender.set(rawPlan.senderId, actionType);
1335
+ addSelectedLane(rawPlan.senderId, actionType);
1336
+ }
1337
+ if (selectedBySender.size === 0) {
1338
+ for (const rawSelection of rawSelections) {
1339
+ if (!isRecord(rawSelection) ||
1340
+ typeof rawSelection.senderId !== "string") {
1341
+ continue;
1342
+ }
1343
+ for (const actionType of actionTypesFrom(rawSelection.actionTypes)) {
1344
+ addSelectedLane(rawSelection.senderId, actionType);
1345
+ }
1346
+ }
1349
1347
  }
1350
- const selectedKeys = new Set([...selectedBySender.entries()].map(([senderId, actionType]) => `${senderId}:${actionType}`));
1351
- const actionTypes = [...new Set(selectedBySender.values())];
1348
+ const selectedKeys = new Set([...selectedBySender.entries()].flatMap(([senderId, lanes]) => [...lanes].map((actionType) => `${senderId}:${actionType}`)));
1349
+ const actionTypes = orderedLanes([...selectedBySender.values()].flatMap((lanes) => [...lanes]));
1352
1350
  if (selectedKeys.size === 0)
1353
1351
  return emptyUnsupportedResult(result);
1354
1352
  const senderPlans = rawSenderPlans.filter((plan) => isRecord(plan) &&
@@ -1362,13 +1360,13 @@ function sanitizeRefillTargetPlanResult(result) {
1362
1360
  typeof selection.senderId === "string" &&
1363
1361
  selectedBySender.has(selection.senderId))
1364
1362
  .map((selection) => {
1365
- const actionType = selectedBySender.get(selection.senderId);
1363
+ const lanes = selectedBySender.get(selection.senderId) ?? new Set();
1366
1364
  const evidence = Array.isArray(selection.evidence)
1367
- ? selection.evidence.filter((item) => isRecord(item) && item.actionType === actionType)
1365
+ ? selection.evidence.filter((item) => isRecord(item) && lanes.has(String(item.actionType)))
1368
1366
  : [];
1369
1367
  return {
1370
1368
  ...selection,
1371
- actionTypes: actionType ? [actionType] : [],
1369
+ actionTypes: orderedLanes(lanes),
1372
1370
  evidence,
1373
1371
  };
1374
1372
  });
@@ -1401,7 +1399,9 @@ function sanitizeRefillTargetPlanResult(result) {
1401
1399
  numberValue(plan.remainingProjectedGap) > 0 &&
1402
1400
  numberValue(plan.remainingReadyOrProjectedGap) === 0 &&
1403
1401
  numberValue(plan.readyBuffer) > 0) {
1404
- sweepEligibleBySender.set(senderId, actionType);
1402
+ const lanes = sweepEligibleBySender.get(senderId) ?? new Set();
1403
+ lanes.add(actionType);
1404
+ sweepEligibleBySender.set(senderId, lanes);
1405
1405
  }
1406
1406
  }
1407
1407
  const schedulerSweepAction = sanitizeTargetSchedulerSweep({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.600",
3
+ "version": "0.1.602-phase110.20260717134543",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code, Codex, and Hermes campaign workflows",
6
6
  "main": "dist/index.js",
@@ -108,18 +108,24 @@ Safe primitives are paid-credit refresh,
108
108
  existing-row message preparation, generated-message approval, receipt-proven
109
109
  same-source row copy, one bounded Signal Discovery search derived only from
110
110
  the campaign's already-approved positive ICP criteria when no reusable source
111
- rows or tab keywords remain, the exact freshly selected PAUSED fallback start, a
112
- request-scoped exact-date product scheduler sweep, and read-only wait rereads.
111
+ rows or tab keywords remain, one bounded SAME-provider-source add for a cold
112
+ campaign whose eligible/prepared rows are receipt-proven exhausted (Sales Nav
113
+ or Prospeo, same saved search — never a source-family switch), the start of a
114
+ route-resolver start-eligible PAUSED campaign-backed sequence campaign selected
115
+ by the packet, a request-scoped exact-date product scheduler sweep, and
116
+ read-only wait rereads. Each source add and campaign start is one bounded
117
+ primitive followed by a full replan.
113
118
  Approved-criteria keywords and the attempt/exhaustion marker are scoped to the
114
119
  current fenced refill run: archive temporary derived search tabs after the
115
120
  source import is materialized, keep normal source-list/import receipts, and
116
121
  retry from the campaign's current approved rubric in every new refill run.
117
- Same-source copy/source
118
- fallback is safe only after receipt-proven exhaustion:
122
+ Same-source copy, same-provider-source add, and source
123
+ fallback are safe only after receipt-proven exhaustion:
119
124
  `hasMoreFrontierRows:false`, zero `approvalCandidates`, no `stuckActiveCells`,
120
125
  and no non-terminal `approvedNotDispatched` work. It does not run unbounded
121
- approval, lower paid-InMail thresholds, switch source families, create
122
- campaigns, launch, or send directly; it never raw-writes scheduler fields.
126
+ approval, lower paid-InMail thresholds/floors, switch an existing campaign's
127
+ source family, create NEW campaigns, archive/delete, launch, or send directly;
128
+ it never raw-writes scheduler fields.
123
129
  Continue with the workflow below for route selection, state rereads, approval
124
130
  gating, source import, preparation, and bounded approval.
125
131
 
@@ -202,6 +208,27 @@ senders" as sender-scoped requests. The target set is senders enrolled in active
202
208
  campaign-backed sequence campaigns, not the first active campaign returned by a
203
209
  resolver.
204
210
 
211
+ ## Sender Selector Resolution
212
+
213
+ When a request names a sender or workspace ("damiano r", "refill Kyle"), resolve
214
+ that selector EXACTLY inside the CORRECT workspace before any mutation. A
215
+ selector resolves only by exact sender id, exact display name, or an
216
+ unambiguous display-name prefix within `list_senders({ workspaceId })` for the
217
+ resolved workspace. `refill_sends` performs this resolution internally: a
218
+ `--yolo`/scheduled invocation with `senders`/`senderNames` first pins each
219
+ selector to an exact sender id in the requested workspace and passes only ids
220
+ downstream.
221
+
222
+ If any selector does not resolve, or resolves ambiguously, `refill_sends`
223
+ returns `sender_selector_unresolved` with the candidate sender list and does NOT
224
+ run. Never silently fall back to the active workspace's full fleet, and never
225
+ mutate a different workspace than the one that owns the named sender. A name
226
+ that belongs to another workspace (for example running "damiano r" while Clover
227
+ is active) must fail loudly so the caller re-runs with the correct
228
+ `workspaceId`; it must never create prep jobs in the active workspace instead.
229
+ Resolve the workspace first (via `list_workspaces`/install mapping) when the
230
+ named sender is not present in the currently resolved workspace's fleet.
231
+
205
232
  Goal-mode continuation: a skill cannot create or invoke `/goal` by itself. When
206
233
  this command is already running inside an active Codex goal, keep that goal open
207
234
  until every selected sender lane is filled by projected coverage
@@ -278,16 +305,23 @@ Structured planner packet:
278
305
  - When that primitive is `run_scheduler_sweep`, show `workspaceWide:true` and
279
306
  its reason in the approval/yolo packet so the operator sees that the sweep
280
307
  may schedule other eligible workspace cells on the approved date.
281
- - `manualAlternates` are not yolo actions. Threshold lowering and campaign
282
- creation are manual continuations only.
308
+ - `manualAlternates` are not yolo actions. Threshold/floor lowering, NEW
309
+ campaign creation, source-family switches, and the capSaturated
310
+ `manual_source_replenishment` dead-end (needs human table cleanup) are manual
311
+ continuations only. Same-provider-source continuation
312
+ (`continue_sales_nav_source`, `continue_prospeo_source`) and
313
+ `start_paused_campaign` for a start-eligible campaign are `--yolo` primitives,
314
+ not manual alternates.
283
315
 
284
316
  Refill action ladder: approve generated rows only when an explicit bounded
285
317
  approval gate exists, process all existing same-campaign unenriched/unprepared
286
318
  rows in bounded batches before any source work, then copy bounded net-new rows
287
319
  from the selected source (`selectedLeadListId`, provider, and source
288
- fingerprint preserved), then use provider-aligned source-more. A new source or
289
- provider switch changes the reply-rate baseline and is a manual alternate, not a
290
- `--yolo` side effect.
320
+ fingerprint preserved), then use provider-aligned same-source-more against the
321
+ campaign's OWN saved provider search (Sales Nav or Prospeo for cold campaigns).
322
+ Same-provider-source-more is a bounded `--yolo` add. A DIFFERENT source or a
323
+ source-family switch changes the reply-rate baseline and stays a manual
324
+ alternate, not a `--yolo` side effect.
291
325
  For Signal Discovery, consume bounded net-new eligible source rows first. If
292
326
  none remain and the campaign has no reusable tab keywords or unselected posts,
293
327
  derive at most five search keywords from its saved positive ICP criteria without