@sellable/mcp 0.1.256 → 0.1.257

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.
@@ -7,6 +7,7 @@ export type EngagementPost = {
7
7
  name: string;
8
8
  headline: string;
9
9
  profileUrl: string;
10
+ followerCount: number | null;
10
11
  };
11
12
  engagement: {
12
13
  likes: number;
@@ -14,6 +15,16 @@ export type EngagementPost = {
14
15
  shares: number;
15
16
  total: number;
16
17
  };
18
+ reachSignals: {
19
+ targetFollowerMin: number | null;
20
+ targetFollowerMax: number | null;
21
+ followerBandFit: "in_target_band" | "below_target_band" | "above_target_band" | "unknown";
22
+ weightedEngagement: number;
23
+ engagementPer1kFollowers: number | null;
24
+ weightedEngagementPer1kFollowers: number | null;
25
+ reachPenaltyMultiplier: number;
26
+ reachAdjustedScore: number | null;
27
+ };
17
28
  contentPreview: string;
18
29
  };
19
30
  export type SearchEngagementPostsInput = {
@@ -22,6 +33,8 @@ export type SearchEngagementPostsInput = {
22
33
  maxAgeDays?: number;
23
34
  minTotalEngagement?: number;
24
35
  maxPosts?: number;
36
+ targetFollowerMin?: number;
37
+ targetFollowerMax?: number;
25
38
  excludePostUrls?: string[];
26
39
  };
27
40
  export type SearchEngagementPostsResponse = {
@@ -63,6 +76,14 @@ export declare const engageDiscoveryToolDefinitions: {
63
76
  type: string;
64
77
  description: string;
65
78
  };
79
+ targetFollowerMin: {
80
+ type: string;
81
+ description: string;
82
+ };
83
+ targetFollowerMax: {
84
+ type: string;
85
+ description: string;
86
+ };
66
87
  excludePostUrls: {
67
88
  type: string;
68
89
  items: {
@@ -27,6 +27,14 @@ export const engageDiscoveryToolDefinitions = [
27
27
  type: "number",
28
28
  description: "Max posts to return after filtering (default 25).",
29
29
  },
30
+ targetFollowerMin: {
31
+ type: "number",
32
+ description: "Optional lower bound for the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
33
+ },
34
+ targetFollowerMax: {
35
+ type: "number",
36
+ description: "Optional upper bound for the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
37
+ },
30
38
  excludePostUrls: {
31
39
  type: "array",
32
40
  items: { type: "string" },
@@ -63,6 +71,101 @@ function safeNumber(value) {
63
71
  const n = typeof value === "number" ? value : Number(value);
64
72
  return Number.isFinite(n) ? n : 0;
65
73
  }
74
+ function parseFollowerCount(value) {
75
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
76
+ return Math.round(value);
77
+ }
78
+ if (typeof value !== "string")
79
+ return null;
80
+ const cleaned = value.trim().toLowerCase().replace(/,/g, "");
81
+ if (!cleaned)
82
+ return null;
83
+ const match = cleaned.match(/(\d+(?:\.\d+)?)\s*([kmb])?/);
84
+ if (!match)
85
+ return null;
86
+ const base = Number(match[1]);
87
+ if (!Number.isFinite(base) || base <= 0)
88
+ return null;
89
+ const suffix = match[2] || "";
90
+ const multiplier = suffix === "b" ? 1_000_000_000 : suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : 1;
91
+ return Math.round(base * multiplier);
92
+ }
93
+ function extractFollowerCount(post) {
94
+ const candidates = [
95
+ post?.author?.followerCount,
96
+ post?.author?.followersCount,
97
+ post?.author?.followers,
98
+ post?.author?.follower_count,
99
+ post?.author?.linkedinFollowers,
100
+ post?.author?.stats?.followers,
101
+ post?.followerCount,
102
+ post?.followersCount,
103
+ post?.followers,
104
+ post?.authorFollowerCount,
105
+ ];
106
+ for (const candidate of candidates) {
107
+ const parsed = parseFollowerCount(candidate);
108
+ if (parsed !== null)
109
+ return parsed;
110
+ }
111
+ return null;
112
+ }
113
+ function normalizeFollowerBound(value) {
114
+ const parsed = parseFollowerCount(value);
115
+ return parsed && parsed > 0 ? parsed : null;
116
+ }
117
+ function followerBandFit(followerCount, targetFollowerMin, targetFollowerMax) {
118
+ if (!followerCount)
119
+ return "unknown";
120
+ if (targetFollowerMin && followerCount < targetFollowerMin) {
121
+ return "below_target_band";
122
+ }
123
+ if (targetFollowerMax && followerCount > targetFollowerMax) {
124
+ return "above_target_band";
125
+ }
126
+ if (targetFollowerMin || targetFollowerMax)
127
+ return "in_target_band";
128
+ return "unknown";
129
+ }
130
+ function bandMultiplier(fit, followerCount, targetFollowerMax) {
131
+ if (fit === "in_target_band")
132
+ return 1;
133
+ if (fit === "below_target_band")
134
+ return 0.75;
135
+ if (fit === "unknown")
136
+ return 0.4;
137
+ if (!followerCount || !targetFollowerMax)
138
+ return 0.45;
139
+ if (followerCount <= targetFollowerMax * 2)
140
+ return 0.65;
141
+ if (followerCount <= targetFollowerMax * 5)
142
+ return 0.35;
143
+ return 0.15;
144
+ }
145
+ function reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax) {
146
+ const weightedEngagement = engagement.likes + engagement.comments * 4 + engagement.shares * 12;
147
+ const fit = followerBandFit(followerCount, targetFollowerMin, targetFollowerMax);
148
+ const engagementPer1kFollowers = followerCount
149
+ ? Number(((engagement.total / followerCount) * 1000).toFixed(3))
150
+ : null;
151
+ const weightedEngagementPer1kFollowers = followerCount
152
+ ? Number(((weightedEngagement / followerCount) * 1000).toFixed(3))
153
+ : null;
154
+ const reachPenaltyMultiplier = bandMultiplier(fit, followerCount, targetFollowerMax);
155
+ const reachAdjustedScore = weightedEngagementPer1kFollowers === null
156
+ ? null
157
+ : Number((weightedEngagementPer1kFollowers * reachPenaltyMultiplier).toFixed(3));
158
+ return {
159
+ targetFollowerMin,
160
+ targetFollowerMax,
161
+ followerBandFit: fit,
162
+ weightedEngagement,
163
+ engagementPer1kFollowers,
164
+ weightedEngagementPer1kFollowers,
165
+ reachPenaltyMultiplier,
166
+ reachAdjustedScore,
167
+ };
168
+ }
66
169
  export async function searchEngagementPosts(input) {
67
170
  const api = getApi();
68
171
  const keywords = (input.keywords || [])
@@ -79,6 +182,10 @@ export async function searchEngagementPosts(input) {
79
182
  const maxPosts = typeof input.maxPosts === "number" && input.maxPosts > 0
80
183
  ? Math.min(50, input.maxPosts)
81
184
  : 25;
185
+ const targetFollowerMin = normalizeFollowerBound(input.targetFollowerMin);
186
+ const targetFollowerMax = normalizeFollowerBound(input.targetFollowerMax);
187
+ const useTargetFollowerBand = Boolean(targetFollowerMin || targetFollowerMax);
188
+ const collectLimit = useTargetFollowerBand ? 50 : maxPosts;
82
189
  const exclude = new Set((input.excludePostUrls || [])
83
190
  .map((u) => normalizePostUrl(u))
84
191
  .filter(Boolean));
@@ -87,11 +194,13 @@ export async function searchEngagementPosts(input) {
87
194
  keywords: keywords.map((keyword) => ({ keyword })),
88
195
  page,
89
196
  });
90
- const rawPosts = Array.isArray(response?.topPostsForLLM)
91
- ? response.topPostsForLLM
92
- : Array.isArray(response?.posts)
93
- ? response.posts
94
- : [];
197
+ const rawPosts = useTargetFollowerBand && Array.isArray(response?.posts)
198
+ ? response.posts
199
+ : Array.isArray(response?.topPostsForLLM)
200
+ ? response.topPostsForLLM
201
+ : Array.isArray(response?.posts)
202
+ ? response.posts
203
+ : [];
95
204
  const now = Date.now();
96
205
  const oldestMs = now - maxAgeDays * 24 * 60 * 60 * 1000;
97
206
  let tooOld = 0;
@@ -111,6 +220,7 @@ export async function searchEngagementPosts(input) {
111
220
  const comments = safeNumber(p?.engagement?.comments);
112
221
  const shares = safeNumber(p?.engagement?.shares);
113
222
  const total = likes + comments + shares;
223
+ const engagement = { likes, comments, shares, total };
114
224
  if (total < minTotalEngagement) {
115
225
  tooLowEngagement += 1;
116
226
  continue;
@@ -121,6 +231,7 @@ export async function searchEngagementPosts(input) {
121
231
  tooOld += 1;
122
232
  continue;
123
233
  }
234
+ const followerCount = extractFollowerCount(p);
124
235
  kept.push({
125
236
  postId: String(p?.id || ""),
126
237
  url,
@@ -132,15 +243,31 @@ export async function searchEngagementPosts(input) {
132
243
  name: String(p?.author?.name || ""),
133
244
  headline: String(p?.author?.headline || ""),
134
245
  profileUrl: String(p?.author?.profileUrl || ""),
246
+ followerCount,
135
247
  },
136
- engagement: { likes, comments, shares, total },
248
+ engagement,
249
+ reachSignals: reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax),
137
250
  contentPreview: previewText(String(p?.content || ""), 220),
138
251
  });
139
- if (kept.length >= maxPosts)
252
+ if (kept.length >= collectLimit)
140
253
  break;
141
254
  }
142
- // Sort by total engagement desc for a predictable shortlist.
143
- kept.sort((a, b) => b.engagement.total - a.engagement.total);
255
+ if (useTargetFollowerBand) {
256
+ kept.sort((a, b) => {
257
+ const scoreDelta = (b.reachSignals.reachAdjustedScore ?? -1) -
258
+ (a.reachSignals.reachAdjustedScore ?? -1);
259
+ if (scoreDelta !== 0)
260
+ return scoreDelta;
261
+ return b.engagement.total - a.engagement.total;
262
+ });
263
+ }
264
+ else {
265
+ // Sort by total engagement desc for a predictable shortlist.
266
+ kept.sort((a, b) => b.engagement.total - a.engagement.total);
267
+ }
268
+ if (kept.length > maxPosts) {
269
+ kept.length = maxPosts;
270
+ }
144
271
  return {
145
272
  success: true,
146
273
  posts: kept,
@@ -1742,6 +1742,14 @@ export declare const allTools: ({
1742
1742
  type: string;
1743
1743
  description: string;
1744
1744
  };
1745
+ targetFollowerMin: {
1746
+ type: string;
1747
+ description: string;
1748
+ };
1749
+ targetFollowerMax: {
1750
+ type: string;
1751
+ description: string;
1752
+ };
1745
1753
  excludePostUrls: {
1746
1754
  type: string;
1747
1755
  items: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.256",
3
+ "version": "0.1.257",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -321,6 +321,7 @@ The research worker must return a compact packet only:
321
321
  - full adapted hook blocks
322
322
  - market belief map: resonating ideas, implicit beliefs, audience wants, resentments, fears, and credible controversy angles
323
323
  - premise inputs: real scenes, observed tensions, reader value openings, and proof gaps
324
+ - reach-normalized signal notes, including follower-band fit when available
324
325
  - exact phrase patterns and sentence shapes
325
326
  - body structures and exact body language moves
326
327
  - rendered mobile and desktop preview records
@@ -334,11 +335,11 @@ the exact extracted phrase shapes.
334
335
  Default flow:
335
336
 
336
337
  1. Convert the idea into 3-8 search keywords.
337
- 2. Call `mcp__sellable__search_engagement_posts` with an explicit multi-month window. Default to `maxAgeDays: 120`, tightening to 30-60 days only when the topic is trend-sensitive.
338
- 3. Shortlist high-engagement posts by topic fit, hook strength, creator repeat evidence, and weighted engagement quality.
339
- 4. Because search results may only include previews, call `mcp__sellable__fetch_linkedin_posts` for shortlisted authors/profile URLs and match recent posts by URL/activity ID when full text is needed.
338
+ 2. Call `mcp__sellable__search_engagement_posts` with an explicit multi-month window. Default to `maxAgeDays: 120`, tightening to 30-60 days only when the topic is trend-sensitive. When the user gives a follower range, pass it as `targetFollowerMin` and `targetFollowerMax`; for example, "8k-20k followers" means `targetFollowerMin: 8000` and `targetFollowerMax: 20000`.
339
+ 3. Shortlist posts by topic fit, rendered hook strength, content pattern replicability, creator repeat evidence, weighted engagement quality, and reach-normalized signal quality. Do not sort by raw engagement alone, and do not let the numeric reach score choose the final hook by itself.
340
+ 4. Because search results may only include previews, call `mcp__sellable__fetch_linkedin_posts` for shortlisted authors/profile URLs and match recent posts by URL/activity ID when full text is needed. If a promising source is missing follower count and the user requested reach normalization, call `mcp__sellable__fetch_linkedin_profile` on a bounded shortlist before treating the source as a keeper.
340
341
  5. If full text cannot be matched, record `full_text_unavailable` and use only the preview. Do not invent missing body details.
341
- 6. Weigh shares/reposts above comments, comments above reactions, and reactions as weak reach unless paired with stronger signals. If shares/reposts are unavailable, record `repost_data_unavailable`.
342
+ 6. Weigh shares/reposts above comments, comments above reactions, and reactions as weak reach unless paired with stronger signals. Normalize engagement against follower count when available: record `authorFollowerCount`, `targetFollowerBand`, `followerBandFit`, `engagementPer1kFollowers`, `weightedEngagementPer1kFollowers`, `reachPenaltyMultiplier`, `reachAdjustedScore`, and `baselineLift` when repeated creator posts make it possible. If follower count is unavailable, record `follower_count_unavailable`; if shares/reposts are unavailable, record `repost_data_unavailable`.
342
343
  7. Penalize lead-magnet or giveaway mechanics unless the user explicitly asks for a lead magnet post.
343
344
  8. Build a market belief map before hook generation: what the space is rewarding, what the audience implicitly believes, what they want permission to say, what they resent or fear, what they will argue with, and which controversial angle the user can credibly own. If the user's raw idea is internally coherent but not attached to a live market tension, do not draft from the internal idea alone; present stronger directions or rewrite the draft angle around the external tension.
344
345
  9. Extract premise inputs: real story/scene possibilities, observed tensions, useful reader takeaways, and proof gaps. A good hook cannot rescue a premise with no value.
@@ -356,7 +357,9 @@ Record provenance:
356
357
  - search window
357
358
  - source post URLs
358
359
  - authors/profile URLs
360
+ - author follower counts and target follower-band fit when available
359
361
  - engagement totals and available likes/comments/shares breakdown
362
+ - reach-normalized scoring fields and confidence notes
360
363
  - creator repeat evidence
361
364
  - lead-magnet or engagement-bait penalties
362
365
  - story mechanism when relevant
@@ -369,6 +372,11 @@ Record provenance:
369
372
  - exact phrase patterns and sentence shapes
370
373
  - body structures and body language patterns
371
374
  - why each pattern fits the user's idea and voice
375
+ - `whyTheHookCarries`: why the selected hook works from the words, tension, and
376
+ content pattern independent of the source creator's reach
377
+ - `whyTheReachEvidenceIsTrustworthy`: follower-band fit, reach-adjusted score,
378
+ baseline lift, share/comment quality, or why a large-account example is only
379
+ secondary pattern evidence
372
380
 
373
381
  ## Step 1.5: Research Learning Report
374
382
 
@@ -394,6 +402,9 @@ Research status:
394
402
  - keywords:
395
403
  - full-text coverage:
396
404
  - repost/share data:
405
+ - target follower band:
406
+ - reach-normalized winners:
407
+ - big-account examples used only as secondary pattern evidence:
397
408
 
398
409
  Best source examples:
399
410
  1. author, URL, engagement, why kept, why not copied
@@ -28,6 +28,7 @@ Worker owns:
28
28
  - tracked-person post fetches
29
29
  - full-text matching by URL/activity ID
30
30
  - duplicate removal
31
+ - follower-band and reach-normalized signal scoring
31
32
  - lead-magnet, giveaway, engagement-bait, and off-voice filtering
32
33
  - market belief mapping across kept and rejected examples
33
34
  - premise input extraction: real scenes, observed tensions, reader value, proof gaps
@@ -58,6 +59,7 @@ Research packet:
58
59
  - exact phrase patterns: max 20
59
60
  - body patterns: max 8
60
61
  - source URLs and author profile URLs
62
+ - reach-normalized signal notes
61
63
  - rendered preview records
62
64
  - confidence gaps
63
65
  - save recommendations
@@ -74,6 +76,13 @@ Use `mcp__sellable__search_engagement_posts` with practical constraints:
74
76
 
75
77
  - explicit `maxAgeDays: 120` by default so research covers the past 30-120 days, not only this week's posts
76
78
  - tighten to 30-60 days for trend-sensitive topics; widen only when the topic has low volume
79
+ - when the user gives a follower range, pass it as `targetFollowerMin` and
80
+ `targetFollowerMax` so the tool can expose reach-normalized signals and
81
+ prioritize comparable creators
82
+ - if a source hook looks promising but follower count is missing, use
83
+ `mcp__sellable__fetch_linkedin_profile` on a bounded shortlist before calling
84
+ the source a keeper; if profile fetch is unavailable or too slow, mark
85
+ `follower_count_unavailable`
77
86
  - high enough engagement to matter
78
87
  - narrow enough to match the idea
79
88
  - enough result depth to see whether a creator has repeated winners, not just one viral outlier
@@ -82,14 +91,19 @@ Record every keyword, filter, search window, page, and result count.
82
91
 
83
92
  ## Weighted Signals
84
93
 
85
- Rank source posts with a weighted signal view. Do not sort by total engagement alone.
94
+ Rank source posts with a weighted signal view. Do not sort by total engagement
95
+ alone. Use the numeric score to decide which sources deserve study; do not let
96
+ the number choose the final hook by itself.
86
97
 
87
98
  Use this scoring shape:
88
99
 
89
100
  ```text
90
101
  hook_score =
91
102
  topic_fit
103
+ + rendered_mobile_preview_strength
92
104
  + hook_clarity
105
+ + content_pattern_replicability
106
+ + reach_adjusted_evidence
93
107
  + creator_repeat_success
94
108
  + repost_share_strength
95
109
  + comment_quality
@@ -107,6 +121,135 @@ Guidance:
107
121
  - If share or repost data is unavailable, record `repost_data_unavailable` instead of inventing it.
108
122
  - Prefer creators with repeated high-performing posts in the same lane over one-off viral posts.
109
123
 
124
+ ## Reach-Normalized Hook Scoring
125
+
126
+ Raw engagement is not the clearest signal when source creators have wildly
127
+ different audience sizes. A post from a 100k+ follower creator can win on
128
+ distribution even when the hook is ordinary. A post from a creator near the
129
+ user's follower range that overperforms is a clearer signal that LinkedIn and
130
+ readers rewarded the hook/premise.
131
+
132
+ This calculation is a source-quality filter, not the final creative decision.
133
+ After reach is controlled, the LLM still chooses the hooks to steal based on the
134
+ visible hook, rendered first-screen promise, content pattern, premise fit, and
135
+ whether the idea can carry without the source creator's distribution.
136
+
137
+ When the user gives a target range, default to that range. For example, if the
138
+ user says "8k to 20k followers," call `mcp__sellable__search_engagement_posts`
139
+ with:
140
+
141
+ ```text
142
+ targetFollowerMin: 8000
143
+ targetFollowerMax: 20000
144
+ ```
145
+
146
+ If the user gives no range, use the user's known follower count from memory or
147
+ ask only when the decision depends on it. If no follower count is available, use
148
+ raw engagement as a fallback and mark `target_follower_band_unknown`.
149
+
150
+ For every shortlisted source post, record:
151
+
152
+ - `authorFollowerCount`
153
+ - `targetFollowerBand`
154
+ - `followerBandFit`: `in_target_band`, `below_target_band`,
155
+ `above_target_band`, or `unknown`
156
+ - `engagementPer1kFollowers`
157
+ - `weightedEngagementPer1kFollowers`
158
+ - `reachAdjustedScore`
159
+ - `creatorBaselineMedianEngagement` when repeated posts from that creator are
160
+ available
161
+ - `baselineLift`: this post's engagement divided by the creator's recent median
162
+ engagement in the same lane, or `creator_baseline_unavailable`
163
+ - `confidence`: `high`, `medium`, or `low`
164
+ - `normalizationNotes`
165
+
166
+ Use this Harvest-compatible calculation when follower counts are available:
167
+
168
+ ```text
169
+ weightedEngagement =
170
+ likes
171
+ + (comments * 4)
172
+ + (shares * 12)
173
+
174
+ engagementPer1kFollowers =
175
+ totalEngagement / authorFollowerCount * 1000
176
+
177
+ weightedEngagementPer1kFollowers =
178
+ weightedEngagement / authorFollowerCount * 1000
179
+
180
+ reachPenaltyMultiplier =
181
+ 1.00 when in target follower band
182
+ 0.75 when below target band
183
+ 0.65 when above target band but <= 2x target max
184
+ 0.35 when above target band but <= 5x target max
185
+ 0.15 when above target band and > 5x target max
186
+ 0.40 when follower count is unknown
187
+
188
+ reachAdjustedScore =
189
+ weightedEngagementPer1kFollowers * reachPenaltyMultiplier
190
+ ```
191
+
192
+ When repeated posts from the same creator are available, calculate:
193
+
194
+ ```text
195
+ creatorBaselineMedianEngagement =
196
+ median weightedEngagement across recent same-lane posts
197
+
198
+ baselineLift =
199
+ post weightedEngagement / creatorBaselineMedianEngagement
200
+ ```
201
+
202
+ Use this scoring shape when deciding which hooks to study:
203
+
204
+ ```text
205
+ hook_score =
206
+ topic_fit
207
+ + rendered_mobile_preview_strength
208
+ + hook_clarity
209
+ + same_follower_band_bonus
210
+ + weighted_engagement_per_1k_followers
211
+ + creator_baseline_lift
212
+ + repost_share_strength
213
+ + substantive_comment_quality
214
+ + creator_repeat_success
215
+ - celebrity_reach_penalty
216
+ - tiny_sample_penalty
217
+ - lead_magnet_penalty
218
+ - engagement_bait_penalty
219
+ - off_voice_penalty
220
+ ```
221
+
222
+ Rules:
223
+
224
+ - Prefer in-band creators when the hook is strong and the engagement is real.
225
+ - Use near-band creators as secondary evidence.
226
+ - Penalize creators far above the target range, especially accounts above 5x
227
+ the target max or above 100k followers when the target range is 8k-20k.
228
+ - Do not automatically reject large-account posts; they can still teach body
229
+ structure, topic heat, or category language. Do not treat them as primary hook
230
+ proof unless they also overperform their creator baseline.
231
+ - Large-account hooks can be selected only when the hook carries without the
232
+ creator: the rendered mobile opening is strong, the premise is reusable by the
233
+ user, the body pattern does not depend on celebrity authority, and the source
234
+ either beats baseline or has unusually high reach-adjusted quality after the
235
+ penalty.
236
+ - Do not overvalue tiny-account anomalies. Very high engagement per follower
237
+ with low absolute engagement is interesting but lower confidence.
238
+ - When follower data is unavailable, do not invent it. Mark
239
+ `follower_count_unavailable`, lower confidence, and rely more on creator repeat
240
+ evidence, repost/share strength, comment quality, and rendered preview quality.
241
+ - A source hook is strongest when it is in/near the target follower band, has
242
+ real absolute engagement, beats the creator's apparent baseline, has
243
+ share/comment quality, and passes rendered mobile preview.
244
+
245
+ Final hook selection must explain both:
246
+
247
+ - `whyTheHookCarries`: the visible words, specificity, tension, and first-screen
248
+ promise that work independent of reach
249
+ - `whyTheReachEvidenceIsTrustworthy`: follower-band fit, reach-adjusted score,
250
+ baseline lift, share/comment quality, or why a large-account example is only
251
+ secondary pattern evidence
252
+
110
253
  Penalize lead-magnet and engagement-bait mechanics unless the user explicitly asks for that style:
111
254
 
112
255
  - `comment "template"` / `comment "guide"` / `comment "playbook"`
@@ -283,7 +426,10 @@ For each shortlisted source post, record:
283
426
  - URL
284
427
  - author
285
428
  - author profile URL when available
429
+ - author follower count and follower-band fit when available
286
430
  - engagement totals and available likes/comments/shares breakdown
431
+ - reach-normalized metrics: engagement per 1k followers, weighted engagement
432
+ per 1k followers, reach-adjusted score, baseline lift, and confidence
287
433
  - creator repeat evidence
288
434
  - visible hook text or preview
289
435
  - rendered preview fields from the section above
@@ -296,6 +442,7 @@ For each shortlisted source post, record:
296
442
  - proof/story dependency
297
443
  - lead magnet or engagement bait penalty
298
444
  - weighted signal notes
445
+ - reach-normalized signal notes
299
446
  - replicability score
300
447
  - track person recommendation: `yes`, `no`, or `ask_user`
301
448
  - tracking reason when recommended
@@ -59,6 +59,11 @@ Hook research files must preserve:
59
59
  - source post URLs
60
60
  - author/profile URLs
61
61
  - engagement totals
62
+ - author follower counts when available, target follower band, follower-band
63
+ fit, engagement per 1k followers, weighted engagement per 1k followers,
64
+ reach penalty multiplier, reach-adjusted score, baseline lift when available,
65
+ why the hook carries independent of creator reach, and normalization
66
+ confidence notes
62
67
  - full-text availability
63
68
  - source hook preview measurements, including text basis, char count including
64
69
  newlines, physical/content line counts, longest nonblank line, blank-line
@@ -11,6 +11,7 @@ Every saved draft needs a validation receipt. A draft without this receipt is no
11
11
  - `candidateHooksConsidered`
12
12
  - `selectedHook`
13
13
  - `selectedHookWhy`
14
+ - `sourceHookReachAudit`
14
15
  - `proofClaimsUsed`
15
16
  - `proofClaimSources`
16
17
  - `storyFilesConsulted`
@@ -77,6 +78,10 @@ Each candidate should include:
77
78
  - premise tension opened
78
79
  - reader value implied
79
80
  - source pattern
81
+ - source pattern reach signals: author follower count when available, target
82
+ follower band, follower-band fit, engagement per 1k followers, weighted
83
+ engagement per 1k followers, reach-adjusted score, baseline lift, and
84
+ confidence
80
85
  - score
81
86
  - `renderedPreview` using `references/linkedin-preview-rendering.md`
82
87
  - literal mobile and desktop rendered preview blocks
@@ -153,6 +158,35 @@ If `selectedControversy` is missing, if the audience belief is only a generic
153
158
  label like "founders want growth," or if `credibleWhyUs` depends on borrowed
154
159
  proof from another creator, save as `needs_revision`.
155
160
 
161
+ ## Source Hook Reach Audit
162
+
163
+ Before a draft can be `ready`, validate that the selected source hook pattern
164
+ was chosen for a reach-normalized reason, not raw audience size.
165
+
166
+ Record:
167
+
168
+ - `targetFollowerBand`
169
+ - `authorFollowerCount` for each source pattern when available
170
+ - `followerBandFit`: `in_target_band`, `below_target_band`,
171
+ `above_target_band`, or `unknown`
172
+ - `engagementPer1kFollowers`
173
+ - `weightedEngagementPer1kFollowers`
174
+ - `reachAdjustedScore`
175
+ - `reachPenaltyMultiplier`
176
+ - `creatorBaselineMedianEngagement` or `creator_baseline_unavailable`
177
+ - `baselineLift` or `creator_baseline_unavailable`
178
+ - `bigAccountPenaltyApplied`
179
+ - `whyTheHookCarries`
180
+ - `followerCountUnavailableSources`
181
+ - `whyThisHookIsAReachAdjustedSignal`
182
+
183
+ If the user asked for a follower range and the selected source pattern is from a
184
+ large account above the target range, the receipt must explain why it survived
185
+ the celebrity reach penalty. Use large-account hooks as secondary pattern
186
+ evidence unless they clearly overperform the creator's baseline or the hook
187
+ itself carries after reach is controlled. A draft cannot be `ready` when the
188
+ selected hook is justified only by raw total engagement or follower count.
189
+
156
190
  ## LinkedIn Preview Audit
157
191
 
158
192
  Audit the selected hook and top candidates against