@sellable/mcp 0.1.112 → 0.1.114

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.
@@ -17,14 +17,22 @@ Use the inherited Sellable MCP tools when available:
17
17
  - `search_signals` to find recent post lanes. Include `campaignOfferId` whenever
18
18
  the parent provides one so selected searches/lists stay attached to the
19
19
  campaign.
20
- - `fetch_post_engagers` to sample engagers from selected posts.
20
+ - `select_promising_posts` to promote the exact posts you will sample into the
21
+ campaign UI before fetching engagers. In campaign-attached runs, do this
22
+ before the first `fetch_post_engagers` call so the user can see which posts
23
+ are being sampled.
24
+ - `fetch_post_engagers` to sample engagers from promoted/selected posts.
21
25
 
22
26
  Process:
23
27
 
24
28
  1. Read the campaign brief, kickoff doc, or lane prompt supplied by the parent.
25
29
  2. Search 3-5 keyword/topic lanes, favoring fresh posts from the last 7-14 days.
26
- 3. Select 3-5 promising posts when available.
27
- 4. Fetch or sample engagers for selected posts and score rough ICP fit from visible headline/display-name cues only. Do not enrich people during viability estimation.
30
+ 3. Select 3-5 promising posts when available. If a `campaignOfferId` was
31
+ supplied, call `select_promising_posts({ campaignOfferId, selectionMode:
32
+ "replace", selections, headlineICPCriteria, currentStep:
33
+ "signal-discovery" })` before sampling so the watched Signal Discovery table
34
+ shows the promoted posts.
35
+ 4. Fetch or sample engagers for promoted posts and score rough ICP fit from visible headline/display-name cues only. Do not enrich people during viability estimation.
28
36
  5. Estimate usable prospects per selected post from sampled pass rate. If the sample is good but volume is low, say how many more similar posts should be added or scraped.
29
37
  6. Return false positives and dead ends explicitly.
30
38
 
@@ -25,6 +25,9 @@ export function buildWatchUrl(config, path) {
25
25
  if (workspaceId && !url.searchParams.has("workspaceId")) {
26
26
  url.searchParams.set("workspaceId", workspaceId);
27
27
  }
28
+ if (config.token && !url.searchParams.has("token")) {
29
+ url.searchParams.set("token", config.token);
30
+ }
28
31
  return url.toString();
29
32
  }
30
33
  export function getCampaignBuilderWatchModeParam() {
@@ -81,7 +81,8 @@ export declare function getCampaignContext(input: GetCampaignContextInput): Prom
81
81
  };
82
82
  watchUrl: {
83
83
  urlPresent: boolean;
84
- direct: boolean | undefined;
84
+ direct: boolean;
85
+ autoLogin: boolean;
85
86
  redirectPath: string | null;
86
87
  };
87
88
  table: {
@@ -179,6 +179,7 @@ export function markCampaignContextDirty(campaignId, reason) {
179
179
  watchUrl: {
180
180
  urlPresent: false,
181
181
  direct: false,
182
+ autoLogin: false,
182
183
  redirectPath: null,
183
184
  },
184
185
  table: {
@@ -1047,7 +1047,7 @@ export const leadToolDefinitions = [
1047
1047
  },
1048
1048
  {
1049
1049
  name: "select_promising_posts",
1050
- description: "Select the most promising LinkedIn posts for lead scraping AND provide headline ICP criteria. Use the selectionTarget returned by search_signals (default 3).",
1050
+ description: "Select the most promising LinkedIn posts for lead scraping AND provide headline ICP criteria. Use the selectionTarget returned by search_signals (default 3). In campaign-attached sampling, call select_promising_posts first to promote the sampled posts into the watched UI before fetch_post_engagers.",
1051
1051
  inputSchema: {
1052
1052
  type: "object",
1053
1053
  properties: {
@@ -21,7 +21,7 @@ export const linkedinToolDefinitions = [
21
21
  },
22
22
  {
23
23
  name: "fetch_post_engagers",
24
- description: "Fetch people who engaged with a LinkedIn post (reactions and/or comments). Returns deduplicated engagers with name, headline, profileUrl, and source. Use 'comments' for lead magnets to get people who actively engaged. Uses smart rate limiting and parallel fetching.",
24
+ description: "Fetch people who engaged with a LinkedIn post (reactions and/or comments). Returns deduplicated engagers with name, headline, profileUrl, and source. Use 'comments' for lead magnets to get people who actively engaged. Uses smart rate limiting and parallel fetching. In campaign-attached Signal Discovery sampling, call select_promising_posts first to promote the sampled posts into the watched UI.",
25
25
  inputSchema: {
26
26
  type: "object",
27
27
  properties: {
@@ -81,7 +81,8 @@ export declare function computeCampaignNavigationStateFromCampaign(campaign: Cam
81
81
  };
82
82
  watchUrl: {
83
83
  urlPresent: boolean;
84
- direct: boolean | undefined;
84
+ direct: boolean;
85
+ autoLogin: boolean;
85
86
  redirectPath: string | null;
86
87
  };
87
88
  table: {
@@ -125,7 +126,8 @@ export declare function getCampaignNavigationState(input: GetCampaignNavigationS
125
126
  };
126
127
  watchUrl: {
127
128
  urlPresent: boolean;
128
- direct: boolean | undefined;
129
+ direct: boolean;
130
+ autoLogin: boolean;
129
131
  redirectPath: string | null;
130
132
  };
131
133
  table: {
@@ -385,7 +385,9 @@ function parseWatchUrl(watchUrl, campaignId) {
385
385
  if (!watchUrl) {
386
386
  return {
387
387
  urlPresent: false,
388
+ direct: false,
388
389
  signed: false,
390
+ autoLogin: false,
389
391
  redirectPath: null,
390
392
  warning: null,
391
393
  };
@@ -398,11 +400,15 @@ function parseWatchUrl(watchUrl, campaignId) {
398
400
  url.searchParams.get("mode") === "codex" ||
399
401
  url.searchParams.get("mode") === "watch");
400
402
  if (isDirectCampaignUrl) {
403
+ const autoLogin = Boolean(url.searchParams.get("token"));
401
404
  return {
402
405
  urlPresent: true,
403
406
  direct: true,
407
+ autoLogin,
404
408
  redirectPath: `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, ""),
405
- warning: null,
409
+ warning: autoLogin
410
+ ? null
411
+ : "Watch URL is direct but missing token auto-login. Recover a fresh /campaign-builder/{campaignId}?mode={claude|codex}&token=... watch link before asking the user to open it.",
406
412
  };
407
413
  }
408
414
  const redirect = url.searchParams.get("redirect");
@@ -413,18 +419,20 @@ function parseWatchUrl(watchUrl, campaignId) {
413
419
  return {
414
420
  urlPresent: true,
415
421
  direct: false,
422
+ autoLogin: false,
416
423
  redirectPath: decodedRedirect,
417
424
  warning: isLegacySigned
418
- ? "Watch URL still uses a tokenized /auth/continue handoff. Return the safe direct /campaign-builder/{campaignId}?mode={claude|codex} watch URL instead."
419
- : "Watch URL is not a safe direct /campaign-builder/{campaignId}?mode={claude|codex} watch link.",
425
+ ? "Watch URL still uses a tokenized /auth/continue handoff. Return the direct /campaign-builder/{campaignId}?mode={claude|codex}&token=... watch URL instead."
426
+ : "Watch URL is not a direct /campaign-builder/{campaignId}?mode={claude|codex}&token=... watch link.",
420
427
  };
421
428
  }
422
429
  catch {
423
430
  return {
424
431
  urlPresent: true,
425
432
  direct: false,
433
+ autoLogin: false,
426
434
  redirectPath: null,
427
- warning: "Watch URL is malformed; recover a fresh safe direct campaign-builder watch link before browser handoff.",
435
+ warning: "Watch URL is malformed; recover a fresh direct campaign-builder auto-login watch link before handing it to the user.",
428
436
  };
429
437
  }
430
438
  }
@@ -593,6 +601,7 @@ export function computeCampaignNavigationStateFromCampaign(campaign, options) {
593
601
  watchUrl: {
594
602
  urlPresent: watchUrlState.urlPresent,
595
603
  direct: watchUrlState.direct,
604
+ autoLogin: watchUrlState.autoLogin,
596
605
  redirectPath: watchUrlState.redirectPath,
597
606
  },
598
607
  table: {
@@ -134,7 +134,8 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
134
134
  };
135
135
  watchUrl: {
136
136
  urlPresent: boolean;
137
- direct: boolean | undefined;
137
+ direct: boolean;
138
+ autoLogin: boolean;
138
139
  redirectPath: string | null;
139
140
  };
140
141
  table: {
@@ -183,7 +184,8 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
183
184
  };
184
185
  watchUrl: {
185
186
  urlPresent: boolean;
186
- direct: boolean | undefined;
187
+ direct: boolean;
188
+ autoLogin: boolean;
187
189
  redirectPath: string | null;
188
190
  };
189
191
  table: {
@@ -234,7 +236,8 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
234
236
  };
235
237
  watchUrl: {
236
238
  urlPresent: boolean;
237
- direct: boolean | undefined;
239
+ direct: boolean;
240
+ autoLogin: boolean;
238
241
  redirectPath: string | null;
239
242
  };
240
243
  table: {
@@ -204,7 +204,7 @@ export const rubricToolDefinitions = [
204
204
  },
205
205
  {
206
206
  name: "save_rubrics",
207
- description: "Persist rubric criteria to the campaign. Pass leadScoringRubrics directly to save without drafting. Use update_campaign to enable ICP filtering and change steps.",
207
+ description: "Persist rubric criteria to the campaign. Pass leadScoringRubrics directly to save without drafting. Saving active rubrics enables ICP filtering on the campaign so the client can continue to Filter Leads.",
208
208
  inputSchema: {
209
209
  type: "object",
210
210
  properties: {
@@ -355,7 +355,7 @@ export const rubricToolDefinitions = [
355
355
  },
356
356
  minPassedCount: {
357
357
  type: "number",
358
- description: "Optional pass floor for bounded create-campaign samples. When this floor is met and remaining target rows are resolved as failed/not processing, the tool returns ready:true with partial:true instead of forcing a timeout.",
358
+ description: "Optional pass floor for bounded create-campaign samples. When this floor is met, the tool returns ready:true with partial:true instead of waiting for every target row to finish.",
359
359
  },
360
360
  timeoutMs: {
361
361
  type: "number",
@@ -686,11 +686,13 @@ export async function waitForRubricResults(input) {
686
686
  stats,
687
687
  };
688
688
  }
689
- if (minPassFloorMet && noActiveProcessing) {
689
+ if (minPassFloorMet) {
690
690
  const percent = completed > 0 ? Math.round((passed / completed) * 100) : 0;
691
- const reason = unresolvedRowsResolvedAsFailures
692
- ? "min_passed_count_met_with_resolved_failures"
693
- : "min_passed_count_met_no_active_processing";
691
+ const reason = !noActiveProcessing
692
+ ? "min_passed_count_met_with_active_processing"
693
+ : unresolvedRowsResolvedAsFailures
694
+ ? "min_passed_count_met_with_resolved_failures"
695
+ : "min_passed_count_met_no_active_processing";
694
696
  const rowSnapshot = includeRows
695
697
  ? await getTableRowsMinimal(tableId, {
696
698
  limit: effectiveTarget,
@@ -6,9 +6,13 @@ function sanitizeWatchUrlValue(value) {
6
6
  if (redirect) {
7
7
  const direct = new URL(decodeURIComponent(redirect), url.origin);
8
8
  const workspaceId = url.searchParams.get("workspaceId");
9
+ const token = url.searchParams.get("token");
9
10
  if (workspaceId && !direct.searchParams.has("workspaceId")) {
10
11
  direct.searchParams.set("workspaceId", workspaceId);
11
12
  }
13
+ if (token && !direct.searchParams.has("token")) {
14
+ direct.searchParams.set("token", token);
15
+ }
12
16
  return direct.toString();
13
17
  }
14
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.112",
3
+ "version": "0.1.114",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -134,6 +134,12 @@ claim they ran in parallel and do not surface install status to the customer. In
134
134
  chat, call the downstream copy stage `message generation`;
135
135
  `message-validation.md` is only an internal proof artifact.
136
136
 
137
+ For campaign-attached Signal Discovery sampling, promote/select the exact posts
138
+ with `select_promising_posts` before `fetch_post_engagers` so the user can see
139
+ which posts are being sampled in the watched app. The watch guide should say
140
+ that we are pulling sample engagers from these posts to confirm the ICP is
141
+ actually engaging and the source is viable.
142
+
137
143
  After find-leads returns a lead source and the user approves it, use the same
138
144
  registry pattern for the two post-lead branches. The create-campaign-v2 subskill
139
145
  calls `get_post_find_leads_scout_registry`, then launches the returned
@@ -159,45 +165,42 @@ linked skill versions, runbooks, npm/package details, repo-local files, VPS or
159
165
  browser automation limitations, or local skill files in normal customer-facing
160
166
  copy.
161
167
 
162
- ## Codex Watch Browser Handoff
168
+ ## Codex Watch Link Handoff
163
169
 
164
- When a campaign tool returns `watchUrl`, treat it as an active browser handoff,
165
- not only a URL to print. A valid handoff link must be a safe direct
166
- `/campaign-builder/{campaignId}?mode=claude` URL. `create_campaign.watchUrl`,
170
+ When a campaign tool returns `watchUrl`, treat it as a user-opened app link, not
171
+ as permission to drive the browser. A valid handoff link must be a direct
172
+ `/campaign-builder/{campaignId}?mode=claude|codex` URL with the auth token and
173
+ workspace routing needed for auto-login. `create_campaign.watchUrl`,
167
174
  `create_campaign({ campaignId }).watchUrl`, and `get_campaign.watchUrl` are all
168
- acceptable only when they return that direct campaign-builder shape, with
169
- `workspaceId` used only as a safe routing hint when needed.
170
-
171
- In Codex Desktop, when in-app browser control is available, open the returned watch link on the user's behalf. After opening it, inspect the browser-visible campaign state, then explain what the user is seeing before continuing with more campaign tools. Use customer language such as:
172
-
173
- ```text
174
- I’ll open the campaign view and keep it in sync as I build.
175
- ```
175
+ acceptable only when they return that direct campaign-builder shape.
176
176
 
177
- If browser control is unavailable, provide the watch link without discussing the
178
- runtime limitation. In off-desktop Codex runs, make the direct watch link easy
179
- to copy/open, then continue with explicit customer-facing campaign progress and
180
- `get_campaign_navigation_state` orientation checks.
181
- Use this fallback shape:
177
+ Never call browser-opening tools, shell `open`, Computer Use, or in-app browser
178
+ automation just because a watch link exists. Print the link and tell the user to
179
+ Command-Enter/click it when they want to watch in the app. Use this shape:
182
180
 
183
181
  ```text
184
182
  Watch link: {watchUrl}
185
183
 
186
- I’ll keep the campaign brief, lead source, and message-review steps explicit
187
- here as I build.
184
+ Command-Enter or click that link to watch the campaign in Sellable. I’ll keep
185
+ the brief, lead source, and message-review steps explicit here as I build.
188
186
  ```
189
187
 
190
- If opening the watch link lands on an auth, 404, permission, blank, or visible
191
- error state, report only what is visible, recover a fresh watch link once with
192
- `create_campaign({ campaignId })` or `get_campaign`, and try that link. Do not
193
- claim the browser was opened, inspected, or synchronized unless the visible
194
- browser state was actually observed. Do not claim the browser was opened unless
195
- it was opened and observed.
188
+ The watch link should auto-login through the token in the URL. If the user says
189
+ the link lands on auth, 404, permission, blank, or a visible error state, recover
190
+ a fresh watch link once with `create_campaign({ campaignId })` or `get_campaign`
191
+ and print that link. Do not claim the browser was opened, inspected, or
192
+ synchronized.
196
193
 
197
194
  After every `update_campaign({ campaignId, currentStep })`, use
198
195
  `get_campaign_navigation_state` when available as a compact orientation check:
199
- match the browser-visible step to the saved campaign state, explain the current
200
- state in one sentence, and only then continue. Sender selection belongs at Settings after message approval and 10-row validation. After message validation, use Settings to help the user connect or select a LinkedIn sender. Explain Slack reply review before launch. After sender selection, attach the recommended sequence and move the watched UI to Send. Do not start the campaign or trigger a live send unless the user explicitly confirms that launch action outside UAT.
196
+ match the saved campaign state to the expected watch-link step, explain the
197
+ current state in one sentence, and only then continue. Sender selection belongs
198
+ at Settings after message approval and 10-row validation. After message
199
+ validation, use Settings to help the user connect or select a LinkedIn sender.
200
+ Explain Slack reply review before launch. After sender selection, attach the
201
+ recommended sequence and move the watched UI to Send. Do not start the campaign
202
+ or trigger a live send unless the user explicitly confirms that launch action
203
+ outside UAT.
201
204
 
202
205
  ## Names To Use
203
206
 
@@ -186,8 +186,10 @@ Optional debug/UAT draft directory, disabled in normal customer runs:
186
186
  `references/watch-guide-narration.md` for the compact screen contract. Do not
187
187
  send separate guide headline/body args. In Find Leads, the static stage label
188
188
  is not enough: name the active lane/provider, what search or sample you are
189
- trying, and why it fits this campaign. Do not promise time estimates in guide
190
- copy.
189
+ trying, and why it fits this campaign. When the source recommendation and
190
+ counts/sample quality are ready in chat, update the guide to tell the user to
191
+ review and approve the source in Codex/Claude instead of saying `I'll show`
192
+ the recommendation later. Do not promise time estimates in guide copy.
191
193
  - Every approval gate must include live campaign access after the readable inline
192
194
  content. Show a short `Watch link:` line with the campaign link when a
193
195
  campaign shell exists. In normal customer runs, do not show `Open artifact:`
@@ -580,6 +582,21 @@ workstreams`, `in parallel`, or `background` unless parallel branches were
580
582
  `attach_recommended_sequence`, `attach_sequence`, and `start_campaign`.
581
583
  `create_campaign`, `update_campaign`, and `save_rubrics` are allowed only when
582
584
  the active `flow.v2.json` step allows them.
585
+ - When source approval moves into import, keep chat and watch narration in the
586
+ same moment. If chat says import is starting, send `watchNarration` with
587
+ `stage: "review-batch"`, current-tense copy such as `Importing the review
588
+ batch`, and a no-launch safety note. Do not leave the guide saying
589
+ `source approved` or `I'll show the review-batch outcome` once import is
590
+ starting.
591
+ - After Lead Fit Builder saves rubrics, move the watched browser to Filter
592
+ Leads before waiting for message work to finish. Persist
593
+ `enableICPFilters: true`, `currentStep: "apply-icp-rubric"`, and
594
+ `watchNarration.stage: "fit-message"` so the user can see fit filtering
595
+ happen while the first message sample finishes. After message approval,
596
+ persist `useMessagingTemplate: true` and keep `enableICPFilters: true`;
597
+ sample validation then runs the review-batch cascade, and the user should be
598
+ walked through fit results, generated message results, and
599
+ Settings/sender/sequence handoff.
583
600
  - During pre-import validation, do not call `check_rubric`; use the lead-filter
584
601
  artifacts and only use campaign-backed scoring after Step 13 imports the
585
602
  15-lead test batch.
@@ -788,7 +805,9 @@ Required behavior:
788
805
  `lead-review.md` focused on source evidence, not host-agent availability.
789
806
  - Branch A: LinkedIn Engagement / active LinkedIn posts (internal provider:
790
807
  Signals / `signal-discovery`). Search relevant keyword lanes, review
791
- finalist posts, fetch top-post engagers, and estimate warm-fit volume.
808
+ finalist posts, promote the sampled posts with `select_promising_posts`
809
+ before fetching engagers when a campaign shell exists, fetch top-post
810
+ engagers, and estimate warm-fit volume.
792
811
  - Branch B: Sales Nav / title + company filters. Run preview filters, inspect
793
812
  preview rows, and estimate scalable-fit volume.
794
813
  - Branch C: Prospeo Contact / domains only when the campaign has a
@@ -1002,7 +1021,9 @@ lower confidence instead of presenting a precise estimate.
1002
1021
 
1003
1022
  For Signals, default to sampling a few promising posts first rather than trying
1004
1023
  to prove the entire source can scale before the user sees evidence. Pick 3-5
1005
- fresh, high-density posts when available, sample engagers, show fit rate, then
1024
+ fresh, high-density posts when available. In campaign-attached watch runs,
1025
+ promote those posts with `select_promising_posts` before sampling so the user
1026
+ sees which posts are being tested. Then sample engagers, show fit rate, and
1006
1027
  state how many additional posts could be added/scraped if that first sample is
1007
1028
  good but volume is low.
1008
1029
 
@@ -330,11 +330,12 @@
330
330
  "campaignBrief"
331
331
  ],
332
332
  "watchUrlSource": "create_campaign.watchUrl",
333
- "requiredWatchUrlShape": "direct /campaign-builder/{campaignId}?mode={claude|codex} watch URL",
333
+ "requiredWatchUrlShape": "direct /campaign-builder/{campaignId}?mode={claude|codex} watch URL with token auto-login and workspace routing",
334
334
  "codexBrowserHandoff": {
335
- "openWhenAvailable": true,
336
- "inspectBeforeContinuing": true,
337
- "explainVisibleStateBeforeContinuing": true,
335
+ "openWhenAvailable": false,
336
+ "printWatchLinkOnly": true,
337
+ "tellUserCommandEnterOrClick": true,
338
+ "mustNotUseBrowserAutomation": true,
338
339
  "fallbackMustNotClaimInspection": true,
339
340
  "recoverFreshSignedLinkOnceOnOpenFailure": true
340
341
  },
@@ -540,7 +541,7 @@
540
541
  "uploadedDomains": "prospeo"
541
542
  },
542
543
  "when": "before_background_source_scouts",
543
- "rule": "Choose the likely primary visible source lane from source intake, brief preference, or the best first lane the main thread will actually search. Send watchNarration with stage find-leads that says what search was tried or is being tried next, what sample is being checked, and why it helps this campaign. Do this before waiting on background scouts so watch mode never sits on only Pick Provider while source work happens elsewhere."
544
+ "rule": "Choose the likely primary visible source lane from source intake, brief preference, or the best first lane the main thread will actually search. Send watchNarration with stage find-leads that says what search was tried or is being tried next, what sample is being checked, and why it helps this campaign. For Signal Discovery sampling, promote/select the posts with select_promising_posts before fetch_post_engagers so the watched table shows the exact posts being sampled; the guide copy should say Codex is pulling sample engagers from these posts to confirm the ICP is actually engaging and the source is viable. Do this before waiting on background scouts so watch mode never sits on only Pick Provider while source work happens elsewhere."
544
545
  },
545
546
  {
546
547
  "action": "run_first_campaign_attached_source_search",
@@ -744,6 +745,7 @@
744
745
  "artifactLinkTiming": "before_next_step_or_revision_question",
745
746
  "doNotCompressToSummaryOnly": false,
746
747
  "doNotRenderArtifactLinksOnly": true,
748
+ "sourceRecommendationReadyWatchRule": "When the source recommendation decision card is ready in chat with counts and sample quality, update watchNarration to a find-leads chat-handoff frame. Use a headline like `Review the source in Codex`, body copy that says the browser is showing the evaluated source/results, and nextAction like `Approve in Codex`. Do not keep future-tense copy like `I'll show a source recommendation` after the decision is visible. Include a safety note that no leads import until the user approves the source.",
747
749
  "chatRenderRule": "Show a slim rendered-Markdown decision summary only, never a fenced code block. The first sentence must make the decision explicit: 'I recommend {primary source} using {exact filter/source recipe}. The runner-up is {source} because {reason}.' Use indexed sections and short bullets: recommendation, Primary source and filters, Runner-up sources, why it won, Quick numbers with one provider/source angle per bullet, raw volume, sampled fit count as n/N only (no percentages), estimated good-fit range after cleanup, activity/warmth basis, confidence note, 3-5 representative sample leads, and one tradeoff. Do not forecast connection acceptance rates, reply rates, meetings, pipeline, revenue, or ROI unless the user supplied verified benchmark data for this exact workspace/sender. If Signals was searched or considered, include two compact inline Markdown tables before the recommendation is treated as final: Signal keyword lanes with keyword lane, timeframe, posts found, and finalist posts reviewed; and LinkedIn posts sampled with post URL/title, author/topic, age, engagers, sampled engagers, good fits as n/N only, estimated usable prospects per post, and use/discard decision. Default to selecting a few promising Signals posts for the first sample instead of trying to prove full Signals scale up front; if the sample is good but volume is low, say how many more posts to add/scrape next. Do not skip or discard Signals based only on raw post count or vibes; show the post-level math first, or explicitly say no engagers could be fetched and lower confidence. Keep discarded paths, full sample rows, and lead-sample.json details in lead-review.md. Do not show plain filesystem paths unless links cannot be created."
748
750
  },
749
751
  {
@@ -760,6 +762,7 @@
760
762
  "selectedLeadListId stays the source list and workflowTableId is the campaign table"
761
763
  ],
762
764
  "watchNarrationRule": "Do not include time estimates. Say what review-batch work is happening and what the user will approve next.",
765
+ "sourceApprovedImportRule": "After source approval, if chat says import is starting, send review-batch watchNarration with current-tense import copy. Headline should be like `Importing the review batch`; body should say the browser is still showing the approved source leads while only the bounded 15-row review batch is imported. Do not use source-approved or future-tense outcome copy once import is starting.",
763
766
  "chatRenderRule": "Lead source is set. Next import and confirm only the first 15-row review batch into the campaign table. After workflowTableId exists, run the fit-filter and message-generation workstreams from the campaign table sample. If real parallel MCP/tool branches or host subagents were actually launched, say so; otherwise say the branches will run sequentially. Never claim parallelism unless parallel execution actually started."
764
767
  },
765
768
  {
@@ -790,6 +793,7 @@
790
793
  "leadSourceProvider",
791
794
  "providerSearchAssociation",
792
795
  "currentStep: primary provider step (signal-discovery, sales-nav, prospeo/contact-search, saved-lists, or leads)",
796
+ "watchNarration: find-leads source recommendation ready, or review-batch import starting when source approval already happened",
793
797
  "selectedLeadListId as source list id only for existing-list or supplied-list preview"
794
798
  ],
795
799
  "fallback": "Stop if campaignId is missing; the source must be attached to the existing CampaignOffer before import.",
@@ -870,7 +874,8 @@
870
874
  "onMissingCampaignAttachedSource": "stop_before_import_and_route_to_find_leads; source scouts must attach searches/selections with campaignOfferId before Step 13"
871
875
  },
872
876
  {
873
- "action": "watch_mode_orient"
877
+ "action": "watch_mode_orient",
878
+ "watchNarrationRule": "Before import_leads or confirm_lead_list starts, align the guide with chat by setting review-batch watchNarration to current-tense import copy. Use a headline like `Importing the review batch`; explain that the browser may still show the approved source leads while Codex imports only the bounded 15-row review batch; include a no-launch safety note."
874
879
  },
875
880
  {
876
881
  "tool": "import_leads",
@@ -1039,17 +1044,17 @@
1039
1044
  "fallback": "If real parallel branches are unavailable or the named agents are absent, run filter-leads and then message-generation in the parent thread with product MCP tools/assets. Do not customer-surface agent install status, and do not claim background or parallel work in that fallback."
1040
1045
  },
1041
1046
  {
1042
- "action": "wait_for_post_lead_artifacts",
1047
+ "action": "wait_for_lead_filter_artifact",
1043
1048
  "requiredArtifacts": [
1044
- "lead-filter.md",
1045
- "message-validation.md"
1049
+ "lead-filter.md"
1046
1050
  ],
1047
1051
  "optionalArtifacts": [
1048
1052
  "rubric.json",
1053
+ "message-validation.md",
1049
1054
  "message-prep.md",
1050
1055
  "message-candidate-drafts.md"
1051
1056
  ],
1052
- "reconciliationRule": "Before entering message-review, verify lead-filter.md and message-validation.md both came from the same brief.md, lead-review.md, and lead-sample.json. lead-filter.md gates the sample rows; lead-sample.json remains the message sample source."
1057
+ "rule": "Do not wait for message-validation.md before saving rubrics and moving the watched browser to Filter Leads. Message work may still be running while the user watches fit filtering."
1053
1058
  },
1054
1059
  {
1055
1060
  "action": "save_filter_rubrics_to_campaign",
@@ -1069,6 +1074,39 @@
1069
1074
  ],
1070
1075
  "writesCampaignState": "leadScoringRubrics",
1071
1076
  "requiredBeforeCascade": true
1077
+ },
1078
+ {
1079
+ "action": "advance_watch_to_filter_leads_after_rubrics_saved",
1080
+ "tool": "update_campaign",
1081
+ "requires": [
1082
+ "campaignId",
1083
+ "workflowTableId",
1084
+ "leadScoringRubrics"
1085
+ ],
1086
+ "requiredValues": {
1087
+ "currentStep": "apply-icp-rubric",
1088
+ "enableICPFilters": true,
1089
+ "watchNarration.stage": "fit-message"
1090
+ },
1091
+ "watchNarrationRule": "Headline should be like `Filtering the review batch`. Body should say the browser is on Filter Leads, Codex saved the fit rules, and the review rows are being checked while the first message sample finishes. Next should point to message review.",
1092
+ "when": "after_save_rubrics_succeeds_before_waiting_for_message_validation",
1093
+ "writesCampaignState": "currentStep:apply-icp-rubric"
1094
+ },
1095
+ {
1096
+ "tool": "get_campaign_navigation_state",
1097
+ "purpose": "confirm the watched UI moved to Filter Leads after rubrics saved",
1098
+ "optional": true
1099
+ },
1100
+ {
1101
+ "action": "wait_for_message_validation_artifact",
1102
+ "requiredArtifacts": [
1103
+ "message-validation.md"
1104
+ ],
1105
+ "optionalArtifacts": [
1106
+ "message-prep.md",
1107
+ "message-candidate-drafts.md"
1108
+ ],
1109
+ "reconciliationRule": "Before entering message-review, verify message-validation.md came from the same brief.md, lead-review.md, lead-sample.json, and saved lead-filter.md. lead-filter.md gates the sample rows; lead-sample.json remains the message sample source."
1072
1110
  }
1073
1111
  ],
1074
1112
  "requiredArtifacts": [
@@ -1090,6 +1128,8 @@
1090
1128
  "get_subskill_asset",
1091
1129
  "get_post_find_leads_scout_registry",
1092
1130
  "save_rubrics",
1131
+ "update_campaign",
1132
+ "get_campaign_navigation_state",
1093
1133
  "Task",
1094
1134
  "spawn_agent",
1095
1135
  "AskUserQuestion",
@@ -1106,7 +1146,6 @@
1106
1146
  "create_campaign",
1107
1147
  "import_leads",
1108
1148
  "confirm_lead_list",
1109
- "update_campaign",
1110
1149
  "queue_cells",
1111
1150
  "start_campaign",
1112
1151
  "check_rubric",
@@ -1114,7 +1153,7 @@
1114
1153
  "enrich_with_prospeo",
1115
1154
  "bulk_enrich_with_prospeo"
1116
1155
  ],
1117
- "watchRequired": false,
1156
+ "watchRequired": true,
1118
1157
  "waitFor": [
1119
1158
  "post_lead_workstreams_ready",
1120
1159
  "revise_leads",
@@ -1496,6 +1535,8 @@
1496
1535
  ],
1497
1536
  "requiredValues": {
1498
1537
  "currentStep": "validate-sample",
1538
+ "enableICPFilters": true,
1539
+ "useMessagingTemplate": true,
1499
1540
  "watchNarration.stage": "review-ready"
1500
1541
  },
1501
1542
  "when": "after_update_campaign_brief_succeeds",
@@ -1939,7 +1980,7 @@
1939
1980
  },
1940
1981
  {
1941
1982
  "tool": "wait_for_campaign_table_ready",
1942
- "purpose": "wait_for_bounded_review_batch_cascade_to_drain"
1983
+ "purpose": "wait_for_review_batch_cascade_to_start_returning_filter_results"
1943
1984
  },
1944
1985
  {
1945
1986
  "tool": "get_rows_minimal",
@@ -1954,11 +1995,12 @@
1954
1995
  "minPassedCount"
1955
1996
  ],
1956
1997
  "targetCountSource": "stats.totalRows_or_imported_batch_count",
1957
- "minPassedCountSource": "sample.minProjectedPass (3)",
1998
+ "minPassedCountSource": "firstPassingRowForMessageStart (1)",
1958
1999
  "requiredValues": {
1959
- "includeRows": false
2000
+ "includeRows": false,
2001
+ "minPassedCount": 1
1960
2002
  },
1961
- "note": "The shell-first flow tests 15 leads first; always pass cohortSize explicitly instead of relying on default 25 behavior. Pass minPassedCount so a 15-row batch with resolved failures can advance when the 3-pass floor is already met.",
2003
+ "note": "The shell-first flow tests 15 leads first; always pass cohortSize explicitly instead of relying on default 25 behavior. Pass minPassedCount=1 so the first passing filtered row unblocks Generate Message observation instead of waiting for every sample row to finish.",
1962
2004
  "readVia": "stats_only_tool_result",
1963
2005
  "extractFields": [
1964
2006
  "ready",
@@ -1975,7 +2017,7 @@
1975
2017
  {
1976
2018
  "action": "handle_partial_or_timeout_sample",
1977
2019
  "when": "wait_for_rubric_results.ready_false_or_reason_timeout",
1978
- "rule": "Do not repeat waits indefinitely. If active processing is visible, wait once more at most. Otherwise treat returned passRate/stats as a partial sample and stop before Settings with sample_revision_required when passed rows are below minProjectedPass or generated messages are incomplete.",
2020
+ "rule": "Do not repeat waits indefinitely. If at least one row has passed, move to Step 15 to observe or queue Generate Message for passing rows even when other sample rows are still processing. If zero rows have passed and active processing is visible, wait once more at most. Otherwise treat returned passRate/stats as a partial sample and stop before Settings with sample_revision_required.",
1979
2021
  "customerStatus": "sample-needs-revision",
1980
2022
  "showFields": [
1981
2023
  "passRate.completed",
@@ -2024,6 +2066,7 @@
2024
2066
  "revision_round_persists_across_resume",
2025
2067
  "wait_for_rubric_results_never_retain_rows_payload_in_tail_context",
2026
2068
  "wait_for_rubric_results_targetCount_always_explicit",
2069
+ "first_passing_row_unblocks_generate_message_observation",
2027
2070
  "timeout_never_repeats_without_customer_handoff",
2028
2071
  "timeout_or_underfloor_sample_never_advances_to_settings",
2029
2072
  "review_batch_cascade_waits_for_saved_rubrics_and_approved_template"
@@ -9,7 +9,9 @@ on every revision round.
9
9
  We spend a bounded review batch (default 15 rows) to prove fit before the
10
10
  user spends credits on hundreds more leads. The sample loop has one job:
11
11
  answer the question "do we have enough real passing examples for the user to
12
- judge this campaign?"
12
+ judge this campaign?" Message generation starts earlier: the first row that
13
+ passes filters is enough to begin observing or queueing Generate Message for
14
+ that passing row.
13
15
 
14
16
  If the answer is yes, proceed to Step 15 messaging for the review batch. If
15
17
  the answer is no, diagnose whether the brief is wrong or the list is wrong,
@@ -48,13 +50,16 @@ auto-revise leads.
48
50
 
49
51
  5. check_rubric(sample)
50
52
 
51
- 6. wait_for_rubric_results(sample, targetCount = <cohortSize>)
53
+ 6. wait_for_rubric_results(sample, targetCount = <cohortSize>, minPassedCount = 1)
52
54
  - cohortSize = stats.totalRows of the enrichment batch, or the
53
55
  imported batch count
54
56
  - default targetCount=15 matches the default review batch, but pass the
55
57
  explicit batch count anyway so future larger expansion batches do not
56
58
  accidentally stop early
57
59
  (see §Known Tool Behaviors #3)
60
+ - minPassedCount=1 means one passing filtered row unblocks Step 15
61
+ Generate Message observation. Do not wait for all sample rows to finish
62
+ before messages start.
58
63
 
59
64
  7. call `wait_for_rubric_results` with `includeRows=false`; extract ONLY:
60
65
  - ready: boolean
@@ -71,11 +76,13 @@ auto-revise leads.
71
76
  8. if `ready=false` and `reason="timeout"`:
72
77
  - do NOT keep polling indefinitely
73
78
  - treat the returned passRate/stats as a partial sample
79
+ - if at least one row has passed filters, advance to Step 15 to observe or
80
+ queue Generate Message for currently passing rows
74
81
  - if active processing is visible (`processingCount > 0` or a similar
75
82
  running-cell stat), one additional wait is allowed
76
83
  - otherwise diagnose the partial sample now
77
- - if passed rows are below `sample.minProjectedPass` or generated messages
78
- are missing for passing rows, stop before Settings with:
84
+ - if zero rows have passed and there is no active processing, stop before
85
+ Settings with:
79
86
  `Status: sample-needs-revision`
80
87
  - show the concrete numbers: completed, passed, pending, pass percent, and
81
88
  message count when available
@@ -89,8 +96,9 @@ auto-revise leads.
89
96
  projectedPass = round(passInSample / sampleSize * importLimit)
90
97
 
91
98
  10. branch:
92
- if projectedPass >= minProjectedPass:
93
- proceed to Step 15 (auto-execute-messaging) with this review batch
99
+ if passInSample >= 1:
100
+ proceed to Step 15 (auto-execute-messaging) with currently passing rows
101
+ so Generate Message can start without waiting for the full sample
94
102
  else:
95
103
  diagnose (see Brief-vs-List Diagnosis below)
96
104
  revisionRound += 1
@@ -166,11 +174,12 @@ and partial stats such as 13/15 scored, 2 passing, 2 messages generated. That is
166
174
  enough to diagnose an underperforming sample. Waiting again without active
167
175
  processing makes the experience feel frozen.
168
176
 
169
- Workaround: treat timeout stats as a partial sample. If the pass count is below
170
- the configured floor or message generation has not covered the passing rows,
171
- stop at `Status: sample-needs-revision` before Settings. Show the completed /
172
- passed / pending counts and ask whether to revise source, revise filter/rubric,
173
- or wait once only if active processing is still visible.
177
+ Workaround: treat timeout stats as a partial sample. If at least one row has
178
+ passed, move to Step 15 and observe or queue Generate Message for the passing
179
+ rows. If zero rows have passed and no active processing is visible, stop at
180
+ `Status: sample-needs-revision` before Settings. Show the completed / passed /
181
+ pending counts and ask whether to revise source, revise filter/rubric, or wait
182
+ once only if active processing is still visible.
174
183
 
175
184
  ## Projected Pass Math
176
185
 
@@ -67,9 +67,9 @@ Signal Discovery:
67
67
  ```json
68
68
  {
69
69
  "stage": "find-leads",
70
- "headline": "Testing Signal Discovery",
71
- "visibleState": "You are watching Signal Discovery search posts about Claude Code and GTM automation.",
72
- "agentIntent": "Codex is sampling engagers to see if they match the founder/operator campaign brief.",
70
+ "headline": "Testing warm LinkedIn activity",
71
+ "visibleState": "The promoted posts are the ones being sampled now.",
72
+ "agentIntent": "Codex is pulling sample engagers from these posts to confirm the ICP is actually engaging and that this source is viable.",
73
73
  "nextAction": "Review batch"
74
74
  }
75
75
  ```
@@ -86,6 +86,24 @@ Search iteration:
86
86
  }
87
87
  ```
88
88
 
89
+ Source recommendation ready:
90
+
91
+ ```json
92
+ {
93
+ "stage": "find-leads",
94
+ "headline": "Review the source in Codex",
95
+ "visibleState": "The browser is showing the evaluated Signal Discovery source with counts and sample quality.",
96
+ "agentIntent": "Approve LinkedIn Engagement or ask for a source change in chat before any leads import.",
97
+ "nextAction": "Approve in Codex",
98
+ "safety": "No leads import until you approve the source."
99
+ }
100
+ ```
101
+
102
+ When the source recommendation, counts, and sample-quality decision are ready in
103
+ chat, update the guide to route the user back to Codex/Claude for approval. Do
104
+ not keep future-tense copy such as `I'll show a source recommendation` once the
105
+ decision card is visible.
106
+
89
107
  Review batch:
90
108
 
91
109
  ```json
@@ -98,6 +116,24 @@ Review batch:
98
116
  }
99
117
  ```
100
118
 
119
+ Source approved and import starting:
120
+
121
+ ```json
122
+ {
123
+ "stage": "review-batch",
124
+ "headline": "Importing the review batch",
125
+ "visibleState": "The browser is still showing the approved LinkedIn Engagement source leads.",
126
+ "agentIntent": "Codex is importing only the bounded 15-row review batch into the campaign now.",
127
+ "nextAction": "Review batch ready",
128
+ "safety": "This is still a review step; nothing launches or sends."
129
+ }
130
+ ```
131
+
132
+ When chat says the source is approved and import is starting, the guide must use
133
+ current-tense import copy. Do not leave the guide in source-approved or
134
+ future-tense copy such as `I'll show the review-batch outcome`; that makes the
135
+ browser guide describe a different moment than chat.
136
+
101
137
  Fit + message:
102
138
 
103
139
  ```json
@@ -11,12 +11,13 @@ gating, and handoff read campaign state first.
11
11
 
12
12
  ## Plumbing Reuse
13
13
 
14
- `create_campaign` already returns a safe direct campaign-builder `watchUrl` on
15
- the response (`CampaignDetail.watchUrl` in
16
- `mcp/sellable/src/tools/campaigns.ts`). V2 does NOT mint a new token, does NOT call
17
- a different route, and does NOT construct the URL locally. Capture whatever
18
- `watchUrl` the existing tool returns and surface it verbatim. The link must
19
- land on the campaign builder without exposing token secrets in the MCP result.
14
+ `create_campaign` already returns a direct campaign-builder `watchUrl` on the
15
+ response (`CampaignDetail.watchUrl` in `mcp/sellable/src/tools/campaigns.ts`).
16
+ The URL includes the auth token and workspace routing needed for browser
17
+ auto-login, then the app exchanges the token through `/auth/continue` and
18
+ cleans it from the browser URL. V2 does NOT mint a new token, does NOT call a
19
+ different route, and does NOT construct the URL locally. Capture whatever
20
+ `watchUrl` the existing tool returns and surface it verbatim.
20
21
 
21
22
  ## Shell-First Link
22
23
 
@@ -39,6 +40,8 @@ No leads import and nothing sends yet.
39
40
 
40
41
  Watch link: {watchUrl}
41
42
 
43
+ Command-Enter or click that link to watch it in Sellable.
44
+
42
45
  Cool, let's find leads.
43
46
  ```
44
47
 
@@ -93,7 +96,9 @@ cached or reconstructed URL.
93
96
  ## Hard Rules
94
97
 
95
98
  - Never fabricate or reconstruct the URL locally.
96
- - The watch link must be a safe direct campaign-builder URL.
99
+ - The watch link must be a direct campaign-builder URL with token auto-login.
100
+ - Do not call browser-opening tools, shell `open`, Computer Use, or in-app
101
+ browser automation; the user opens the link when they want to watch.
97
102
  - Missing `watchUrl` is an error, not a silent skip.
98
103
  - The first watch link must be shown only after the v1 brief is on the campaign.
99
104
  - A watch link is not approval to import, attach sequence, or start.
@@ -20,8 +20,8 @@ Every tail run MUST call these tools in this exact order. The tail is
20
20
  **review-batch cascade-driven**: you kick off Enrich Prospect only for
21
21
  the imported review batch, and the workflow engine chains DNC Check →
22
22
  ICP Score → Passes Rubric → Generate Message automatically. Your job is
23
- to START the bounded cascade, WAIT for it to drain, OBSERVE the results,
24
- and stop for review.
23
+ to START the bounded cascade, WAIT until filter results land, OBSERVE message
24
+ generation as soon as one row passes, and stop for review.
25
25
  Do NOT manually run rubric-check, enrich, or message-generation
26
26
  tools — the cascade already does them.
27
27
 
@@ -43,14 +43,16 @@ Post-import main thread
43
43
 
44
44
  Step 14 — kick bounded cascade + observe sample
45
45
  queue_cells(cellIds=<review-batch Enrich Prospect cells only>) <-- starts bounded chain
46
- wait_for_campaign_table_ready # review-batch cascade drains here
46
+ wait_for_campaign_table_ready # wait until review-batch cascade starts returning filter results
47
47
  get_rows_minimal # read passesRubric + message cell status per row
48
- compute projectedPass
49
- if OK: update_campaign(currentStep=auto-execute-messaging)
50
- else: diagnose brief-vs-list; if brief: update_campaign_brief + re-queue + wait
48
+ wait_for_rubric_results(minPassedCount=1, includeRows=false)
49
+ if at least one row passes: update_campaign(currentStep=auto-execute-messaging)
50
+ compute projectedPass for later reporting / revision decisions
51
+ if zero rows pass: diagnose brief-vs-list; if brief: update_campaign_brief + re-queue + wait
51
52
  (check_rubric / bulk_enrich_with_prospeo are NOT called here —
52
53
  cascade already did them. wait_for_rubric_results is OK as a
53
- read-only observation helper if you need to block on completion.)
54
+ read-only observation helper if you need to block until the first
55
+ passing filtered row exists.)
54
56
 
55
57
  Step 15 — observe messaging
56
58
  get_rows_minimal # confirm passing rows have completed Generate Message cells
@@ -98,9 +100,12 @@ Message` column's http_request writes those cells via the cascade.
98
100
  - `wait_for_rubric_results` is read-only and OK to use as an
99
101
  observation helper when you need to block on rubric completion —
100
102
  it does not mutate cells. Prefer `get_rows_minimal` +
101
- `wait_for_campaign_table_ready` for the primary cascade-drain
103
+ `wait_for_campaign_table_ready` for the primary cascade-observation
102
104
  path, but use `wait_for_rubric_results({ targetCount: cohortSize })`
103
- when the table-level wait returns before rubric cells finish.
105
+ when the table-level wait returns before rubric cells finish. In
106
+ create-campaign-v2 Step 14, pass `minPassedCount: 1`; one passing
107
+ filtered row is enough to start observing Generate Message, even if
108
+ other sample rows are still processing.
104
109
  - `start_campaign` is FORBIDDEN in the autonomous tail. It belongs only
105
110
  in the Claude-greenlight path, AFTER the user signals "start". See
106
111
  `references/final-handoff-contract.md`.
@@ -275,9 +280,9 @@ decision tree lives in `references/sample-validation-loop.md`.
275
280
 
276
281
  **Step 14 starts the bounded cascade, then observes it.** Step 13 imported the
277
282
  review batch only. After `save_rubrics` and the approved message template are
278
- persisted, Step 14 queues the review-batch Enrich Prospect cells, waits for the
279
- table cascade to drain, then reads the resulting ICP scores and Passes Rubric
280
- values. It does NOT call `check_rubric`, `bulk_enrich_with_prospeo`, or any
283
+ persisted, Step 14 queues the review-batch Enrich Prospect cells, waits until
284
+ filter results start landing, then moves to message observation as soon as one
285
+ row passes. It does NOT call `check_rubric`, `bulk_enrich_with_prospeo`, or any
281
286
  other direct enrichment/scoring tool.
282
287
 
283
288
  Shape:
@@ -286,23 +291,23 @@ Shape:
286
291
  queue_cells({ tableId: workflowTableId, cellIds: reviewBatchEnrichCellIds })
287
292
  wait_for_campaign_table_ready({ tableId: workflowTableId })
288
293
  get_rows_minimal({ tableId: workflowTableId })
289
- wait_for_rubric_results({ tableId: workflowTableId, targetCount: cohortSize, minPassedCount: minProjectedPass, includeRows: false })
294
+ wait_for_rubric_results({ tableId: workflowTableId, targetCount: cohortSize, minPassedCount: 1, includeRows: false })
290
295
  passInSample = count of first sampleSize review-batch rows with passesRubric === true
291
296
  projectedPass = round(passInSample / sampleSize * importLimit)
292
297
 
293
- if wait_for_rubric_results.ready === true and partial === true and reason === "min_passed_count_met_with_resolved_failures":
294
- use the partial passRate/stats as a valid sample diagnostic
295
- continue if projectedPass >= minProjectedPass and generated messages are present for the passing rows
298
+ if wait_for_rubric_results.ready === true and passRate.passed >= 1:
299
+ advance to Step 15 to observe or queue Generate Message for currently passing rows
300
+ do not wait for every sample row to finish before message generation starts
296
301
  else if wait_for_rubric_results.ready === false and reason === "timeout":
297
302
  use the partial passRate/stats as the sample diagnostic
298
- if active processing is visible:
303
+ if passRate.passed >= 1:
304
+ advance to Step 15 to observe/queue Generate Message for passing rows
305
+ else if active processing is visible:
299
306
  wait one more time at most
300
307
  else:
301
308
  stop before Settings with Status: sample-needs-revision
302
309
  show completed, passed, pending, pass percent, and message count when available
303
310
  ask whether to revise source, revise filter/rubric, or wait once only if still processing
304
- else if projectedPass >= minProjectedPass:
305
- advance to Step 15
306
311
  else:
307
312
  diagnose brief-vs-list per sample-validation-loop.md
308
313
  - brief: autonomous update_campaign_brief, then re-kick cascade
@@ -94,6 +94,8 @@ Use conservative logic:
94
94
  1. Start from recent strong-post inventory and sample quality, not raw total post count.
95
95
  2. If the lane looks promising, sample real engagers before claiming a reachable-lead estimate:
96
96
  - choose 1-2 promising posts
97
+ - in campaign-attached runs, call `select_promising_posts` first to promote
98
+ those posts in the watched Signal Discovery UI before sampling engagers
97
99
  - call `fetch_post_engagers`
98
100
  - fetch only a representative first sample, not a full scrape
99
101
  - default to `limit: 25` for one post or `limit: 20` per post when checking two posts
@@ -325,6 +327,11 @@ Select the best posts to scrape AND provide headline ICP criteria.
325
327
  Use `selectionTarget` and `recommendedPostIds` returned by `search_signals`
326
328
  (default selectionTarget is 3).
327
329
 
330
+ For campaign-attached viability sampling, this is also the promote step: call it
331
+ before `fetch_post_engagers` so the user sees the exact posts being sampled in
332
+ the watched Signal Discovery table. Use `selectionMode: "replace"` for the first
333
+ sample set unless the user explicitly wants to add to existing promoted posts.
334
+
328
335
  ```json
329
336
  select_promising_posts({
330
337
  "campaignOfferId": "cmp_xxx",