@m13v/s4l 1.6.197-rc.10

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 (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ """Single source of truth for the engagement MODE (2026-06-26, dual-flag 2026-06-29).
3
+
4
+ The S4L pipeline drafts for TWO independently toggleable lanes:
5
+
6
+ - "personal_brand" (default ON): pure organic engagement to grow the user's
7
+ personal brand. The cycle is forced onto the persona project
8
+ (the config entry with `"persona": true`, normally
9
+ `enabled:false` so the promotion pick never touches it) and
10
+ replies are link-free.
11
+ - "promotion" (default OFF): the normal project/product-marketing pipeline.
12
+ The weighted pick (pick_project.py) chooses among enabled
13
+ projects; replies carry the project's link per the A/B gate.
14
+
15
+ Both can be ON at once. When both are ON the cycle splits **50/50**: each cycle
16
+ invocation flips a coin and runs that one cycle as either a persona (link-free)
17
+ cycle or a normal promotion cycle. The locked pipeline never changes — it just
18
+ reads the env vars env_exports() prints.
19
+
20
+ State lives in ONE small file, `$S4L_STATE_DIR/mode.json`:
21
+ {"personal_brand": true, "promotion": false, "mode": "personal_brand"}
22
+
23
+ The `"mode"` field is a DERIVED legacy mirror (personal_brand if that lane is on,
24
+ else promotion) kept only so any old reader that still does `data["mode"]` keeps
25
+ working. saps_mode.py is the only writer; it always writes all three keys.
26
+
27
+ Backward-compat read: a legacy file `{"mode": "promotion"}` (no flags) maps to
28
+ promotion-only; `{"mode": "personal_brand"}` maps to personal-only. A missing
29
+ file defaults to personal_brand ON / promotion OFF (the 2026-06-29 default flip).
30
+
31
+ The toggle takes effect WITHOUT touching any locked pipeline file: the unlocked
32
+ wrapper `skill/run-draft-and-publish.sh` evals `saps_mode.py env` right before it
33
+ invokes the locked `run-twitter-cycle.sh`, exporting the env vars the locked
34
+ pipeline already honors:
35
+ S4L_FORCE_PROJECT -> pick_project.py forces this exact project
36
+ (--project bypasses the enabled gate), so a
37
+ disabled persona is still selectable.
38
+ TWITTER_TAIL_LINK_RATE=0 -> twitter_post_plan.py ships every reply bare.
39
+
40
+ Usage:
41
+ saps_mode.py get # print derived legacy mode (compat)
42
+ saps_mode.py flags # print JSON {personal_brand, promotion}
43
+ saps_mode.py set personal_brand # legacy: personal-only (compat)
44
+ saps_mode.py set promotion # legacy: promotion-only (compat)
45
+ saps_mode.py set-flags <pb> <pr> # set both lanes, e.g. `set-flags 1 1`
46
+ saps_mode.py enable personal_brand|promotion
47
+ saps_mode.py disable personal_brand|promotion
48
+ saps_mode.py toggle personal_brand|promotion # flip ONE lane
49
+ saps_mode.py toggle # legacy whole-mode flip (compat)
50
+ saps_mode.py env # print shell `export` lines for this cycle
51
+ saps_mode.py persona-name # print the persona project name (or empty)
52
+ """
53
+
54
+ import json
55
+ import os
56
+ import random
57
+ import shlex
58
+ import sys
59
+ from pathlib import Path
60
+
61
+ PROMOTION = "promotion"
62
+ PERSONAL_BRAND = "personal_brand"
63
+ VALID_MODES = (PROMOTION, PERSONAL_BRAND)
64
+
65
+ # 2026-06-29 default flip: personal brand is the out-of-the-box lane; promotion
66
+ # is opt-in (asked for during setup).
67
+ DEFAULT_PERSONAL_BRAND = True
68
+ DEFAULT_PROMOTION = False
69
+
70
+ # Retained so old imports of `DEFAULT_MODE` don't break.
71
+ DEFAULT_MODE = PERSONAL_BRAND
72
+
73
+
74
+ def state_dir() -> Path:
75
+ # Mirrors mcp/src/index.ts sapsStateDir() and menubar/s4l_state.py state_dir().
76
+ return Path(
77
+ os.environ.get("S4L_STATE_DIR")
78
+ or (Path.home() / ".social-autoposter-mcp")
79
+ )
80
+
81
+
82
+ def mode_file() -> Path:
83
+ return state_dir() / "mode.json"
84
+
85
+
86
+ def config_path() -> Path:
87
+ # Match the locked pipeline's resolution: S4L_REPO_DIR/config.json when set,
88
+ # else the canonical ~/social-autoposter/config.json (what pick_project.py /
89
+ # project_topics.py read directly).
90
+ repo = os.environ.get("S4L_REPO_DIR")
91
+ if repo:
92
+ p = Path(repo) / "config.json"
93
+ if p.exists():
94
+ return p
95
+ return Path.home() / "social-autoposter" / "config.json"
96
+
97
+
98
+ def _coerce_bool(v) -> bool:
99
+ if isinstance(v, bool):
100
+ return v
101
+ if isinstance(v, (int, float)):
102
+ return v != 0
103
+ if isinstance(v, str):
104
+ return v.strip().lower() in ("1", "true", "yes", "on")
105
+ return False
106
+
107
+
108
+ def get_flags() -> dict:
109
+ """Current lane flags as {"personal_brand": bool, "promotion": bool}.
110
+
111
+ Read precedence: explicit flag keys win; else map a legacy {"mode": ...}
112
+ string; else the (new) default of personal-brand ON / promotion OFF.
113
+ """
114
+ try:
115
+ data = json.loads(mode_file().read_text())
116
+ except Exception:
117
+ data = None
118
+ if not isinstance(data, dict):
119
+ return {"personal_brand": DEFAULT_PERSONAL_BRAND, "promotion": DEFAULT_PROMOTION}
120
+
121
+ if "personal_brand" in data or "promotion" in data:
122
+ return {
123
+ "personal_brand": _coerce_bool(data.get("personal_brand", False)),
124
+ "promotion": _coerce_bool(data.get("promotion", False)),
125
+ }
126
+
127
+ # Legacy single-mode file.
128
+ legacy = str(data.get("mode") or "").strip()
129
+ if legacy == PERSONAL_BRAND:
130
+ return {"personal_brand": True, "promotion": False}
131
+ if legacy == PROMOTION:
132
+ return {"personal_brand": False, "promotion": True}
133
+ return {"personal_brand": DEFAULT_PERSONAL_BRAND, "promotion": DEFAULT_PROMOTION}
134
+
135
+
136
+ def _legacy_mode(flags: dict) -> str:
137
+ """Derived single-mode mirror: personal_brand wins when on (it's the default
138
+ lane), else promotion. Only used for the back-compat `mode` field/readers."""
139
+ return PERSONAL_BRAND if flags.get("personal_brand") else PROMOTION
140
+
141
+
142
+ def get_mode() -> str:
143
+ """Derived legacy mode string (compat shim for old callers)."""
144
+ return _legacy_mode(get_flags())
145
+
146
+
147
+ def write_flags(personal_brand: bool, promotion: bool) -> dict:
148
+ """Persist both lane flags atomically (plus the derived legacy `mode`)."""
149
+ flags = {"personal_brand": bool(personal_brand), "promotion": bool(promotion)}
150
+ payload = dict(flags)
151
+ payload["mode"] = _legacy_mode(flags)
152
+ d = state_dir()
153
+ d.mkdir(parents=True, exist_ok=True)
154
+ tmp = mode_file().with_suffix(".json.tmp")
155
+ tmp.write_text(json.dumps(payload))
156
+ tmp.replace(mode_file())
157
+ return flags
158
+
159
+
160
+ def set_mode(mode: str) -> str:
161
+ """Legacy single-mode setter: turns the named lane ON and the other OFF."""
162
+ mode = (mode or "").strip()
163
+ if mode not in VALID_MODES:
164
+ raise ValueError(f"invalid mode {mode!r}; expected one of {VALID_MODES}")
165
+ write_flags(personal_brand=(mode == PERSONAL_BRAND), promotion=(mode == PROMOTION))
166
+ return mode
167
+
168
+
169
+ def set_lane(lane: str, on: bool) -> dict:
170
+ lane = (lane or "").strip()
171
+ if lane not in VALID_MODES:
172
+ raise ValueError(f"invalid lane {lane!r}; expected one of {VALID_MODES}")
173
+ flags = get_flags()
174
+ flags[lane] = bool(on)
175
+ return write_flags(flags["personal_brand"], flags["promotion"])
176
+
177
+
178
+ def toggle_lane(lane: str) -> dict:
179
+ lane = (lane or "").strip()
180
+ if lane not in VALID_MODES:
181
+ raise ValueError(f"invalid lane {lane!r}; expected one of {VALID_MODES}")
182
+ flags = get_flags()
183
+ flags[lane] = not flags.get(lane)
184
+ return write_flags(flags["personal_brand"], flags["promotion"])
185
+
186
+
187
+ def _load_projects() -> list:
188
+ try:
189
+ cfg = json.loads(config_path().read_text())
190
+ return cfg.get("projects") or []
191
+ except Exception:
192
+ return []
193
+
194
+
195
+ def persona_name() -> str:
196
+ """Name of the persona project (the entry with `persona: true`), or ''.
197
+
198
+ First match wins. Returns '' when no persona is configured yet (the cycle
199
+ then falls back to the normal weighted pick — a safe no-op for the toggle).
200
+ """
201
+ for p in _load_projects():
202
+ if p.get("persona") is True:
203
+ return str(p.get("name") or "")
204
+ return ""
205
+
206
+
207
+ def _persona_env_lines() -> str:
208
+ name = persona_name()
209
+ if not name:
210
+ print(
211
+ "[saps_mode] personal_brand lane is on but no persona project "
212
+ "(persona:true) is configured; running the normal pick instead.",
213
+ file=sys.stderr,
214
+ )
215
+ return ""
216
+ return "\n".join(
217
+ [
218
+ f"export S4L_FORCE_PROJECT={shlex.quote(name)}",
219
+ "export TWITTER_TAIL_LINK_RATE=0",
220
+ # Explicit lane signal so the (locked) cycle can branch the draft
221
+ # directive + inject the persona corpus without re-deriving the lane
222
+ # from S4L_FORCE_PROJECT (which is also set by manual single-project
223
+ # MCP draft_cycle runs). Only the personal_brand lane sets this.
224
+ "export S4L_ACTIVE_LANE=personal_brand",
225
+ ]
226
+ )
227
+
228
+
229
+ def env_exports() -> str:
230
+ """Shell `export` lines for THIS cycle, safe to `eval`.
231
+
232
+ personal_brand only -> force the persona project + link-free replies.
233
+ promotion only -> nothing (normal weighted pick; persona is enabled:false).
234
+ both on -> 50/50 coin flip per cycle: half persona/link-free,
235
+ half normal promotion pick.
236
+ neither (shouldn't happen; default keeps personal on) -> behave like personal
237
+ so the cycle is never a silent no-op.
238
+ """
239
+ flags = get_flags()
240
+ pb = flags.get("personal_brand")
241
+ pr = flags.get("promotion")
242
+
243
+ if pb and pr:
244
+ # Both lanes active: this single cycle is one or the other, 50/50.
245
+ if random.random() < 0.5:
246
+ print("[saps_mode] both lanes on; this cycle -> personal_brand (50/50)",
247
+ file=sys.stderr)
248
+ return _persona_env_lines()
249
+ print("[saps_mode] both lanes on; this cycle -> promotion (50/50)",
250
+ file=sys.stderr)
251
+ return ""
252
+ if pb:
253
+ return _persona_env_lines()
254
+ if pr:
255
+ return ""
256
+ # Neither on (degenerate) -> don't leave the cycle dead; run personal.
257
+ print("[saps_mode] no lane enabled; defaulting this cycle to personal_brand.",
258
+ file=sys.stderr)
259
+ return _persona_env_lines()
260
+
261
+
262
+ def main(argv) -> int:
263
+ if not argv:
264
+ print(get_mode())
265
+ return 0
266
+ cmd = argv[0]
267
+ if cmd == "get":
268
+ print(get_mode())
269
+ return 0
270
+ if cmd == "flags":
271
+ print(json.dumps(get_flags()))
272
+ return 0
273
+ if cmd == "set":
274
+ if len(argv) < 2:
275
+ print("usage: saps_mode.py set <personal_brand|promotion>", file=sys.stderr)
276
+ return 2
277
+ try:
278
+ print(set_mode(argv[1]))
279
+ return 0
280
+ except ValueError as e:
281
+ print(str(e), file=sys.stderr)
282
+ return 2
283
+ if cmd == "set-flags":
284
+ if len(argv) < 3:
285
+ print("usage: saps_mode.py set-flags <personal_brand 0|1> <promotion 0|1>",
286
+ file=sys.stderr)
287
+ return 2
288
+ flags = write_flags(_coerce_bool(argv[1]), _coerce_bool(argv[2]))
289
+ print(json.dumps(flags))
290
+ return 0
291
+ if cmd in ("enable", "disable"):
292
+ if len(argv) < 2:
293
+ print(f"usage: saps_mode.py {cmd} <personal_brand|promotion>", file=sys.stderr)
294
+ return 2
295
+ try:
296
+ flags = set_lane(argv[1], on=(cmd == "enable"))
297
+ print(json.dumps(flags))
298
+ return 0
299
+ except ValueError as e:
300
+ print(str(e), file=sys.stderr)
301
+ return 2
302
+ if cmd == "toggle":
303
+ if len(argv) >= 2:
304
+ try:
305
+ flags = toggle_lane(argv[1])
306
+ print(json.dumps(flags))
307
+ return 0
308
+ except ValueError as e:
309
+ print(str(e), file=sys.stderr)
310
+ return 2
311
+ # Legacy whole-mode flip: personal<->promotion (mutually exclusive).
312
+ new = PROMOTION if get_mode() == PERSONAL_BRAND else PERSONAL_BRAND
313
+ print(set_mode(new))
314
+ return 0
315
+ if cmd == "env":
316
+ out = env_exports()
317
+ if out:
318
+ print(out)
319
+ return 0
320
+ if cmd == "persona-name":
321
+ print(persona_name())
322
+ return 0
323
+ print(f"unknown command: {cmd}", file=sys.stderr)
324
+ return 2
325
+
326
+
327
+ if __name__ == "__main__":
328
+ raise SystemExit(main(sys.argv[1:]))