@m13v/s4l 1.7.6-rc.8 → 1.7.6

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 (85) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +33 -9
  3. package/mcp/dist/index.js +290 -177
  4. package/mcp/dist/panel.html +31 -17
  5. package/mcp/dist/setup.js +27 -7
  6. package/mcp/dist/telemetry.js +15 -6
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +10 -2
  9. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  10. package/mcp/menubar/s4l_card.py +63 -4
  11. package/mcp/menubar/s4l_card_canvas.py +62 -11
  12. package/mcp/menubar/s4l_menubar.py +380 -95
  13. package/mcp/menubar/s4l_state.py +119 -41
  14. package/mcp/package.json +1 -1
  15. package/package.json +1 -1
  16. package/scripts/account_resolver.py +58 -14
  17. package/scripts/amplitude_24h_signups.py +7 -4
  18. package/scripts/amplitude_signups.py +7 -4
  19. package/scripts/autopilot_stall_watch.py +8 -1
  20. package/scripts/browser_lifecycle.py +90 -0
  21. package/scripts/cdp_ready_check.py +81 -50
  22. package/scripts/claude_job.py +70 -10
  23. package/scripts/dm_conversation.py +15 -12
  24. package/scripts/draft_prompt_core.py +132 -64
  25. package/scripts/engage_github.py +11 -5
  26. package/scripts/engage_reddit.py +12 -5
  27. package/scripts/engagement_signal_feedback.py +1 -4
  28. package/scripts/engagement_styles.py +144 -105
  29. package/scripts/feedback_digest.py +54 -11
  30. package/scripts/find_threads.py +9 -7
  31. package/scripts/github_tools.py +23 -10
  32. package/scripts/learned_preferences.py +112 -78
  33. package/scripts/li_discovery.py +4 -1
  34. package/scripts/link_tail.py +1 -1
  35. package/scripts/live_status.py +151 -0
  36. package/scripts/merge_review_queue.py +15 -2
  37. package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
  38. package/scripts/pick_project.py +7 -4
  39. package/scripts/pick_thread_target.py +64 -13
  40. package/scripts/pick_twitter_thread_target.py +7 -4
  41. package/scripts/platform_strike_events.py +139 -7
  42. package/scripts/post_github.py +376 -35
  43. package/scripts/post_reddit.py +202 -192
  44. package/scripts/preflight.sh +23 -3
  45. package/scripts/preview_reddit_card.py +38 -0
  46. package/scripts/project_stats.py +7 -4
  47. package/scripts/reddit_ban_check.py +324 -0
  48. package/scripts/reddit_browser.py +56 -19
  49. package/scripts/reddit_chat_sync.py +10 -11
  50. package/scripts/reddit_tools.py +20 -4
  51. package/scripts/release-mcpb.sh +47 -7
  52. package/scripts/run_moltbook_cycle.py +9 -5
  53. package/scripts/s4l_ctl.sh +2 -2
  54. package/scripts/s4l_posting_mode.py +67 -6
  55. package/scripts/scan_dm_candidates.py +15 -9
  56. package/scripts/scan_github_replies.py +9 -7
  57. package/scripts/scan_moltbook_replies.py +7 -6
  58. package/scripts/scan_reddit_replies.py +11 -7
  59. package/scripts/scan_twitter_mentions_browser.py +7 -6
  60. package/scripts/scan_twitter_thread_followups.py +7 -6
  61. package/scripts/schedule_state.py +8 -4
  62. package/scripts/score_twitter_candidates.py +8 -3
  63. package/scripts/setup_twitter_auth.py +20 -96
  64. package/scripts/snapshot.py +19 -0
  65. package/scripts/stats.py +12 -10
  66. package/scripts/strike_alert.py +165 -12
  67. package/scripts/sub_moderation_history.py +129 -0
  68. package/scripts/subreddit_bans_client.py +157 -0
  69. package/scripts/test_cross_language_parity.py +185 -0
  70. package/scripts/test_no_silent_fallbacks.py +84 -0
  71. package/scripts/test_percard_posting.py +3 -3
  72. package/scripts/test_stall_watch_batch_progression.py +99 -0
  73. package/scripts/top_performers.py +24 -0
  74. package/scripts/twitter_browser.py +22 -52
  75. package/scripts/twitter_gen_links.py +3 -3
  76. package/scripts/twitter_post_plan.py +4 -4
  77. package/scripts/virality.py +151 -0
  78. package/setup/SKILL.md +1 -1
  79. package/skill/engage-dm-replies.sh +11 -0
  80. package/skill/lib/reddit-backend.sh +1 -0
  81. package/skill/run-draft-and-publish.sh +16 -0
  82. package/skill/run-reddit-search-launchd.sh +1 -0
  83. package/skill/run-reddit-search.sh +10 -1
  84. package/skill/run-reddit-threads.sh +12 -0
  85. package/skill/run-twitter-cycle.sh +104 -88
@@ -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,84 @@ 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 are the "
155
+ "strongest signal available, real human corrections to this "
156
+ "account's own past drafts (some carry a before/after example), and "
157
+ "are MANDATORY, not advisory. (2) "
158
+ f"The ACCOUNT VOICE CORPUS block and {voice_examples_ref} are "
159
+ "VERBATIM GROUND TRUTH for how this account actually writes: "
160
+ "capitalization, punctuation, contractions, sentence length, "
161
+ "terseness. Match those mechanics exactly. (3) voice.tone is a "
162
+ "supporting description only, never ground truth: if it ever "
163
+ "conflicts with what the corpus/examples actually show, follow the "
164
+ "examples, not the tone description. Do NOT use the "
165
+ "concede-then-reverse skeleton in ANY form. Banned openings "
166
+ "include: 'X is the easy part/half/win, the hard part is Y'; 'X "
167
+ "was never the [thing], it's Y'; 'X isn't the [problem], it's Y'; "
168
+ "'the real/actual/harder part is Y'; 'what actually "
169
+ "breaks/ships/matters is Y'; 'the part nobody says/shows is Y'; 'X "
170
+ "is solved, Y is what breaks'. Lead with substance from ONE entry "
171
+ "point and vary the entry point AND the rhetorical move across "
172
+ "every reply you draft this batch, even when the same topic or "
173
+ "style would naturally suggest reusing the same construction: a "
174
+ "concrete first-hand specific or number; a direct answer to the "
175
+ "exact question asked; one sharp opinion with no hedge; a genuine "
176
+ "question that moves the thread forward; or a relevant pointer. No "
177
+ "warm-up framing sentence before the substance."
178
+ )
124
179
 
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
180
 
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."
181
+ _TREATMENT_LENGTH_NOTE = (
182
+ "Length: match how long the account's own real examples above actually "
183
+ "run; when the corpus doesn't make it obvious, default to one or two "
184
+ "sentences, well under Twitter's 250-character practical limit. NEVER "
185
+ "em dashes."
186
+ )
187
+
188
+ _DIRECTIVE_TREATMENT = (
189
+ "Otherwise: draft a direct, natural reply that stands on its own as a "
190
+ "useful contribution to the thread. Mention the matched project only "
191
+ "when it is genuinely the most relevant thing to say, and state it "
192
+ "plainly in one clause; most replies will not need it. "
193
+ + _treatment_core("the matched project's voice.examples") +
194
+ " " + _TREATMENT_LENGTH_NOTE +
195
+ " Never violate voice.never. Treat learned_preferences.audience_avoid "
196
+ "/ thread_avoid matches as strong reasons to skip the candidate. Never "
197
+ "violate content_guardrails.do_not."
198
+ )
128
199
 
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."
200
+ _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."
130
201
 
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."
202
+ _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."
203
+
204
+ _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
205
 
133
206
 
134
207
  def draft_directive(arm=None, lane=None):
@@ -137,17 +210,17 @@ def draft_directive(arm=None, lane=None):
137
210
  lane = lane if lane is not None else os.environ.get("S4L_ACTIVE_LANE", "")
138
211
  if lane == "personal_brand":
139
212
  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)"
213
+ core = " " + _treatment_core("voice.examples above")
214
+ length_note = " " + _TREATMENT_LENGTH_NOTE
215
+ rel = "(per the PRIORITY ORDER above: learned_preferences and the account's real voice are what drive this reply)"
143
216
  else:
144
- ban = ""
145
- priority = ""
217
+ core = ""
218
+ length_note = " Length is governed by the per-style LENGTH LIMIT in the style block above. NEVER em dashes."
146
219
  rel = "(it overrides the engagement style's structural template on conflict)"
147
220
  return (
148
221
  _PERSONA_DIRECTIVE_TEMPLATE
149
- .replace("@SKELETON_BAN@", ban)
150
- .replace("@VOICE_PRIORITY@", priority)
222
+ .replace("@TREATMENT_CORE@", core)
223
+ .replace("@LENGTH_NOTE@", length_note)
151
224
  .replace("@PREFS_RELATION@", rel)
152
225
  )
153
226
  if arm == ARM_TREATMENT:
@@ -260,6 +333,9 @@ def global_learned_prefs_json():
260
333
  config = _load_config()
261
334
  block = lp.get_global_block(config)
262
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)
263
339
  return json.dumps(block, indent=2)
264
340
  except Exception:
265
341
  return "{}"
@@ -279,24 +355,6 @@ def recent_self_block(platform, limit=20):
279
355
  return ""
280
356
 
281
357
 
282
- def product_names():
283
- """Every configured project name (lowercased) plus the agency tag, for
284
- the reddit never-name-the-product rule. Falls back to a generic list on
285
- config failure so the rule never silently vanishes."""
286
- names = []
287
- try:
288
- config = _load_config()
289
- for p in config.get("projects", []):
290
- n = (p.get("name") or "").strip().lower()
291
- if n and n not in names:
292
- names.append(n)
293
- except Exception:
294
- pass
295
- if "s4l" not in names:
296
- names.append("s4l")
297
- return names
298
-
299
-
300
358
  # --------------------------------------------------------------------------
301
359
  # TWITTER renderer. Byte-exact transcription of the run-twitter-cycle.sh
302
360
  # PREP_PROMPT heredoc (2026-07-16). @TOKENS@ are unique and replaced below;
@@ -354,7 +412,7 @@ For each chosen candidate:
354
412
  - 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
413
  - 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
414
  - 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@
415
+ - 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
416
  - @DRAFT_DIRECTIVE@ (applies to both drafts on fresh candidates; each still obeys its OWN style's length limit, not a shared one).
359
417
  3a. PERSIST DRAFT A (skip entirely for reused drafts):
360
418
  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 +497,18 @@ def render_twitter_prompt(ing):
439
497
  """
440
498
  arm = ing.get("arm") or os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or ""
441
499
  lane = ing.get("lane") if ing.get("lane") is not None else os.environ.get("S4L_ACTIVE_LANE", "")
500
+ # picked_style/picked_style_b already carry the sentinel for treatment_v4
501
+ # (engagement_styles.pick_style_for_post/pick_exploration_style refuse to
502
+ # assign a real style under that arm, 2026-07-17 consolidation), no
503
+ # override needed here -- this used to re-derive the sentinel from `arm`
504
+ # independently of the picker, which is exactly the kind of duplicated
505
+ # decision that let a second, unfixed copy (Reddit's post_reddit.py,
506
+ # which calls the picker directly and never went through this function)
507
+ # silently keep assigning real styles under treatment_v4.
442
508
  picked_style = ing.get("picked_style") or ""
509
+ picked_style_b = ing.get("picked_style_b") or ""
510
+ picked_mode = ing.get("picked_mode") or "use"
511
+ picked_mode_b = ing.get("picked_mode_b") or "use"
443
512
  return (
444
513
  _TW_TEMPLATE
445
514
  .replace("@PREFIX@", ing.get("prefix") or "")
@@ -459,10 +528,10 @@ def render_twitter_prompt(ing):
459
528
  .replace("@DRAFT_B_DIVERGENCE_NOTE@", draft_b_divergence_note(arm))
460
529
  .replace("@DRAFT_DIRECTIVE@", draft_directive(arm, lane))
461
530
  .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")
531
+ .replace("@PICKED_STYLE_B@", picked_style_b)
532
+ .replace("@PICKED_MODE_B@", picked_mode_b)
464
533
  .replace("@PICKED_STYLE@", picked_style)
465
- .replace("@PICKED_MODE@", ing.get("picked_mode") or "use")
534
+ .replace("@PICKED_MODE@", picked_mode)
466
535
  )
467
536
 
468
537
 
@@ -526,21 +595,6 @@ OMIT THESE (clear no-bridge cases only):
526
595
  ## THREAD CONTENT (pre-fetched)
527
596
  Each candidate above carries a THREAD CONTENT block (OP + top comments), already fetched for you. Never fetch, navigate, or open any reddit.com URL and never try to reload a thread; apply the SELECTION GATE using the inlined content only. WebSearch and WebFetch ARE available for EXTERNAL fact-checking: use them ONLY when a thread hinges on a current fact, a name, a release, or a claim you are not sure about, so your comment is specific and correct instead of vague. Most comments need no search at all. If a candidate is somehow missing its THREAD CONTENT block, OMIT it.
528
597
 
529
- ## CRITICAL CONTENT RULES (apply only to threads that pass the gate)
530
- These platform rules are ABSOLUTE and override anything more permissive in the DRAFT DIRECTIVE below (including any allowance to mention the project).
531
- - Go BIMODAL on length: 1 punchy sentence (<100 chars) OR 4-5 sentences of real substance. Avoid 2-3 sentence middle-ground.
532
- - GROUNDING RULE — pick ONE lane per comment:
533
- LANE 1 - DISCLOSED STORY: open with a hedge ("hypothetically", "imagine someone running this", "scenario:") then you may invent specifics freely.
534
- LANE 2 - NO FABRICATION: every specific (numbers, durations, places, tools) must appear verbatim in the content_angle above. Otherwise drop the specific and pattern-frame ("the part that breaks down is...", "the typical failure mode is...").
535
- - VOICE RELATIONSHIP: see the dedicated section below; it governs whether you speak AS the maker or as an outside observer.
536
- - NEVER mention product names (@PRODUCT_NAMES@).
537
- - NEVER include URLs or links in your comment text.
538
- - Prefer replying to OP (top-level reply). ONE comment per thread.
539
- - Statements beat questions. Be authoritative, not inquisitive.
540
-
541
- ## Content rules
542
- @CONTENT_RULES@
543
-
544
598
  ## DRAFT A: assigned style
545
599
  @STYLES_BLOCK_A@
546
600
 
@@ -550,15 +604,13 @@ These platform rules are ABSOLUTE and override anything more permissive in the D
550
604
  ## DRAFT DIRECTIVE
551
605
  @DRAFT_DIRECTIVE@ (applies to both drafts; each still obeys its OWN style's length limit, not a shared one.)
552
606
 
553
- @VOICE_RELATIONSHIP@
554
-
555
607
  ## PERSIST EACH DRAFT (keeps this session alive; do it per thread, not at the end)
556
608
  Right after drafting BOTH texts for a thread, persist Draft A (never Draft B) with ONE quick Bash call, then move to the next thread:
557
609
  python3 @REPO_DIR@/scripts/log_draft.py --platform reddit --thread-url 'THREAD_URL' --text 'DRAFT_A_TEXT' --style DRAFT_A_STYLE
558
610
  Work ONE thread at a time: gate it, draft both texts, persist, next. Failure here is non-fatal, log a warning and continue. Only after EVERY thread is handled do you assemble and return the single result JSON.
559
611
 
560
612
  ## OUTPUT FORMAT
561
- Return ONE JSON object with two arrays (a JSON schema is enforced on this session). Both draft_a_text and draft_b_text are REQUIRED for every posts[] entry — write both, under their respective assigned styles above, applying the CRITICAL CONTENT RULES and Content rules to each independently:
613
+ Return ONE JSON object with two arrays (a JSON schema is enforced on this session). Both draft_a_text and draft_b_text are REQUIRED for every posts[] entry — write both, under their respective assigned styles above, applying the DRAFT DIRECTIVE to each independently:
562
614
 
563
615
  {"posts": [...], "rejects": [...]}
564
616
 
@@ -593,6 +645,19 @@ Examples of WRONG proposals (do not emit):
593
645
  Do NOT narrate beyond the persist calls. Gate, draft-or-reject, persist, return the single JSON object."""
594
646
 
595
647
 
648
+ def _strip_grounding_section(styles_block):
649
+ """Drop the '## GROUNDING RULE' tail that get_assigned_style_prompt
650
+ appends to every style block. Reddit-draft-only (2026-07-18 content-rule
651
+ deprecation): the reddit draft prompt no longer carries the GROUNDING
652
+ RULE or VOICE RELATIONSHIP sections, so the style-block copy would both
653
+ resurrect a deprecated rule and dangle a reference to a section that no
654
+ longer exists. X and the engage rails keep their copies (their prompts
655
+ still render the sections it points at). Safe as truncation because the
656
+ grounding rule is the LAST thing get_assigned_style_prompt appends."""
657
+ i = (styles_block or "").find("## GROUNDING RULE")
658
+ return styles_block[:i].rstrip() if i != -1 else (styles_block or "")
659
+
660
+
596
661
  def render_reddit_prompt(ing):
597
662
  """Render the full Reddit draft prompt. Keys in `ing`:
598
663
  project_name, content_angle, candidates_block, n_candidates, batch_id,
@@ -601,14 +666,16 @@ def render_reddit_prompt(ing):
601
666
  recent_self_block, arm (optional), lane (optional, promotion assumed).
602
667
  """
603
668
  arm = ing.get("arm") or os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or ""
604
- # Reddit drafting is promotion-lane work by definition today (one product
605
- # project per draft call); the persona directive would reference blocks
606
- # this prompt does not carry. Force the promotion directive regardless of
607
- # any stray S4L_ACTIVE_LANE in the environment.
669
+ # Lane comes ONLY from the ingredients (post_reddit.py stamps it onto the
670
+ # discover plan at the cycle's assignment point and threads it through
671
+ # build_draft_prompt). Deliberately NOT read from S4L_ACTIVE_LANE here:
672
+ # salvage plans drafted in a later cycle must keep their own (promotion)
673
+ # directive even when THIS cycle's coin flip landed personal_brand.
674
+ # lane='personal_brand' renders the persona directive + the persona-
675
+ # whitelisted projects_json; the RD template carries every block the
676
+ # persona directive references (@CORPUS_BLOCK@ = ACCOUNT VOICE CORPUS).
608
677
  lane = ing.get("lane") or ""
609
678
 
610
- from engagement_styles import get_content_rules, get_voice_relationship_rule
611
-
612
679
  top_ctx = ""
613
680
  if not skip_top_report(arm):
614
681
  top_report = ing.get("top_report") or ""
@@ -630,15 +697,16 @@ def render_reddit_prompt(ing):
630
697
  .replace("@BATCH_ID@", ing.get("batch_id") or "")
631
698
  .replace("@RECENT_SELF_BLOCK@", ing.get("recent_self_block") or "")
632
699
  .replace("@TOP_CTX@", top_ctx)
633
- .replace("@PRODUCT_NAMES@", ", ".join(product_names()))
634
- .replace("@CONTENT_RULES@", get_content_rules("reddit"))
635
- .replace("@STYLES_BLOCK_A@", ing.get("styles_block_a") or "")
636
- .replace("@STYLES_BLOCK_B@", ing.get("styles_block_b") or "")
700
+ .replace("@STYLES_BLOCK_A@", _strip_grounding_section(ing.get("styles_block_a")))
701
+ .replace("@STYLES_BLOCK_B@", _strip_grounding_section(ing.get("styles_block_b")))
637
702
  .replace("@DRAFT_DIRECTIVE@", draft_directive(arm, lane))
638
- .replace("@VOICE_RELATIONSHIP@", get_voice_relationship_rule())
639
703
  .replace("@STYLE_A_NAME@", ing.get("style_a_name") or "style_name")
640
704
  .replace("@STYLE_B_NAME@", ing.get("style_b_name") or "style_name")
641
705
  .replace("@PROJECT_NAME@", project_name)
706
+ # The treatment length note is shared text written for X; on Reddit
707
+ # keep the same short-length instruction without the platform label.
708
+ .replace("well under Twitter's 250-character practical limit",
709
+ "well under 250 characters")
642
710
  )
643
711
 
644
712
 
@@ -40,7 +40,13 @@ except Exception: # never let a missing module break the engage lane
40
40
  return ""
41
41
 
42
42
  REPO_DIR = os.path.expanduser("~/social-autoposter")
43
- CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
43
+ # THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
44
+ # S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
45
+ # its hardcoded config path (the S4L-4H dead-path class on customer boxes).
46
+ import os as _cfg_os, sys as _cfg_sys
47
+ _cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
48
+ from config import config_path as _canonical_config_path, load_config
49
+ CONFIG_PATH = _canonical_config_path()
44
50
  REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
45
51
  SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
46
52
 
@@ -60,9 +66,6 @@ os.environ["S4L_PYTHON"] = PYTHON
60
66
  THREAD_CHAR_CAP = 12000
61
67
 
62
68
 
63
- def load_config():
64
- with open(CONFIG_PATH) as f:
65
- return json.load(f)
66
69
 
67
70
 
68
71
  def get_next_pending():
@@ -451,7 +454,10 @@ def main():
451
454
  # (HTTP-only now; per-account scoping is resolved inside the helper.)
452
455
  from github_tools import _dynamic_owner_blocklist
453
456
  excluded_repos = excluded_repos | _dynamic_owner_blocklist()
454
- our_username = config.get("accounts", {}).get("github", {}).get("username", "m13v")
457
+ # Resolved identity, never a hardcoded default (the old "m13v" fallback
458
+ # silently self-filtered/attributed as the repo owner on other installs).
459
+ from account_resolver import resolve as _resolve_account
460
+ our_username = _resolve_account("github") or ""
455
461
 
456
462
  start_time = time.time()
457
463
  processed = 0
@@ -27,7 +27,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
27
  from http_api import api_get, api_post
28
28
 
29
29
  REPO_DIR = os.path.expanduser("~/social-autoposter")
30
- CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
30
+ # THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
31
+ # S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
32
+ # its hardcoded config path (the S4L-4H dead-path class on customer boxes).
33
+ import os as _cfg_os, sys as _cfg_sys
34
+ _cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
35
+ from config import config_path as _canonical_config_path, load_config
36
+ CONFIG_PATH = _canonical_config_path()
31
37
  REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
32
38
  CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
33
39
  REDDIT_MCP_CONFIG = os.path.expanduser("~/.claude/browser-agent-configs/reddit-agent-mcp.json")
@@ -110,9 +116,6 @@ def _release_browser_lease() -> None:
110
116
  pass
111
117
 
112
118
 
113
- def load_config():
114
- with open(CONFIG_PATH) as f:
115
- return json.load(f)
116
119
 
117
120
 
118
121
  def load_active_reddit_campaigns():
@@ -386,7 +389,11 @@ def get_recent_archetypes(platform, limit=3):
386
389
 
387
390
  def build_prompt(reply, recent_replies, config, excluded_authors, top_report="", prior_history_block="", meta_callout=None):
388
391
  """Build a minimal prompt for one reply."""
389
- reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "Deep_Ad1959")
392
+ # Resolve through the one account resolver (env -> config); never a hardcoded
393
+ # username. Empty means "unknown account" (the prompt just omits it) rather
394
+ # than silently impersonating the repo owner on a misconfigured install.
395
+ from account_resolver import resolve as _resolve_account
396
+ reddit_username = _resolve_account("reddit") or ""
390
397
  reply_json = json.dumps(reply, indent=2)
391
398
 
392
399
  # 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