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

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.21",
3
- "installedAt": "2026-07-17T22:22:26.590Z"
2
+ "version": "1.7.6-rc.22",
3
+ "installedAt": "2026-07-17T22:37:48.040Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.6-rc.21",
5
+ "version": "1.7.6-rc.22",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.21",
3
+ "version": "1.7.6-rc.22",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.21",
3
+ "version": "1.7.6-rc.22",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -85,15 +85,17 @@ def load_config():
85
85
 
86
86
  def get_our_account(config, platform):
87
87
  accounts = config.get("accounts", {})
88
+ # No hardcoded fallback on any platform: a default handle/username/name stamped
89
+ # on a DM silently mis-attributes it to the repo owner. Resolve from config / env
90
+ # through the one resolver; "" means unknown (caller decides how to degrade).
91
+ from account_resolver import resolve as _resolve_account
88
92
  if platform == "reddit":
89
- return accounts.get("reddit", {}).get("username", "Deep_Ad1959")
93
+ return accounts.get("reddit", {}).get("username") or _resolve_account("reddit") or ""
90
94
  elif platform == "linkedin":
91
- return accounts.get("linkedin", {}).get("name", "Matthew Diakonov")
95
+ return accounts.get("linkedin", {}).get("name") or _resolve_account("linkedin") or ""
92
96
  elif platform == "x":
93
- # No hardcoded fallback: stamping a default handle on an outbound DM
94
- # silently mis-attributes it to the repo owner. Resolve from config / env
95
- # and fail loud if absent.
96
- from account_resolver import resolve as _resolve_account
97
+ # Twitter fails LOUD if unresolved (an outbound DM must carry a real
98
+ # sender); reddit/linkedin above degrade to "" since their callers can.
97
99
  h = _resolve_account("twitter")
98
100
  if not h:
99
101
  raise RuntimeError(
@@ -151,9 +151,10 @@ def _treatment_core(voice_examples_ref):
151
151
  """
152
152
  return (
153
153
  "PRIORITY ORDER for how you write this, highest first: (1) "
154
- "learned_preferences.draft_style_notes and edit_examples are the "
154
+ "learned_preferences.draft_style_notes are the "
155
155
  "strongest signal available, real human corrections to this "
156
- "account's own past drafts, and are MANDATORY, not advisory. (2) "
156
+ "account's own past drafts (some carry a before/after example), and "
157
+ "are MANDATORY, not advisory. (2) "
157
158
  f"The ACCOUNT VOICE CORPUS block and {voice_examples_ref} are "
158
159
  "VERBATIM GROUND TRUTH for how this account actually writes: "
159
160
  "capitalization, punctuation, contractions, sentence length, "
@@ -332,6 +333,9 @@ def global_learned_prefs_json():
332
333
  config = _load_config()
333
334
  block = lp.get_global_block(config)
334
335
  block.pop("history", None)
336
+ # edit_examples feed only the digest now (2026-07-17); the drafting and
337
+ # judging prompts read their distilled form via draft_style_notes.
338
+ block.pop("edit_examples", None)
335
339
  return json.dumps(block, indent=2)
336
340
  except Exception:
337
341
  return "{}"
@@ -386,7 +386,11 @@ def get_recent_archetypes(platform, limit=3):
386
386
 
387
387
  def build_prompt(reply, recent_replies, config, excluded_authors, top_report="", prior_history_block="", meta_callout=None):
388
388
  """Build a minimal prompt for one reply."""
389
- reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "Deep_Ad1959")
389
+ # Resolve through the one account resolver (env -> config); never a hardcoded
390
+ # username. Empty means "unknown account" (the prompt just omits it) rather
391
+ # than silently impersonating the repo owner on a misconfigured install.
392
+ from account_resolver import resolve as _resolve_account
393
+ reddit_username = _resolve_account("reddit") or ""
390
394
  reply_json = json.dumps(reply, indent=2)
391
395
 
392
396
  # Moltbook: skip recent_replies + top_report context blocks. Both are
@@ -55,7 +55,6 @@ MIN_STYLE_SAMPLE = 10
55
55
  WARM_MIN_LAST_30D = 4
56
56
  WARM_MIN_CHILD_REPLIES = 1
57
57
  MAX_WARM_PROMOTIONS_PER_RUN = 3
58
- ENTRY_CHAR_LIMIT = 200 # mirrors learned_preferences.MAX_ENTRY_CHARS
59
58
 
60
59
 
61
60
  def fetch_window_rows(platform: str, days: int, limit_pages: int = 20) -> list[dict]:
@@ -165,7 +164,7 @@ def build_warm_entry(w: dict) -> str:
165
164
  f"({w['replied']}/{w['last_30d']} replied in 30d, {w['child_replies']} child "
166
165
  f"replies, 0 risk skips); warm to more engaged/product-relevant replies."
167
166
  )
168
- return entry[:ENTRY_CHAR_LIMIT]
167
+ return entry
169
168
 
170
169
 
171
170
  def build_style_note(top: dict) -> str:
@@ -174,8 +173,6 @@ def build_style_note(top: dict) -> str:
174
173
  f"({top['child_reply_rate']:.2f}/reply, n={top['n']}); prefer it for technical-"
175
174
  "correction replies (concrete metric, name the exact failure mode)."
176
175
  )
177
- if len(note) > ENTRY_CHAR_LIMIT:
178
- note = note[: ENTRY_CHAR_LIMIT - 1].rsplit(" ", 1)[0] + "…"
179
176
  return note
180
177
 
181
178
 
@@ -277,6 +277,30 @@ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] |
277
277
  f"{plat_lines}"
278
278
  )
279
279
 
280
+ ex_pairs = block.get("edit_examples") or []
281
+ if ex_pairs:
282
+ ex_lines = "\n".join(
283
+ f"{i + 1}. ORIGINAL (ours): {e.get('original', '')}\n"
284
+ f" FINAL (user rewrote to): {e.get('final', '')}"
285
+ for i, e in enumerate(ex_pairs)
286
+ )
287
+ edit_examples_block = (
288
+ f"\n\nACCUMULATED EDIT EXAMPLES ({len(ex_pairs)} total, newest "
289
+ "first): each is a draft the user hand-rewrote on the review card "
290
+ "before approving it, so every FINAL is a direct statement of the "
291
+ "voice they want. The drafting model NO LONGER sees these pairs "
292
+ "directly, so the ONLY way a rewrite's lesson reaches drafting is "
293
+ "if you distill it into a draft_style_notes entry. When 2+ examples "
294
+ "share a correction (a phrase type removed, a structure replaced, "
295
+ "tone or length shifted), write ONE draft_style_notes entry and "
296
+ "embed a short before/after fragment as an anchor (e.g. rewrote "
297
+ "'X' to 'Y') so the concrete rewrite travels with the rule. Ignore "
298
+ "lead-specific or cosmetic edits (typo fixes, one-off facts); learn "
299
+ f"only what generalizes across products.\n{ex_lines}"
300
+ )
301
+ else:
302
+ edit_examples_block = ""
303
+
280
304
  return f"""You maintain the SINGLE, install-wide learned_preferences block for this social-posting pipeline (shared across every configured project, not just "{project.get('name')}"). It distills the user's own approve/reject decisions on draft cards into short standing preferences that steer future thread selection and drafting across ALL projects. It is SOFT guidance read by the drafting model, not a filter.
281
305
 
282
306
  The events below all happened to come from project "{project.get('name')}"'s cards, but since the block is shared, only propose an entry that generalizes to a human reviewer's standing taste or quality bar (voice, tone, structural habits, author-quality signals, what counts as a good vs bad reply) — NOT something that is true only because of this one product's specific audience, niche, or content angle (e.g. "prefers accounts studying for nursing boards" belongs to one product's ICP, not to every project this reviewer runs). When the evidence is really project-specific, prefer NO change over forcing it into the shared block.
@@ -285,7 +309,7 @@ CURRENT learned_preferences (shared by every project):
285
309
  {json.dumps({k: block[k] for k in ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")}, indent=2)}
286
310
 
287
311
  CURRENT voice.never: {json.dumps(voice_never)}
288
- CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
312
+ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}{edit_examples_block}
289
313
 
290
314
  NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved, {len(plat_removed)} platform moderation strikes):
291
315
  {ev_lines}{overall_block}{platform_block}
@@ -304,8 +328,8 @@ Propose changes to the block. RULES, in priority order:
304
328
  3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
305
329
  4. Approvals are counter-evidence. If approvals contradict an existing entry, propose removing or narrowing it. Also propose removing entries that events show are stale.
306
330
  5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
307
- 6. Each entry: one sentence, under 200 characters, plain language, no em dashes, no hashtags, understandable a month from now without these events.
308
- 7. Respect the cap: at most {lp.MAX_ENTRIES_PER_LIST} entries per list. If a list is full, fold the new signal into an existing entry via remove+add.
331
+ 6. Keep each entry plain language, no em dashes, no hashtags, understandable a month from now without these events. There is NO length or per-list count cap. A draft_style_notes entry SHOULD embed a short before/after fragment from the edit examples when one fits (e.g. rewrote 'X' to 'Y'); that anchor is the only way the concrete rewrite reaches the drafting model. audience_avoid, audience_prefer, and thread_avoid stay short type descriptions with no example.
332
+ 7. Never propose a note that tells the writer to drop, strip, omit, or leave out the product link or url, or to remove trailing punctuation. Those contradict the tail-link bridge feature and are auto-rejected on write, so spend no entry on them even if an edit example shows the user deleting a link.
309
333
  8. Scope check (this block is shared across every project): before proposing an entry, ask whether it would still make sense read from a totally different product's cards. If it only makes sense for "{project.get('name')}" specifically, don't add it.
310
334
 
311
335
  OUTPUT: a single JSON object, nothing else. Schema:
@@ -68,20 +68,17 @@ MANAGED_LISTS = ("audience_avoid", "audience_prefer", "thread_avoid", "draft_sty
68
68
  # Existing config fields the digest may APPEND to (never remove from).
69
69
  APPEND_ONLY_FIELDS = ("voice_never_add", "guardrails_do_not_add")
70
70
 
71
- MAX_ENTRIES_PER_LIST = 10
72
- MAX_ENTRY_CHARS = 200
73
71
  MAX_HISTORY = 50
74
72
 
75
- # Few-shot edit examples (2026-07-06, ported from the s4l-email inbox repo's
76
- # original_draft_body/draft_body pair): when the user rewrites a draft on the
77
- # review card before approving, the (original, final) pair is the strongest
78
- # style signal we have; showing recent pairs to the drafting model beats any
79
- # distilled negative rule (measured: the draft-prompt A/B's one-bullet skeleton
80
- # ban moved nothing, treatment 30% ~= control 28%). Written DETERMINISTICALLY
81
- # by feedback_digest.record via record_edit_examples(), never by the digest
82
- # LLM's mutation plan (apply_mutations drops 'edit_examples' as unknown).
83
- MAX_EDIT_EXAMPLES = 5
84
- MAX_EXAMPLE_CHARS = 600
73
+ # Edit examples: (original, final) pairs from the user's own card rewrites,
74
+ # recorded DETERMINISTICALLY by feedback_digest via record_edit_examples()
75
+ # (never by the digest LLM's mutation plan). As of 2026-07-17 these are read
76
+ # ONLY by the feedback digest, which distills recurring corrections into
77
+ # draft_style_notes (optionally embedding a short before/after fragment as an
78
+ # anchor); the drafting/judging prompts NO LONGER embed them. Kept newest-first
79
+ # and capped at MAX_EDIT_EXAMPLES so the digest sees a deep rewrite history.
80
+ # No per-entry length cap: a rewrite is the user's exact words, stored verbatim.
81
+ MAX_EDIT_EXAMPLES = 100
85
82
 
86
83
  # Travels inside the global block the prep prompt embeds (once, via
87
84
  # GLOBAL_LEARNED_PREFS_JSON), so the drafting model reads its own operating
@@ -100,12 +97,9 @@ DEFAULT_INSTRUCTION = (
100
97
  "exceptionally on-topic despite a match. When WRITING a draft, "
101
98
  "draft_style_notes is MANDATORY, not advisory: follow every entry, and "
102
99
  "on conflict it overrides the engagement style's structural template. "
103
- "edit_examples are before/after pairs from the user's own manual edits "
104
- "on the review card: 'original' is the draft we wrote, 'final' is what "
105
- "the user rewrote it to before posting. Treat every 'final' as the "
106
- "target voice and every 'original' as the rejected voice: write new "
107
- "drafts in the style of the finals, and never reproduce phrasing or "
108
- "structure that the user edited away."
100
+ "Some draft_style_notes carry a short before/after example of a real "
101
+ "user rewrite; match the 'after' phrasing and never reproduce the "
102
+ "'before'."
109
103
  )
110
104
 
111
105
 
@@ -156,15 +150,15 @@ def normalized(block) -> dict:
156
150
  }
157
151
  for key in MANAGED_LISTS:
158
152
  vals = b.get(key)
159
- out[key] = [str(v).strip()[:MAX_ENTRY_CHARS] for v in vals if str(v).strip()] if isinstance(vals, list) else []
153
+ out[key] = [str(v).strip() for v in vals if str(v).strip()] if isinstance(vals, list) else []
160
154
  # Few-shot before/after pairs from the user's card edits (newest first).
161
155
  # Preserved through normalization so apply_mutations round-trips never
162
156
  # drop them; only record_edit_examples() writes this list.
163
157
  ex = b.get("edit_examples")
164
158
  out["edit_examples"] = [
165
159
  {
166
- "original": str(e.get("original") or "")[:MAX_EXAMPLE_CHARS],
167
- "final": str(e.get("final") or "")[:MAX_EXAMPLE_CHARS],
160
+ "original": str(e.get("original") or ""),
161
+ "final": str(e.get("final") or ""),
168
162
  "ts": e.get("ts"),
169
163
  }
170
164
  for e in (ex if isinstance(ex, list) else [])
@@ -223,12 +217,8 @@ def prompt_block(project_cfg=None) -> str:
223
217
  for key in MANAGED_LISTS:
224
218
  for v in b[key]:
225
219
  lines.append(f"- {labels[key]}: {v}")
226
- for e in b["edit_examples"]:
227
- lines.append(
228
- "- Edit example (write like FINAL, never like ORIGINAL):\n"
229
- f" ORIGINAL (ours, rejected style): {e['original']}\n"
230
- f" FINAL (user's rewrite, target style): {e['final']}"
231
- )
220
+ # edit_examples are intentionally NOT rendered here (2026-07-17): they feed
221
+ # only the feedback digest now, which distills them into draft_style_notes.
232
222
  if not lines:
233
223
  return ""
234
224
  return (
@@ -240,15 +230,15 @@ def prompt_block(project_cfg=None) -> str:
240
230
  )
241
231
 
242
232
 
243
- def _validate_add_list(raw, cap=MAX_ENTRIES_PER_LIST):
233
+ def _validate_add_list(raw, cap=None):
244
234
  out = []
245
235
  if not isinstance(raw, list):
246
236
  return out
247
237
  for v in raw:
248
238
  s = str(v).strip()
249
239
  if s:
250
- out.append(s[:MAX_ENTRY_CHARS])
251
- if len(out) >= cap:
240
+ out.append(s)
241
+ if cap is not None and len(out) >= cap:
252
242
  break
253
243
  return out
254
244
 
@@ -279,8 +269,8 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
279
269
  if not orig or not final or orig == final:
280
270
  continue
281
271
  clean.append({
282
- "original": orig[:MAX_EXAMPLE_CHARS],
283
- "final": final[:MAX_EXAMPLE_CHARS],
272
+ "original": orig,
273
+ "final": final,
284
274
  "ts": str(p.get("ts") or _now_iso()),
285
275
  })
286
276
  if not clean:
@@ -390,9 +380,15 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
390
380
  for v in _validate_add_list(ops.get("add")):
391
381
  if v in block[key]:
392
382
  continue
393
- if len(block[key]) >= MAX_ENTRIES_PER_LIST:
394
- dropped.append(f"{key} at cap ({MAX_ENTRIES_PER_LIST}), skipped: {v}")
395
- continue
383
+ # Never let the digest (re)learn a link/punctuation suppression
384
+ # note: it contradicts the tail-link bridge feature. Same guard
385
+ # migrate_to_global uses, now enforced on every digest write so
386
+ # reading the edit_examples pool can't resurrect URL-stripping.
387
+ if key == "draft_style_notes":
388
+ reason = _excluded_note_reason(v)
389
+ if reason:
390
+ dropped.append(f"{key} rejected ({reason}): {v}")
391
+ continue
396
392
  block[key].append(v)
397
393
  applied.append(f"{key} added: {v}")
398
394
 
@@ -528,9 +524,6 @@ def migrate_to_global(cfg_path: str | None = None) -> dict:
528
524
  continue
529
525
  if v in global_block[key]:
530
526
  continue
531
- if len(global_block[key]) >= MAX_ENTRIES_PER_LIST:
532
- dropped_at_cap.append({"project": pname, "key": key, "value": v})
533
- continue
534
527
  global_block[key].append(v)
535
528
  touched = True
536
529
  existing_finals = {e["final"] for e in global_block["edit_examples"]}
@@ -552,8 +545,6 @@ def migrate_to_global(cfg_path: str | None = None) -> dict:
552
545
  summary = f"migrated {len(projects_merged)} project block(s) into learned_preferences_global"
553
546
  if excluded:
554
547
  summary += f"; excluded {len(excluded)} note(s) (link/punctuation suppression)"
555
- if dropped_at_cap:
556
- summary += f"; {len(dropped_at_cap)} entrie(s) dropped at the {MAX_ENTRIES_PER_LIST}-per-list cap"
557
548
  merged_history.append({
558
549
  "ts": _now_iso(),
559
550
  "change": summary,
@@ -2878,7 +2878,10 @@ def main():
2878
2878
  args = parser.parse_args()
2879
2879
 
2880
2880
  config = load_config()
2881
- reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "Deep_Ad1959")
2881
+ # Resolve through the one account resolver (env -> config); never a hardcoded
2882
+ # username. Empty = "unknown account" rather than impersonating the repo owner.
2883
+ from account_resolver import resolve as _resolve_account
2884
+ reddit_username = _resolve_account("reddit") or ""
2882
2885
 
2883
2886
  if args.phase == "phase0":
2884
2887
  # Hard-expire stale pending rows + re-assign salvageable rows to the
@@ -91,7 +91,9 @@ VIEWPORT = {"width": 911, "height": 1016}
91
91
  # matching comments → permalink=None → pipeline records `failed` despite
92
92
  # the comment landing on Reddit).
93
93
  _config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
94
- OUR_USERNAME = "Deep_Ad1959"
94
+ # No hardcoded username: "" means "unknown account". A wrong default here silently
95
+ # mis-attributes / breaks the own-comment permalink lookup on a misconfigured install.
96
+ OUR_USERNAME = ""
95
97
  if os.path.exists(_config_path):
96
98
  try:
97
99
  with open(_config_path) as f:
@@ -48,7 +48,9 @@ USER_AGENT = (
48
48
  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
49
49
  )
50
50
 
51
- OUR_USERNAME = "Deep_Ad1959"
51
+ # No hardcoded username: "" means "unknown account" rather than impersonating the
52
+ # repo owner when config.json carries no reddit account.
53
+ OUR_USERNAME = ""
52
54
  _config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
53
55
  if os.path.exists(_config_path):
54
56
  try:
@@ -912,7 +912,7 @@ def main():
912
912
  p_log.add_argument("project")
913
913
  p_log.add_argument("thread_author")
914
914
  p_log.add_argument("thread_title")
915
- p_log.add_argument("--account", default="Deep_Ad1959")
915
+ p_log.add_argument("--account", default=(_resolve_account("reddit") or ""))
916
916
  p_log.add_argument("--engagement-style", default=None)
917
917
  p_log.add_argument("--search-topic", dest="search_topic", default=None,
918
918
  help="The seed topic/query used to find this thread (feedback loop input)")
@@ -24,6 +24,22 @@ Two checks:
24
24
  bug_twitter_handle_scrape_brittle_m13v_fallback.md and
25
25
  bug_multitenant_no_install_scoping_account_fallback.md).
26
26
 
27
+ 3. check_style_assignment_render_agrees_with_pick() -- an engagement-style
28
+ picker (engagement_styles.pick_style_for_post/pick_exploration_style)
29
+ and the prompt renderer (get_assigned_style_prompt) must never disagree
30
+ about what was assigned, on any platform, under any draft-prompt
31
+ experiment arm; whatever a picker assigns must also survive
32
+ validate_or_register() unmodified. Deliberately asserts nothing about
33
+ what any specific arm's CONTENT is (arms are experiments and will keep
34
+ changing) -- only that the picker and the renderer agree with each
35
+ other for whatever they currently do. Guards against the class of bug
36
+ where a special-case decision lives in only one of the two functions:
37
+ one platform's driver hand-patches around the gap, the next platform's
38
+ driver (calling the picker directly) doesn't, and a real post lands
39
+ with the wrong engagement_style, silently coerced by
40
+ validate_or_register's drift protection (2026-07-17, see commit
41
+ 2723662d and the Reddit r/saasbuild incident it fixed).
42
+
27
43
  Run:
28
44
  python3 scripts/test_no_silent_fallbacks.py
29
45
  Exit 0 = all pass; non-zero with FAIL lines otherwise.
@@ -273,11 +289,68 @@ def check_account_resolver_hard_fails():
273
289
  os.environ.update(saved_env)
274
290
 
275
291
 
292
+ # ---------------------------------------------------------------------------
293
+ # Check 3: engagement-style picker and prompt renderer must never disagree
294
+ # ---------------------------------------------------------------------------
295
+
296
+ def check_style_assignment_render_agrees_with_pick():
297
+ sys.path.insert(0, os.path.join(REPO_ROOT, "scripts"))
298
+ import engagement_styles as es
299
+ import draft_prompt_core as dpc
300
+
301
+ saved_env = os.environ.get("S4L_DRAFT_PROMPT_VARIANT")
302
+ try:
303
+ # dpc.ARM_TREATMENT is whatever the CURRENT draft-prompt experiment's
304
+ # specially-handled arm is called -- read from its one source of
305
+ # truth, never duplicated as a literal here, so a future rename
306
+ # (e.g. treatment_v4 -> treatment_v5) needs no change to this test.
307
+ # This is also the arm that resolves without any network call in
308
+ # both functions under test, so this check stays fast and offline.
309
+ os.environ["S4L_DRAFT_PROMPT_VARIANT"] = dpc.ARM_TREATMENT
310
+ for platform in ("twitter", "reddit", "linkedin", "github", "moltbook"):
311
+ pickers = (
312
+ ("pick_style_for_post",
313
+ lambda p=platform: es.pick_style_for_post(p, context="posting")),
314
+ ("pick_exploration_style",
315
+ lambda p=platform: es.pick_exploration_style(p, context="posting", exclude=set())),
316
+ )
317
+ for label, picker in pickers:
318
+ assignment = picker()
319
+ if not assignment or assignment.get("mode") != "use" or not assignment.get("style"):
320
+ continue # nothing assigned this call -- nothing to cross-check
321
+
322
+ rendered = es.get_assigned_style_prompt(platform, assignment, context="posting")
323
+ check(
324
+ f"{platform}/{label}: render names the exact style it was handed",
325
+ assignment["style"] in rendered,
326
+ f"assigned={assignment['style']!r}",
327
+ )
328
+
329
+ decision = {"engagement_style": assignment["style"], "new_style": None}
330
+ validated_style, action = es.validate_or_register(
331
+ decision,
332
+ assigned_style=assignment["style"],
333
+ assigned_mode=assignment["mode"],
334
+ )
335
+ check(
336
+ f"{platform}/{label}: assignment survives validate_or_register unmodified",
337
+ action in ("valid", "registered") and validated_style == assignment["style"],
338
+ f"assigned={assignment['style']!r} action={action!r} got={validated_style!r}",
339
+ )
340
+ finally:
341
+ if saved_env is None:
342
+ os.environ.pop("S4L_DRAFT_PROMPT_VARIANT", None)
343
+ else:
344
+ os.environ["S4L_DRAFT_PROMPT_VARIANT"] = saved_env
345
+
346
+
276
347
  def main():
277
348
  print("-- check 1: no bare-python3 subprocess spawning a playwright-dependent script --")
278
349
  check_no_bare_playwright_subprocess()
279
350
  print("-- check 2: account_resolver hard-fails instead of impersonating --")
280
351
  check_account_resolver_hard_fails()
352
+ print("-- check 3: engagement-style picker and renderer agree, on every platform --")
353
+ check_style_assignment_render_agrees_with_pick()
281
354
 
282
355
  if FAILS:
283
356
  print(f"\n{len(FAILS)} FAILURE(S):")