@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.
- package/dist/refill-run-loop.js +251 -2
- package/dist/tools/refill-executors.d.ts +13 -0
- package/dist/tools/refill-executors.js +123 -0
- package/dist/tools/refill-sends.d.ts +106 -0
- package/dist/tools/refill-sends.js +145 -9
- package/dist/tools/refill-target-plan.js +58 -58
- package/package.json +1 -1
- package/skills/refill-sends/SKILL.md +45 -11
package/dist/refill-run-loop.js
CHANGED
|
@@ -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:
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
331
|
-
"lower paid InMail thresholds",
|
|
332
|
-
"create connection
|
|
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
|
-
"
|
|
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
|
|
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(
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
654
|
-
const eligibleActionTypes = actionTypesFrom(row.eligibleActionTypes).filter((actionType) => actionType
|
|
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)
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
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()].
|
|
1351
|
-
const actionTypes = [...
|
|
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
|
|
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
|
|
1365
|
+
? selection.evidence.filter((item) => isRecord(item) && lanes.has(String(item.actionType)))
|
|
1368
1366
|
: [];
|
|
1369
1367
|
return {
|
|
1370
1368
|
...selection,
|
|
1371
|
-
actionTypes:
|
|
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.
|
|
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
|
@@ -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,
|
|
112
|
-
|
|
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
|
|
118
|
-
fallback
|
|
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
|
|
122
|
-
campaigns, launch, or send directly;
|
|
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
|
|
282
|
-
creation
|
|
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
|
|
289
|
-
|
|
290
|
-
`--yolo`
|
|
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
|