@sellable/mcp 0.1.739 → 0.1.741

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.
@@ -214,18 +214,21 @@ export type SchedulerChangedCounts = {
214
214
  }>;
215
215
  };
216
216
  export declare function normalizeSchedulerChangedCounts(value: unknown): SchedulerChangedCounts | null;
217
+ export declare function schedulerEnvelopeIsTerminal(schedulerStatus: string): boolean;
217
218
  export declare function normalizeSchedulerPrimitiveResult(value: unknown, expected?: {
218
219
  expectedTargets: SchedulerExpectedTargetInput[];
219
220
  expectedTargetsHash: string;
220
221
  maxPlacements: number;
221
222
  }): {
223
+ retryAfterMs: number;
224
+ changedCounts: null;
225
+ auditComplete: false;
226
+ replayedReceipt?: Record<string, unknown> | undefined;
222
227
  status: "matching_scheduler_active";
223
228
  schedulerStatus: string;
229
+ terminal: false;
224
230
  disposition: "bounded_reread_wait";
225
231
  receipt: null;
226
- retryAfterMs: number;
227
- changedCounts: null;
228
- auditComplete: false;
229
232
  blocker?: undefined;
230
233
  } | {
231
234
  status: string;
@@ -236,6 +239,7 @@ export declare function normalizeSchedulerPrimitiveResult(value: unknown, expect
236
239
  changedCounts: null;
237
240
  auditComplete: false;
238
241
  blocker: string;
242
+ terminal?: undefined;
239
243
  } | {
240
244
  status: "scheduler_receipt_incomplete";
241
245
  schedulerStatus: string;
@@ -245,8 +249,10 @@ export declare function normalizeSchedulerPrimitiveResult(value: unknown, expect
245
249
  auditComplete: false;
246
250
  blocker: "scheduler_receipt_incomplete";
247
251
  disposition?: undefined;
252
+ terminal?: undefined;
248
253
  } | {
249
254
  status: string;
255
+ terminal: boolean;
250
256
  receipt: {} | null;
251
257
  retryAfterMs: number | null;
252
258
  changedCounts: SchedulerChangedCounts;
@@ -274,18 +274,50 @@ function completeExpectedTargetSummary(value, expectedTargets, maxPlacements) {
274
274
  JSON.stringify(accounted) === JSON.stringify(target?.senderIds));
275
275
  });
276
276
  }
277
+ // fix(112ao-2): the scheduler's in-flight envelope statuses. A response with
278
+ // one of these describes a job that is still running — it never describes an
279
+ // outcome of this dispatch, no matter what it carries alongside.
280
+ const SCHEDULER_IN_FLIGHT_ENVELOPE_STATUSES = new Set(["accepted", "attached"]);
281
+ // The envelope statuses that report a real outcome for this dispatch and are
282
+ // therefore eligible for the receipt audit below.
283
+ const SCHEDULER_TERMINAL_ENVELOPE_STATUSES = new Set([
284
+ "ran",
285
+ "window_closed_noop",
286
+ ]);
287
+ export function schedulerEnvelopeIsTerminal(schedulerStatus) {
288
+ return SCHEDULER_TERMINAL_ENVELOPE_STATUSES.has(schedulerStatus);
289
+ }
277
290
  export function normalizeSchedulerPrimitiveResult(value, expected) {
278
291
  const response = recordValue(value);
279
292
  const receipt = recordValue(response?.receipt);
280
293
  const schedulerStatus = stringValue(response?.status) ?? stringValue(receipt?.status) ?? "unknown";
281
294
  const errorCode = stringValue(recordValue(response?.error)?.code);
282
- if (!receipt &&
283
- (schedulerStatus === "accepted" || schedulerStatus === "attached")) {
295
+ // fix(112ao-2): whether this dispatch finished is a total function of the
296
+ // ENVELOPE status, decided here before any receipt is read. The previous
297
+ // guard was `!receipt && (accepted || attached)`, which made completion
298
+ // depend on whether a field happened to be present: the scheduler may attach
299
+ // an in-flight response to an already-running job and replay THAT job's
300
+ // receipt, so an `attached` envelope arrived carrying complete, v3-auditable
301
+ // counters from a prior run. It skipped this branch, passed the audit on
302
+ // that stale evidence, and returned auditComplete: true — the coordinator
303
+ // read a finished sweep and redispatched the run it should have awaited
304
+ // (Dotwork 2026-07-29: 21 exact runs, coverage frozen at sent:0/scheduled:0).
305
+ //
306
+ // `accepted`/`attached` are IN-FLIGHT; `ran`/`window_closed_noop` are
307
+ // TERMINAL and continue to the audit below. `backoff` and `failed` keep their
308
+ // existing handling — the run loop already unwraps a backoff envelope's
309
+ // replayed receipt deliberately (schedulerRunReceiptIsFreshZeroScheduled).
310
+ if (SCHEDULER_IN_FLIGHT_ENVELOPE_STATUSES.has(schedulerStatus)) {
284
311
  return {
285
312
  status: "matching_scheduler_active",
286
313
  schedulerStatus,
314
+ terminal: false,
287
315
  disposition: "bounded_reread_wait",
288
316
  receipt: null,
317
+ // A prior run's counters are never this dispatch's effect. Surfaced
318
+ // under a distinct name so it can be read for diagnostics but can never
319
+ // be mistaken for a fresh outcome.
320
+ ...(receipt ? { replayedReceipt: receipt } : {}),
289
321
  retryAfterMs: numberValue(response?.retryAfterMs) ?? 30_000,
290
322
  changedCounts: null,
291
323
  auditComplete: false,
@@ -329,6 +361,9 @@ export function normalizeSchedulerPrimitiveResult(value, expected) {
329
361
  }
330
362
  return {
331
363
  status: schedulerStatus,
364
+ // fix(112ao-2): the completion decision, carried explicitly instead of
365
+ // leaving each consumer to re-infer it from the status string.
366
+ terminal: schedulerEnvelopeIsTerminal(schedulerStatus),
332
367
  receipt: response?.receipt ?? null,
333
368
  retryAfterMs: numberValue(response?.retryAfterMs),
334
369
  changedCounts,
@@ -1410,7 +1410,8 @@ function sanitizeSchedulerExpectedTargets(value, scope) {
1410
1410
  branchActionTypes.length === 0 ||
1411
1411
  !scope.campaignIds.includes(campaignId) ||
1412
1412
  !scope.tableIds.includes(tableId) ||
1413
- senderIds.some((senderId) => !scope.senderIds.includes(senderId))) {
1413
+ senderIds.some((senderId) => !scope.senderIds.includes(senderId)) ||
1414
+ senderIds.some((senderId) => !scope.laneSelected(senderId, refillLaneKey))) {
1414
1415
  return null;
1415
1416
  }
1416
1417
  expectedTargets.push({
@@ -1423,6 +1424,28 @@ function sanitizeSchedulerExpectedTargets(value, scope) {
1423
1424
  }
1424
1425
  return expectedTargets;
1425
1426
  }
1427
+ // fix(112ao-3): sweep ELIGIBILITY is server-authoritative. This sanitizer
1428
+ // validates PROVENANCE and SHAPE — identity, revisions, date format, scope
1429
+ // containment, side-effect class, receipt requirements — and no longer
1430
+ // re-derives which lanes qualify for a sweep.
1431
+ //
1432
+ // The old contract required the server's emitted sender/lane set to EQUAL a
1433
+ // set this function re-derived from senderPlans, so any disagreement in
1434
+ // EITHER direction discarded the entire sweep:
1435
+ // - client stricter than server (112ak-s: `remainingReadyOrProjectedGap === 0`)
1436
+ // silently vetoed valid server sweeps until 112ak-s2 relaxed the copy;
1437
+ // - client looser than server (112ao-3) does exactly the same damage, because
1438
+ // the server excludes lanes for facts senderPlans never carries — an
1439
+ // exhausted per-date capacity probe (`schedulerFillableSlots === 0`), no
1440
+ // `selectedDay` on the target date, a non-structured lane, or a campaign
1441
+ // with no ready-holding ACTIVE source.
1442
+ // Keeping two copies of one predicate in sync across a release boundary is the
1443
+ // defect; relaxing the copy was only ever a truce. The client cannot see the
1444
+ // inputs the decision needs, so it must not make the decision.
1445
+ //
1446
+ // Scope containment is retained and is NOT eligibility: a sweep may only name
1447
+ // senders and lanes belonging to this run's selected cohort, so a drifted or
1448
+ // forged packet still fails closed.
1426
1449
  function sanitizeTargetSchedulerSweep(params) {
1427
1450
  if (!Array.isArray(params.value) ||
1428
1451
  !params.expectedWorkspaceId ||
@@ -1432,13 +1455,12 @@ function sanitizeTargetSchedulerSweep(params) {
1432
1455
  !params.expectedStateRevision) {
1433
1456
  return null;
1434
1457
  }
1435
- const expectedSenderIds = [...params.sweepEligibleBySender.keys()].sort();
1436
- const expectedActionTypes = [
1437
- ...new Set([...params.sweepEligibleBySender.values()].flatMap((lanes) => [...lanes])),
1438
- ].sort();
1439
- if (expectedSenderIds.length === 0 || expectedActionTypes.length === 0) {
1458
+ const selectedSenderIds = new Set(params.selectedBySender.keys());
1459
+ const selectedActionTypes = new Set([...params.selectedBySender.values()].flatMap((lanes) => [...lanes]));
1460
+ if (selectedSenderIds.size === 0 || selectedActionTypes.size === 0) {
1440
1461
  return null;
1441
1462
  }
1463
+ const laneSelected = (senderId, actionType) => params.selectedBySender.get(senderId)?.has(actionType) === true;
1442
1464
  for (const rawAction of params.value) {
1443
1465
  if (!isRecord(rawAction) || rawAction.type !== "run_scheduler_sweep") {
1444
1466
  continue;
@@ -1451,7 +1473,7 @@ function sanitizeTargetSchedulerSweep(params) {
1451
1473
  const campaignIds = sortedUniqueStrings(input.campaignIds);
1452
1474
  const tableIds = sortedUniqueStrings(input.tableIds);
1453
1475
  const sourceScopes = sanitizeSchedulerSweepSourceScopes(input.sourceScopes);
1454
- const expectedTargets = sanitizeSchedulerExpectedTargets(input.expectedTargets, { senderIds, campaignIds, tableIds });
1476
+ const expectedTargets = sanitizeSchedulerExpectedTargets(input.expectedTargets, { senderIds, campaignIds, tableIds, laneSelected });
1455
1477
  const expectedTargetsHash = stringValue(input.expectedTargetsHash)?.trim();
1456
1478
  const maxPlacements = numberValue(input.maxPlacements);
1457
1479
  const receiptRequirements = isRecord(rawAction.receiptRequirements)
@@ -1474,10 +1496,18 @@ function sanitizeTargetSchedulerSweep(params) {
1474
1496
  input.stateRevision !== params.expectedStateRevision ||
1475
1497
  !actionKey ||
1476
1498
  requestKey !== actionKey ||
1477
- !sameStrings(senderIds, expectedSenderIds) ||
1478
- !sameStrings(actionTypes, expectedActionTypes) ||
1479
- (expectedActionTypes.length === 1 &&
1480
- rawActionType !== expectedActionTypes[0]) ||
1499
+ // Provenance, not eligibility: the sweep may only name senders and lanes
1500
+ // this run selected. Containment (not equality) lets the server exclude
1501
+ // a lane on facts the client cannot see without losing the whole sweep.
1502
+ senderIds.length === 0 ||
1503
+ senderIds.some((senderId) => !selectedSenderIds.has(senderId)) ||
1504
+ actionTypes.length === 0 ||
1505
+ actionTypes.some((actionType) => !selectedActionTypes.has(actionType)) ||
1506
+ // The server sets a top-level actionType only for a single-lane sweep,
1507
+ // derived from the packet's own actionTypes — so this stays a check of
1508
+ // the packet's internal consistency, at exactly the prior strictness
1509
+ // (multi-lane sweeps carry no top-level actionType and are not asserted).
1510
+ (actionTypes.length === 1 && rawActionType !== actionTypes[0]) ||
1481
1511
  campaignIds.length === 0 ||
1482
1512
  tableIds.length === 0 ||
1483
1513
  sourceScopes.length === 0 ||
@@ -1876,41 +1906,20 @@ export function sanitizeRefillTargetPlanResult(result) {
1876
1906
  const paidRefreshNeededSenderIdSet = paidRefreshNeededSenderIds(senderRefillPlans);
1877
1907
  const blockers = sanitizeBlockers(result.blockers, selectedKeys, paidRefreshNeededSenderIdSet);
1878
1908
  const request = isRecord(result.request) ? result.request : {};
1879
- // fix(112ak-s2): this is the CLIENT mirror of the server's dated-sweep gate,
1880
- // and it silently discarded the server's valid sweep. sanitizeTargetSchedulerSweep
1881
- // derives its expected senders/actions from this map and returns null when the
1882
- // map is empty, so a stricter rule here vetoes the server outright: Dotwork
1883
- // 2026-07-29 (five lanes, remainingProjectedGap 10, readyBuffer 4, rrpg 6) had
1884
- // the server emit a 20-placement sweep across all five senders, this loop
1885
- // marked zero lanes eligible, and every served plan came back with
1886
- // approve_messages at globalActionQueue[0] and no sweep at any rank. Because
1887
- // the coordinator consumes the sanitized plan, that blocked execution, not just
1888
- // display. 112ak-s relaxed the same predicate server-side
1889
- // (refill-target-plan.ts buildExactDateSchedulerSweepAction): a lane earns a
1890
- // sweep by HOLDING ready rows against an open dated slot, not by its ready
1891
- // buffer fully covering its gap. No target-date condition is needed to mirror
1892
- // the server's scoping — sanitizeTargetSchedulerSweep already refuses to run
1893
- // without an expectedTargetDate matching ^\d{4}-\d{2}-\d{2}$, so this path is
1894
- // explicit-date-only by construction. Deliberately the ONLY relaxed occurrence:
1895
- // the wait_for_scheduler append and the status derivation below both keep
1896
- // `remainingReadyOrProjectedGap === 0`, where full ready coverage is the
1897
- // correct and intended meaning.
1898
- const sweepEligibleBySender = new Map();
1899
- for (const plan of senderPlans) {
1900
- const senderId = stringValue(plan.senderId);
1901
- const actionType = allowedActionType(plan.actionType);
1902
- if (senderId &&
1903
- actionType &&
1904
- numberValue(plan.remainingProjectedGap) > 0 &&
1905
- numberValue(plan.readyBuffer) > 0) {
1906
- const lanes = sweepEligibleBySender.get(senderId) ?? new Set();
1907
- lanes.add(actionType);
1908
- sweepEligibleBySender.set(senderId, lanes);
1909
- }
1910
- }
1909
+ // fix(112ao-3): the client no longer re-derives sweep eligibility. It passes
1910
+ // the run's SELECTED cohort so sanitizeTargetSchedulerSweep can check scope
1911
+ // containment (provenance), and leaves the qualify/disqualify decision to
1912
+ // buildExactDateSchedulerSweepAction, which alone holds the per-date capacity
1913
+ // probe, selectedDays, and ready-supply campaign facts the decision needs.
1914
+ // 112ak-s2 relaxed a duplicated `remainingReadyOrProjectedGap === 0` copy
1915
+ // here after it vetoed valid Dotwork sweeps; the duplication itself was the
1916
+ // defect, since a client that is merely LOOSER than the server discards the
1917
+ // sweep just as completely. The two other occurrences below and above are
1918
+ // untouched and mean something different: the wait_for_scheduler append and
1919
+ // the status derivation both use full ready coverage correctly.
1911
1920
  const schedulerSweepAction = sanitizeTargetSchedulerSweep({
1912
1921
  value: target.globalActionQueue,
1913
- sweepEligibleBySender,
1922
+ selectedBySender,
1914
1923
  expectedWorkspaceId: stringValue(target.workspaceId),
1915
1924
  expectedTargetDate: stringValue(request.targetDate),
1916
1925
  expectedTargetShapeRevision: stringValue(result.targetShapeRevision),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.739",
3
+ "version": "0.1.741",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code, Codex, and Hermes campaign workflows",
6
6
  "main": "dist/index.js",
@@ -33,7 +33,9 @@ You are a pipeline supply agent. People who engage with a sender's LinkedIn post
33
33
  <inputs>
34
34
  The invoking prompt names the senders ("refresh sender engagement for csreyes92 and thomas"). Resolve each via `list_senders` (match name/handle/LinkedIn URL). With no names given, inspect the active workspace and refresh every connected sender that has an active/paused sender-owned Post Engagers campaign backed by Signal Discovery.
35
35
 
36
- Optional: target sender names/ids, `campaignId` when the user wants to force a specific Post Engagers campaign, `tableId` when the user wants to force a specific campaign table, maximum posts per sender (default 5, hard cap 5 unless the user explicitly asks for more), and maximum engager pages per tracked post.
36
+ Optional: target sender names/ids, `campaignId` when the user wants to force a specific Post Engagers campaign, `tableId` when the user wants to force a specific campaign table, `maxPosts` to refresh a specific number of posts, and maximum engager pages per tracked post.
37
+
38
+ Leave `maxPosts` unset unless the user asked for a specific number. By default the command refreshes every tracked post published inside the 30-day lookback window, which is what keeps recent posts checked often enough to catch new engagers. A post whose engagement counts have not moved costs a single cheap count probe and is skipped before any engager fetch, so a wider sweep is not a proportionally more expensive one. Passing `maxPosts` narrows that sweep.
37
39
 
38
40
  `campaignId` is optional. When it is omitted the command discovers the sender's Post Engagers campaign itself — it searches the workspace for campaigns the sender solely owns that are backed by a post-engager provider and named as a Post Engagers lane. A workspace can hold one such lane per sender, so discovery is always scoped to the sender you are refreshing; another sender's lane and shared multi-sender lanes are never selected. Do not pass a `campaignId` you guessed.
39
41
  </inputs>
@@ -84,7 +86,7 @@ For each target sender/campaign:
84
86
  - LinkedIn operations here are read-only fetches plus a typed product import into a campaign table. **No messages are generated, approved, scheduled, or sent by this skill.**
85
87
  - Respect workspace boundaries: only add leads to campaigns in the active workspace, and only for senders that belong to it.
86
88
  - Respect campaign boundaries: only add engagers to the matched sender-owned Post Engagers campaign/table. Do not mix shared-lane engagers into sender-owned campaigns or sender-owned engagers into shared lanes.
87
- - Cap provider usage per run: at most 5 posts × `fetch_post_engagers` per sender. If the invoking automation wants more, it must say so explicitly.
89
+ - Provider usage per run is bounded by the 30-day lookback window plus the count-probe short-circuit: unchanged posts cost one probe and fetch no engagers. Do not widen `maxEngagerPages` beyond what the user asked for.
88
90
  - Never call `start_campaign`, `attach_sequence`, `queue_campaign_cells`, `start_campaign_message_preparation`, approval tools, send/schedule tools, or shared-lane mutation tools.
89
91
  - Never create campaigns, change campaign status, change sender ownership, approve campaign cells, or start message prep as part of this refresh.
90
92
  </safety>