@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.21

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.
Files changed (35) hide show
  1. package/mcp/dist/index.js +256 -92
  2. package/mcp/dist/telemetry.js +2 -1
  3. package/mcp/dist/version.json +2 -2
  4. package/mcp/manifest.json +1 -1
  5. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  6. package/mcp/menubar/s4l_card.py +144 -18
  7. package/mcp/menubar/s4l_card_canvas.py +135 -14
  8. package/mcp/menubar/s4l_menubar.py +216 -73
  9. package/mcp/menubar/s4l_state.py +45 -130
  10. package/mcp/package.json +1 -1
  11. package/package.json +1 -1
  12. package/scripts/account_resolver.py +35 -1
  13. package/scripts/autopilot_stall_watch.py +8 -1
  14. package/scripts/browser_lifecycle.py +90 -0
  15. package/scripts/cdp_ready_check.py +81 -50
  16. package/scripts/draft_prompt_core.py +100 -17
  17. package/scripts/engagement_styles.py +135 -98
  18. package/scripts/merge_review_queue.py +192 -129
  19. package/scripts/platform_strike_events.py +54 -5
  20. package/scripts/post_reddit.py +10 -0
  21. package/scripts/preview_reddit_card.py +38 -0
  22. package/scripts/reap_stale_claude_sessions.py +7 -6
  23. package/scripts/reddit_ban_check.py +220 -0
  24. package/scripts/reddit_browser.py +24 -0
  25. package/scripts/release-mcpb.sh +38 -7
  26. package/scripts/setup_twitter_auth.py +20 -96
  27. package/scripts/store_patch.py +111 -0
  28. package/scripts/strike_alert.py +161 -12
  29. package/scripts/test_no_silent_fallbacks.py +11 -0
  30. package/scripts/test_stall_watch_batch_progression.py +99 -0
  31. package/scripts/top_performers.py +24 -0
  32. package/scripts/twitter_browser.py +22 -52
  33. package/skill/archive-old-logs.sh +24 -0
  34. package/skill/lib/reddit-backend.sh +1 -0
  35. package/skill/run-twitter-cycle.sh +146 -66
@@ -76,6 +76,12 @@ def _config_path():
76
76
  ARM_TREATMENT = "treatment_v4"
77
77
  ARM_CONTROL = "control_v4"
78
78
 
79
+ # The "treatment_v4 assigns no real style" decision (and its sentinel value)
80
+ # lives ONLY in engagement_styles.py now (STYLE_SENTINEL_TREATMENT,
81
+ # pick_style_for_post, pick_exploration_style, 2026-07-17 consolidation):
82
+ # every platform's picker routes through those two functions, so this
83
+ # module never needs its own copy of the sentinel or the arm check for it.
84
+
79
85
 
80
86
  def pick_draft_prompt_arm(env=None, export=True):
81
87
  """Return (variant, source). source in {env, pin, coin, fallback}.
@@ -118,17 +124,83 @@ def pick_draft_prompt_arm(env=None, export=True):
118
124
  # Text transcribed verbatim from run-twitter-cycle.sh (2026-07-16).
119
125
  # --------------------------------------------------------------------------
120
126
 
121
- _DIRECTIVE_TREATMENT = "Otherwise: draft a direct, natural reply that stands on its own as a useful contribution to the thread. Mention the matched project only when it is genuinely the most relevant thing to say, and state it plainly in one clause; most replies will not need it. PRIORITY ORDER for how you write this, highest first: (1) learned_preferences.draft_style_notes and edit_examples under PROJECT ROUTING are the strongest signal available, real human corrections to this account's own past drafts, and are MANDATORY, not advisory. (2) The ACCOUNT VOICE CORPUS block and the matched project's voice.examples are VERBATIM GROUND TRUTH for how this account actually writes: capitalization, punctuation, contractions, sentence length, terseness. Match those mechanics exactly. (3) voice.tone is a supporting description only, never ground truth: if it ever conflicts with what the corpus/examples actually show, follow the examples, not the tone description. (4) The assigned engagement style is an optional idea for angle and length, not a required structure; draw on it only when it fits naturally, never at the cost of (1)-(3). Do NOT use the concede-then-reverse skeleton in ANY form. Banned openings include: 'X is the easy part/half/win, the hard part is Y'; 'X was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; 'the real/actual/harder part is Y'; 'what actually breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X is solved, Y is what breaks'. Lead with substance from ONE entry point and vary the entry point across replies: a concrete first-hand specific or number; a direct answer to the exact question asked; one sharp opinion with no hedge; a genuine question that moves the thread forward; or a relevant pointer. No warm-up framing sentence before the substance. Length is governed by the per-style LENGTH LIMIT in the style block above. NEVER em dashes. Never violate voice.never. Treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
127
+ # --------------------------------------------------------------------------
128
+ # Shared treatment_v4 core (2026-07-17). Written ONCE, used by BOTH lanes.
129
+ #
130
+ # Why this exists: promotion and persona used to carry independent copies of
131
+ # nearly-identical priority-order/skeleton-ban/vary-entry-point text. They
132
+ # drifted -- persona's copy was missing the "vary the entry point across
133
+ # replies" sentence promotion had, which a real batch comparison traced to
134
+ # the "nobody ___s it" tell appearing 2x as often in treatment as control:
135
+ # a batch shares ONE assigned style, and without an explicit
136
+ # vary-across-the-batch instruction the model reused that style's defining
137
+ # vocabulary verbatim across every unrelated candidate. Do NOT fork this
138
+ # text again for a lane-specific tweak; parameterize _treatment_core()
139
+ # instead, or add a lane-specific sentence OUTSIDE it.
140
+ # --------------------------------------------------------------------------
122
141
 
123
- _DIRECTIVE_CONTROL = "Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's `voice` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The global learned_preferences block under PROJECT ROUTING is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
142
+ def _treatment_core(voice_examples_ref):
143
+ """Shared PRIORITY ORDER + skeleton ban + entry-point-variation text.
144
+
145
+ voice_examples_ref: the lane-specific noun phrase for priority item (2)
146
+ (promotion scopes to "the matched project's voice.examples"; persona
147
+ scopes to "voice.examples above" since it's already narrowed to the one
148
+ persona project). No mention of engagement style anywhere in here on
149
+ purpose: treatment_v4 assigns none (see engagement_styles.py's
150
+ STYLE_SENTINEL_TREATMENT / pick_style_for_post).
151
+ """
152
+ return (
153
+ "PRIORITY ORDER for how you write this, highest first: (1) "
154
+ "learned_preferences.draft_style_notes and edit_examples are the "
155
+ "strongest signal available, real human corrections to this "
156
+ "account's own past drafts, and are MANDATORY, not advisory. (2) "
157
+ f"The ACCOUNT VOICE CORPUS block and {voice_examples_ref} are "
158
+ "VERBATIM GROUND TRUTH for how this account actually writes: "
159
+ "capitalization, punctuation, contractions, sentence length, "
160
+ "terseness. Match those mechanics exactly. (3) voice.tone is a "
161
+ "supporting description only, never ground truth: if it ever "
162
+ "conflicts with what the corpus/examples actually show, follow the "
163
+ "examples, not the tone description. Do NOT use the "
164
+ "concede-then-reverse skeleton in ANY form. Banned openings "
165
+ "include: 'X is the easy part/half/win, the hard part is Y'; 'X "
166
+ "was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; "
167
+ "'the real/actual/harder part is Y'; 'what actually "
168
+ "breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X "
169
+ "is solved, Y is what breaks'. Lead with substance from ONE entry "
170
+ "point and vary the entry point AND the rhetorical move across "
171
+ "every reply you draft this batch, even when the same topic or "
172
+ "style would naturally suggest reusing the same construction: a "
173
+ "concrete first-hand specific or number; a direct answer to the "
174
+ "exact question asked; one sharp opinion with no hedge; a genuine "
175
+ "question that moves the thread forward; or a relevant pointer. No "
176
+ "warm-up framing sentence before the substance."
177
+ )
124
178
 
125
- _DIVERGENCE_NOTE_TREATMENT = " Beyond following different style templates: draft_a_text and draft_b_text must be genuinely distinct takes on this thread -- think of them as variant 1a and variant 1b: different entry point, different specific detail seized on, different rhetorical move -- not two phrasings of the same underlying observation dressed in different style language. If 1a and 1b would say essentially the same thing, pick a different angle entirely for 1b rather than just rewording 1a."
126
179
 
127
- _PERSONA_SKELETON_BAN = " Also do NOT use the concede-then-reverse skeleton in ANY form: banned openings include 'X is the easy part/half/win, the hard part is Y', 'X was never the [thing], it's Y', 'X isn't the [problem], it's Y', 'the real/actual/harder part is Y', 'what actually breaks/ships/matters is Y', 'the part nobody says/shows is Y', and 'X is solved, Y is what breaks'; if a draft has that pivot, rewrite it from one of the entry points above."
180
+ _TREATMENT_LENGTH_NOTE = (
181
+ "Length: match how long the account's own real examples above actually "
182
+ "run; when the corpus doesn't make it obvious, default to one or two "
183
+ "sentences, well under Twitter's 250-character practical limit. NEVER "
184
+ "em dashes."
185
+ )
186
+
187
+ _DIRECTIVE_TREATMENT = (
188
+ "Otherwise: draft a direct, natural reply that stands on its own as a "
189
+ "useful contribution to the thread. Mention the matched project only "
190
+ "when it is genuinely the most relevant thing to say, and state it "
191
+ "plainly in one clause; most replies will not need it. "
192
+ + _treatment_core("the matched project's voice.examples") +
193
+ " " + _TREATMENT_LENGTH_NOTE +
194
+ " Never violate voice.never. Treat learned_preferences.audience_avoid "
195
+ "/ thread_avoid matches as strong reasons to skip the candidate. Never "
196
+ "violate content_guardrails.do_not."
197
+ )
198
+
199
+ _DIRECTIVE_CONTROL = "Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's `voice` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The global learned_preferences block under PROJECT ROUTING is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
128
200
 
129
- _PERSONA_VOICE_PRIORITY_CLAUSE = " PRIORITY ORDER for how you write this, highest first: (1) learned_preferences.draft_style_notes and edit_examples are the strongest signal available, real human corrections to this account's own past drafts, and are MANDATORY. (2) The ACCOUNT VOICE CORPUS block and voice.examples above are VERBATIM GROUND TRUTH for how they actually write: capitalization, punctuation, contractions, sentence length, terseness. Match those mechanics exactly. (3) voice.tone is a supporting description only, never ground truth: if it ever conflicts with what the corpus/examples actually show (for example tone says lowercase is fine but every real example is properly capitalized), follow the examples. (4) The assigned engagement style is an optional idea for angle and length, not a required structure."
201
+ _DIVERGENCE_NOTE_TREATMENT = " draft_a_text and draft_b_text must be genuinely distinct takes on this thread regardless of style -- think of them as variant 1a and variant 1b: different entry point, different specific detail seized on, different rhetorical move -- not two phrasings of the same underlying observation. If 1a and 1b would say essentially the same thing, pick a different angle entirely for 1b rather than just rewording 1a."
130
202
 
131
- _PERSONA_DIRECTIVE_TEMPLATE = "Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the ACCOUNT VOICE CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's `voice` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').@SKELETON_BAN@ This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing. Length is governed by the per-style LENGTH LIMIT in the style block above. NEVER em dashes. Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present.@VOICE_PRIORITY@ The global learned_preferences block under PROJECT ROUTING is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing @PREFS_RELATION@, and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
203
+ _PERSONA_DIRECTIVE_TEMPLATE = "Otherwise: draft a reply that stands on its own as a genuinely useful contribution to THIS thread. Ground it in the persona's real, first-hand experience from the ACCOUNT VOICE CORPUS block below (specific projects, real numbers, sharp opinions, actual failures) and in the persona's `voice` block from ALL_PROJECTS_JSON. Add exactly ONE of: a concrete specific from that lived experience, a sharp non-obvious opinion, a useful pointer, or a question that genuinely moves the thread forward. NEVER generic agreement ('makes sense', 'this is spot on', 'great point', 'the nuance here is').@TREATMENT_CORE@ This is a personal account, not a brand: sound like a real person in the thread. If web search is available and the thread hinges on a current fact, verify it before drafting rather than guessing.@LENGTH_NOTE@ Follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The global learned_preferences block under PROJECT ROUTING is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing @PREFS_RELATION@, and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
132
204
 
133
205
 
134
206
  def draft_directive(arm=None, lane=None):
@@ -137,17 +209,17 @@ def draft_directive(arm=None, lane=None):
137
209
  lane = lane if lane is not None else os.environ.get("S4L_ACTIVE_LANE", "")
138
210
  if lane == "personal_brand":
139
211
  if arm == ARM_TREATMENT:
140
- ban = _PERSONA_SKELETON_BAN
141
- priority = _PERSONA_VOICE_PRIORITY_CLAUSE
142
- rel = "(per the PRIORITY ORDER above: learned_preferences and the account's real voice outrank the engagement style whenever they disagree)"
212
+ core = " " + _treatment_core("voice.examples above")
213
+ length_note = " " + _TREATMENT_LENGTH_NOTE
214
+ rel = "(per the PRIORITY ORDER above: learned_preferences and the account's real voice are what drive this reply)"
143
215
  else:
144
- ban = ""
145
- priority = ""
216
+ core = ""
217
+ length_note = " Length is governed by the per-style LENGTH LIMIT in the style block above. NEVER em dashes."
146
218
  rel = "(it overrides the engagement style's structural template on conflict)"
147
219
  return (
148
220
  _PERSONA_DIRECTIVE_TEMPLATE
149
- .replace("@SKELETON_BAN@", ban)
150
- .replace("@VOICE_PRIORITY@", priority)
221
+ .replace("@TREATMENT_CORE@", core)
222
+ .replace("@LENGTH_NOTE@", length_note)
151
223
  .replace("@PREFS_RELATION@", rel)
152
224
  )
153
225
  if arm == ARM_TREATMENT:
@@ -354,7 +426,7 @@ For each chosen candidate:
354
426
  - If the candidate block shows an EXISTING DRAFT line AND draft age < 30 minutes, REUSE the draft text verbatim as draft_a_text/draft_a_style (set is_reused_draft=true, draft_b_text=null, draft_b_style=null). Do NOT call log_draft.py; do NOT redraft; do NOT write a second variant, prior cycle already paid the LLM cost for the one draft you have.
355
427
  - Otherwise (fresh candidate, is_reused_draft=false): write TWO independent drafts. Do NOT judge, rank, or pick a favorite between them, both are shown to the reviewer, who decides.
356
428
  - draft_a_text: follow the DRAFT A style block above (its own description/example/note/length limit).
357
- - draft_b_text: follow the DRAFT B style block above, written INDEPENDENTLY from scratch as if draft_a_text did not exist. Do NOT lightly reword draft_a_text into draft_b_text, they must diverge in length and rhetorical move because they follow different style templates, not just differ in phrasing. If you notice draft_b_text ending up as a paraphrase of draft_a_text, stop and rewrite it from Style B's own example instead.@DRAFT_B_DIVERGENCE_NOTE@
429
+ - draft_b_text: follow the DRAFT B style block above, written INDEPENDENTLY from scratch as if draft_a_text did not exist. Do NOT lightly reword draft_a_text into draft_b_text, they must diverge in length and rhetorical move, not just differ in phrasing. If you notice draft_b_text ending up as a paraphrase of draft_a_text, stop and rewrite it from Style B's own example instead.@DRAFT_B_DIVERGENCE_NOTE@
358
430
  - @DRAFT_DIRECTIVE@ (applies to both drafts on fresh candidates; each still obeys its OWN style's length limit, not a shared one).
359
431
  3a. PERSIST DRAFT A (skip entirely for reused drafts):
360
432
  python3 @REPO_DIR@/scripts/log_draft.py --candidate-id CANDIDATE_ID --text 'DRAFT_A_TEXT' --style DRAFT_A_STYLE --assigned-style '@PICKED_STYLE@' --assigned-mode '@PICKED_MODE@'
@@ -439,7 +511,18 @@ def render_twitter_prompt(ing):
439
511
  """
440
512
  arm = ing.get("arm") or os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or ""
441
513
  lane = ing.get("lane") if ing.get("lane") is not None else os.environ.get("S4L_ACTIVE_LANE", "")
514
+ # picked_style/picked_style_b already carry the sentinel for treatment_v4
515
+ # (engagement_styles.pick_style_for_post/pick_exploration_style refuse to
516
+ # assign a real style under that arm, 2026-07-17 consolidation), no
517
+ # override needed here -- this used to re-derive the sentinel from `arm`
518
+ # independently of the picker, which is exactly the kind of duplicated
519
+ # decision that let a second, unfixed copy (Reddit's post_reddit.py,
520
+ # which calls the picker directly and never went through this function)
521
+ # silently keep assigning real styles under treatment_v4.
442
522
  picked_style = ing.get("picked_style") or ""
523
+ picked_style_b = ing.get("picked_style_b") or ""
524
+ picked_mode = ing.get("picked_mode") or "use"
525
+ picked_mode_b = ing.get("picked_mode_b") or "use"
443
526
  return (
444
527
  _TW_TEMPLATE
445
528
  .replace("@PREFIX@", ing.get("prefix") or "")
@@ -459,10 +542,10 @@ def render_twitter_prompt(ing):
459
542
  .replace("@DRAFT_B_DIVERGENCE_NOTE@", draft_b_divergence_note(arm))
460
543
  .replace("@DRAFT_DIRECTIVE@", draft_directive(arm, lane))
461
544
  .replace("@PICKED_STYLE_OR_INVENT@", picked_style or "(invent)")
462
- .replace("@PICKED_STYLE_B@", ing.get("picked_style_b") or "")
463
- .replace("@PICKED_MODE_B@", ing.get("picked_mode_b") or "use")
545
+ .replace("@PICKED_STYLE_B@", picked_style_b)
546
+ .replace("@PICKED_MODE_B@", picked_mode_b)
464
547
  .replace("@PICKED_STYLE@", picked_style)
465
- .replace("@PICKED_MODE@", ing.get("picked_mode") or "use")
548
+ .replace("@PICKED_MODE@", picked_mode)
466
549
  )
467
550
 
468
551
 
@@ -1048,6 +1048,51 @@ def compute_target_distribution(platform, context="posting"):
1048
1048
  INVENT_RATE = 0.0 # retired inline roll (was 0.05, ~1 in 20 posts)
1049
1049
  CURATED_TOP_N = 5 # size of the invent-mode reference list (top 5 by score)
1050
1050
 
1051
+ # Draft-prompt treatment_v4 assigns NO real engagement style, on ANY platform
1052
+ # that participates in the S4L_DRAFT_PROMPT_VARIANT experiment (currently
1053
+ # Twitter and Reddit, both of which export it via
1054
+ # draft_prompt_core.pick_draft_prompt_arm() before picking a style). This is
1055
+ # the ONE place that decision is made: pick_style_for_post() and
1056
+ # pick_exploration_style() below both short-circuit on it, so every caller
1057
+ # (Twitter's s4l_pick_style bash wrapper, Reddit's direct Python call, and
1058
+ # any future platform driver) gets the same behavior for free, with nothing
1059
+ # to remember to wire up per-platform. A caller that reads the env var
1060
+ # directly instead of routing through these functions is the only way to
1061
+ # miss this. Must match draft_prompt_core.py's ARM_TREATMENT = "treatment_v4".
1062
+ # 2026-07-17: consolidated here after a real bug -- the same "treatment_v4 =
1063
+ # no style" check used to live only in run-twitter-cycle.sh (Twitter's bash
1064
+ # driver), one layer above this shared function, so Reddit (which calls
1065
+ # pick_style_for_post() directly, bypassing that bash script entirely) never
1066
+ # got it: Reddit kept assigning a real style, the model still correctly
1067
+ # wrote "voice_first" per get_assigned_style_prompt()'s render-side check
1068
+ # below, and validate_or_register()'s USE-mode drift protection silently
1069
+ # coerced "voice_first" back to the real assigned style at post time.
1070
+ STYLE_SENTINEL_TREATMENT = "voice_first"
1071
+
1072
+
1073
+ def _is_treatment_v4():
1074
+ return (os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or "").strip() == "treatment_v4"
1075
+
1076
+
1077
+ def _treatment_v4_assignment():
1078
+ """The single no-style assignment returned by both pickers below when
1079
+ _is_treatment_v4(). style is the literal sentinel, not None/empty, on
1080
+ purpose: this makes the assignment identical to what the model is told
1081
+ to output (see get_assigned_style_prompt), so validate_or_register's
1082
+ first, simplest check (style == assigned_style -> valid) accepts it
1083
+ with no special-casing anywhere downstream, on either platform."""
1084
+ return {
1085
+ "mode": "use",
1086
+ "style": STYLE_SENTINEL_TREATMENT,
1087
+ "description": None,
1088
+ "example": None,
1089
+ "note": None,
1090
+ "target_chars": None,
1091
+ "reference_styles": [],
1092
+ "distribution_snapshot": [],
1093
+ "picked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
1094
+ }
1095
+
1051
1096
  # Fallback target comment length (chars) for any style that lacks an explicit
1052
1097
  # target_chars (legacy DB rows, cold-start before the registry is reachable).
1053
1098
  # Set just above the top-human-reply median (~74) so the long tail of styles
@@ -1207,6 +1252,9 @@ def pick_style_for_post(platform, context="posting",
1207
1252
  "picked_at": ISO-8601 UTC,
1208
1253
  }
1209
1254
  """
1255
+ if _is_treatment_v4():
1256
+ return _treatment_v4_assignment()
1257
+
1210
1258
  rnd = rng or random
1211
1259
 
1212
1260
  # Human-derived branch (2026-05-22, second pass): on every platform,
@@ -1377,6 +1425,12 @@ def pick_exploration_style(platform, context="posting", exclude=None,
1377
1425
  posted draft's engagement write the style's first real score, and
1378
1426
  winners graduate into the Draft-A pool through the normal sampler.
1379
1427
  """
1428
+ if _is_treatment_v4():
1429
+ # No exploration under treatment_v4 (see STYLE_SENTINEL_TREATMENT
1430
+ # above): return None so the caller's existing "pool empty, fall
1431
+ # back to the scored picker" path fires, which routes through
1432
+ # pick_style_for_post() and converges on the same sentinel.
1433
+ return None
1380
1434
  rnd = rng or random
1381
1435
  try:
1382
1436
  never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
@@ -1463,112 +1517,95 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
1463
1517
  policy = PLATFORM_POLICY.get(platform, PLATFORM_POLICY["reddit"])
1464
1518
  lines = []
1465
1519
 
1466
- if assignment["mode"] == "use":
1467
- # Draft-prompt A/B v4 (voice-first, 2026-07-15 RESET of v3): v3's
1468
- # treatment rendered the style as the BINDING FORM of the draft
1469
- # (defining move + per-style length for EVERY assignment + self-check
1470
- # + two-layer learned_preferences contract). v4 replaces that
1471
- # wholesale: measured, real voice signal (the account's own corpus +
1472
- # voice.examples + learned_preferences, all handled in the
1473
- # DRAFT_DIRECTIVE built by run-twitter-cycle.sh) is meant to be the
1474
- # DOMINANT signal, and a generic cross-account style template
1475
- # dictating structure/length was competing with that. treatment_v4
1476
- # keeps the style as an optional idea for angle/length only, never a
1477
- # required shape. The arm is read from the env HERE because this
1478
- # block is rendered inside the cycle process where
1479
- # run-twitter-cycle.sh assigns and exports S4L_DRAFT_PROMPT_VARIANT
1480
- # (stamp-at-source: that same process also stamps the arm onto every
1481
- # plan candidate via active_experiments). Downstream/post-time
1482
- # consumers never re-read this env. Unset or control_v4 (and every
1483
- # non-twitter caller, which never has the env) renders the legacy
1484
- # block below unchanged — that block IS the v4 control arm, same as
1485
- # it was the v3 control arm; v1-v3 arm strings are retired and will
1486
- # never match here again, so this branch is now unreachable for any
1487
- # value except "treatment_v4".
1488
- _dp_arm = (os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or "").strip()
1489
- if _dp_arm == "treatment_v4":
1490
- _tc = assignment.get("target_chars") or DEFAULT_TARGET_CHARS
1491
- lines.append(
1492
- f"## Your assigned engagement style: **{assignment['style']}** "
1493
- "(an optional idea for angle and length, NOT a required structure)"
1494
- )
1495
- lines.append("")
1496
- lines.append(
1497
- f"This style was selected by the picker (weighted by live "
1498
- f"click-driven performance across {platform}). Draw on it for "
1499
- f"inspiration when it fits naturally. The voice/preferences "
1500
- f"priority order in the draft directive below takes precedence "
1501
- f"whenever it pulls in a different direction — never force this "
1502
- f"style's shape at the cost of that."
1503
- )
1504
- lines.append("")
1505
- lines.append(f"Platform tone: {policy.get('note', '')}")
1506
- lines.append("")
1507
- lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1508
- if assignment.get("example"):
1509
- lines.append(f' Example: "{assignment["example"]}"')
1510
- if assignment.get("note"):
1511
- lines.append(f" Note: {assignment['note']}")
1520
+ # Draft-prompt A/B v4 (voice-first, 2026-07-15 RESET of v3; style
1521
+ # assignment dropped entirely 2026-07-17): v3's treatment rendered the
1522
+ # style as the BINDING FORM of the draft. v4 first demoted it to "an
1523
+ # optional idea for angle/length", but a real 21-pair batch comparison
1524
+ # showed the demoted style block was STILL polluting context: the
1525
+ # picker's one canonical example for a batch's assigned style bled its
1526
+ # own vocabulary into every candidate that batch regardless of topic
1527
+ # (e.g. a batch drawing 'attention_is_the_input' produced 6/6 drafts
1528
+ # pivoting on "who's paying attention", across unrelated threads). Style
1529
+ # is now not shown to the model AT ALL for treatment_v4: the account's
1530
+ # real voice corpus + learned_preferences are the sole drafting signal.
1531
+ # The arm is read from the env HERE because this block is rendered
1532
+ # inside the cycle process where run-twitter-cycle.sh assigns and
1533
+ # exports S4L_DRAFT_PROMPT_VARIANT (stamp-at-source: that same process
1534
+ # also stamps the arm onto every plan candidate via active_experiments).
1535
+ # Unset or control_v4 (and every non-twitter caller, which never has the
1536
+ # env) renders the legacy block below unchanged — that block IS the v4
1537
+ # control arm, same as it was the v3 control arm.
1538
+ #
1539
+ # pick_style_for_post()/pick_exploration_style() (above, in this same
1540
+ # module) already refuse to assign a real style under treatment_v4 and
1541
+ # return STYLE_SENTINEL_TREATMENT itself as `assignment["style"]`, so
1542
+ # this render-side check and that pick-side check agree by construction
1543
+ # instead of by convention -- no separate literal to keep in sync here.
1544
+ if _is_treatment_v4():
1545
+ lines.append(
1546
+ "## Engagement style: not used for this draft (voice-first mode)"
1547
+ )
1548
+ lines.append("")
1549
+ lines.append(
1550
+ "This account's real voice (the ACCOUNT VOICE CORPUS block and "
1551
+ "voice.examples) and learned_preferences drive this reply, not a "
1552
+ "named engagement style — no style name, description, example, "
1553
+ "or length target is assigned this draft. "
1554
+ f'In your output JSON, set "engagement_style" to exactly '
1555
+ f'"{STYLE_SENTINEL_TREATMENT}" and leave "new_style" as null, for BOTH '
1556
+ "Draft A and Draft B. Do not invent, reuse, or reference any "
1557
+ "other style name for this draft."
1558
+ )
1559
+ elif assignment["mode"] == "use":
1560
+ # This IS the v4 control arm (unchanged since v3 control / v2's control
1561
+ # text). treatment_v4 never reaches here; see the sentinel branch above.
1562
+ lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
1563
+ lines.append("")
1564
+ lines.append(
1565
+ f"This style was selected by the picker (weighted by live "
1566
+ f"click-driven performance across {platform}). Use it. Do not "
1567
+ f"swap it for a different listed style."
1568
+ )
1569
+ lines.append("")
1570
+ lines.append(f"Platform tone: {policy.get('note', '')}")
1571
+ lines.append("")
1572
+ lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1573
+ if assignment.get("example"):
1574
+ lines.append(f' Example: "{assignment["example"]}"')
1575
+ if assignment.get("note"):
1576
+ lines.append(f" Note: {assignment['note']}")
1577
+ # LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
1578
+ # uses the legacy generic length guidance. The treatment's per-style
1579
+ # target prompt remains preserved only in the shipped experiment card.
1580
+ #
1581
+ # EXCEPTION (2026-07-11, Draft-B explore slot): exploration
1582
+ # assignments (pick_exploration_style, marked by `source`) honor the
1583
+ # style's own target_chars so the A/B pair diverges on the length
1584
+ # axis too. The uniform clamp flattened every draft to the same
1585
+ # 2-sentence shape (user: "the older drafts looked all very similar
1586
+ # in terms of the length"). The 06-04 conclusion still governs the
1587
+ # scored path, which never carries `source`.
1588
+ _explore_tc = assignment.get("target_chars")
1589
+ if (assignment.get("source") in ("human_derived", "model_invented")
1590
+ and _explore_tc):
1512
1591
  lines.append("")
1513
1592
  lines.append(
1514
- f"**LENGTH: aim for about {int(_tc)} characters** "
1593
+ f"**LENGTH: aim for about {int(_explore_tc)} characters** "
1515
1594
  "(this style's own winning length; within about 30% either "
1516
- "way is fine, never above 250) unless the account's own real "
1517
- "examples run clearly shorter or longer, in which case match "
1518
- "them instead. This applies to the comment text only; any "
1519
- "link/CTA the system appends afterward is separate."
1595
+ "way is fine, never above 250). Let the target set the form: "
1596
+ "a very short target means one clipped line, a long one can "
1597
+ "breathe. Do NOT default to the usual two-sentence shape. "
1598
+ "This applies to the comment text only; any link/CTA the "
1599
+ "system appends afterward is separate."
1520
1600
  )
1521
1601
  else:
1522
- lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
1523
1602
  lines.append("")
1524
1603
  lines.append(
1525
- f"This style was selected by the picker (weighted by live "
1526
- f"click-driven performance across {platform}). Use it. Do not "
1527
- f"swap it for a different listed style."
1604
+ "**LENGTH: keep it tight.** One or two sentences, well under the "
1605
+ "250-character Twitter limit. A short, sharp reply almost always "
1606
+ "beats a paragraph. This applies to the comment text only; any "
1607
+ "link/CTA the system appends afterward is separate."
1528
1608
  )
1529
- lines.append("")
1530
- lines.append(f"Platform tone: {policy.get('note', '')}")
1531
- lines.append("")
1532
- lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1533
- if assignment.get("example"):
1534
- lines.append(f' Example: "{assignment["example"]}"')
1535
- if assignment.get("note"):
1536
- lines.append(f" Note: {assignment['note']}")
1537
- # LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
1538
- # uses the legacy generic length guidance. The treatment's per-style
1539
- # target prompt remains preserved only in the shipped experiment card.
1540
- #
1541
- # EXCEPTION (2026-07-11, Draft-B explore slot): exploration
1542
- # assignments (pick_exploration_style, marked by `source`) honor the
1543
- # style's own target_chars so the A/B pair diverges on the length
1544
- # axis too. The uniform clamp flattened every draft to the same
1545
- # 2-sentence shape (user: "the older drafts looked all very similar
1546
- # in terms of the length"). The 06-04 conclusion still governs the
1547
- # scored path, which never carries `source`.
1548
- #
1549
- # (The draft-prompt v3 treatment arm above supersedes this clamp
1550
- # for its cycles; this legacy branch IS the v3 control arm.)
1551
- _explore_tc = assignment.get("target_chars")
1552
- if (assignment.get("source") in ("human_derived", "model_invented")
1553
- and _explore_tc):
1554
- lines.append("")
1555
- lines.append(
1556
- f"**LENGTH: aim for about {int(_explore_tc)} characters** "
1557
- "(this style's own winning length; within about 30% either "
1558
- "way is fine, never above 250). Let the target set the form: "
1559
- "a very short target means one clipped line, a long one can "
1560
- "breathe. Do NOT default to the usual two-sentence shape. "
1561
- "This applies to the comment text only; any link/CTA the "
1562
- "system appends afterward is separate."
1563
- )
1564
- else:
1565
- lines.append("")
1566
- lines.append(
1567
- "**LENGTH: keep it tight.** One or two sentences, well under the "
1568
- "250-character Twitter limit. A short, sharp reply almost always "
1569
- "beats a paragraph. This applies to the comment text only; any "
1570
- "link/CTA the system appends afterward is separate."
1571
- )
1572
1609
  lines.append("")
1573
1610
  lines.append(
1574
1611
  'In your output JSON, set "engagement_style" to exactly '